Merge remote-tracking branch 'origin/main' into brennanb2025/contextual-feature-tours

# Conflicts:
#	docs/reference/feature-discovery-interaction-tracking.md
#	src/main/persistence.test.ts
#	src/main/persistence.ts
#	src/main/runtime/rpc/dispatcher-feature-interactions.test.ts
#	src/main/runtime/rpc/dispatcher.ts
#	src/main/runtime/rpc/methods/client-ui.test.ts
#	src/main/runtime/rpc/methods/client-ui.ts
#	src/renderer/src/App.tsx
#	src/renderer/src/components/automations/AutomationsPage.tsx
#	src/renderer/src/components/browser-pane/BrowserPane.tsx
#	src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts
#	src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
#	src/renderer/src/components/settings/NotificationsPane.tsx
#	src/renderer/src/components/sidebar/AddRepoSetupStep.tsx
#	src/renderer/src/store/slices/browser.ts
#	src/renderer/src/store/slices/diffComments.ts
#	src/renderer/src/store/slices/tabs.ts
#	src/renderer/src/store/slices/terminals.ts
#	src/renderer/src/store/slices/ui.test.ts
#	src/renderer/src/store/slices/ui.ts
#	src/renderer/src/store/slices/workspace-space.ts
#	src/renderer/src/web/web-preload-api.test.ts
#	src/shared/constants.ts
#	src/shared/feature-interactions.test.ts
#	src/shared/feature-interactions.ts
#	src/shared/types.ts
This commit is contained in:
brennanb2025
2026-05-28 14:55:23 -07:00
795 changed files with 69862 additions and 9404 deletions
+6
View File
@@ -113,6 +113,9 @@ const OSC_TITLE_RE = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g
* normalizeTerminalChunk pass.
*/
export function extractLastOscTitle(data: string): string | null {
if (!data.includes('\x1b]')) {
return null
}
let last: string | null = null
for (const m of data.matchAll(OSC_TITLE_RE)) {
last = m[2]
@@ -131,6 +134,9 @@ export function extractLastOscTitle(data: string): string | null {
* spinner-miss follow-up.
*/
export function extractAllOscTitles(data: string): string[] {
if (!data.includes('\x1b]')) {
return []
}
const titles: string[] = []
for (const m of data.matchAll(OSC_TITLE_RE)) {
titles.push(m[2])
+214 -2
View File
@@ -42,6 +42,9 @@ describe('shared agent-hook-listener', () => {
expect(resolveHookSource('/hook/antigravity')).toBe('antigravity')
expect(resolveHookSource('/hook/grok')).toBe('grok')
expect(resolveHookSource('/hook/hermes')).toBe('hermes')
expect(resolveHookSource('/hook/pi')).toBe('pi')
expect(resolveHookSource('/hook/omp')).toBe('omp')
expect(resolveHookSource('/hook/command-code')).toBe('command-code')
expect(resolveHookSource('/hook/unknown')).toBeNull()
expect(resolveHookSource('/')).toBeNull()
})
@@ -77,6 +80,150 @@ describe('shared agent-hook-listener', () => {
expect(event!.payload.agentType).toBe('claude')
})
it('normalizes Gemini BeforeTool to working with tool fields', () => {
const event = normalizeHookPayload(
state,
'gemini',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'BeforeTool',
tool_name: 'read_file',
args: { file_path: 'src/index.ts' }
}
},
'production'
)
expect(event?.payload.state).toBe('working')
expect(event?.payload.agentType).toBe('gemini')
expect(event?.payload.toolName).toBe('read_file')
expect(event?.payload.toolInput).toBe('src/index.ts')
})
it('normalizes OMP Pi-compatible hooks with OMP attribution', () => {
const event = normalizeHookPayload(
state,
'omp',
{
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt',
env: 'production',
version: '1',
payload: {
hook_event_name: 'before_agent_start',
prompt: 'wire omp status'
}
},
'production'
)
expect(event?.payload).toMatchObject({
state: 'working',
prompt: 'wire omp status',
agentType: 'omp'
})
const tool = normalizeHookPayload(
state,
'omp',
{
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt',
env: 'production',
version: '1',
payload: {
hook_event_name: 'tool_call',
tool_name: 'bash',
tool_input: { command: 'pnpm test' }
}
},
'production'
)
expect(tool?.payload).toMatchObject({
state: 'working',
prompt: 'wire omp status',
agentType: 'omp',
toolName: 'bash',
toolInput: 'pnpm test'
})
})
it('normalizes Command Code hooks and reads turn text from the transcript', () => {
const tmpDir = mkdtempSync(join(tmpdir(), 'orca-command-code-transcript-'))
const transcriptPath = join(tmpDir, 'transcript.jsonl')
try {
writeFileSync(
transcriptPath,
`${[
JSON.stringify({
role: 'user',
content: [{ type: 'text', text: 'Run pwd and report it' }]
}),
JSON.stringify({
role: 'assistant',
content: [
{ type: 'reasoning', text: 'Need to run pwd.' },
{ type: 'text', text: 'The output is /tmp/project.' }
]
})
].join('\n')}\n`
)
const tool = normalizeHookPayload(
state,
'command-code',
{
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt',
env: 'production',
version: '1',
payload: {
hook_event_name: 'PreToolUse',
transcript_path: transcriptPath,
tool_name: 'shell_command',
tool_input: { command: 'pwd' }
}
},
'production'
)
expect(tool?.payload).toMatchObject({
state: 'working',
prompt: 'Run pwd and report it',
agentType: 'command-code',
toolName: 'shell_command',
toolInput: 'pwd'
})
const done = normalizeHookPayload(
state,
'command-code',
{
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt',
env: 'production',
version: '1',
payload: {
hook_event_name: 'Stop',
transcript_path: transcriptPath
}
},
'production'
)
expect(done?.payload).toMatchObject({
state: 'done',
prompt: 'Run pwd and report it',
agentType: 'command-code',
lastAssistantMessage: 'The output is /tmp/project.'
})
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
})
it('trims surrounding whitespace from extracted prompt text', () => {
const event = normalizeHookPayload(
state,
@@ -176,6 +323,29 @@ describe('shared agent-hook-listener', () => {
})
})
it('normalizes Antigravity events even when the hook body is empty', () => {
const started = normalizeHookPayload(
state,
'antigravity',
{
paneKey: PANE_KEY,
tabId: 'tab-1',
hook_event_name: 'PreInvocation',
payload: {}
},
'production'
)
// Why: Antigravity can invoke managed hooks without stdin. The wrapper
// posts `{}` in that case, and the event name is still enough to keep the
// visible status alive.
expect(started?.payload).toMatchObject({
state: 'working',
prompt: '',
agentType: 'antigravity'
})
})
it('reads Antigravity user requests from the transcript', () => {
const tmpDir = mkdtempSync(join(tmpdir(), 'orca-antigravity-prompt-'))
const transcriptPath = join(tmpDir, 'transcript.jsonl')
@@ -381,7 +551,7 @@ describe('shared agent-hook-listener', () => {
}
})
it('normalizes Antigravity Stop to done even when fullyIdle is false', () => {
it('keeps Antigravity Stop working while fullyIdle is false', () => {
const event = normalizeHookPayload(
state,
'antigravity',
@@ -394,11 +564,47 @@ describe('shared agent-hook-listener', () => {
)
expect(event?.payload).toMatchObject({
state: 'done',
state: 'working',
agentType: 'antigravity'
})
})
it('keeps Antigravity tool hooks active after a non-idle Stop for the same transcript', () => {
const transcriptPath = '/tmp/antigravity-non-idle-transcript.jsonl'
const stop = normalizeHookPayload(
state,
'antigravity',
{
paneKey: PANE_KEY,
hook_event_name: 'Stop',
payload: { transcriptPath, fullyIdle: false }
},
'production'
)
expect(stop?.payload.state).toBe('working')
const nextTool = normalizeHookPayload(
state,
'antigravity',
{
paneKey: PANE_KEY,
hook_event_name: 'PostToolUse',
payload: {
transcriptPath,
toolCall: { name: 'run_command', args: { CommandLine: 'pwd' } }
}
},
'production'
)
expect(nextTool?.payload).toMatchObject({
state: 'working',
agentType: 'antigravity',
toolName: 'run_command',
toolInput: 'pwd'
})
})
it('ignores late Antigravity tool hooks after a completed Stop for the same transcript', () => {
const transcriptPath = '/tmp/antigravity-transcript.jsonl'
const done = normalizeHookPayload(
@@ -446,6 +652,12 @@ describe('shared agent-hook-listener', () => {
}
})
).toBe(false)
expect(
hasPendingAgentResultText('antigravity', {
hook_event_name: 'Stop',
payload: { fullyIdle: false, transcriptPath: '/tmp/antigravity-transcript.jsonl' }
})
).toBe(false)
})
it('normalizes Grok hookEventName payloads and keeps prompt across tool events', () => {
+209 -9
View File
@@ -650,6 +650,68 @@ function readLastUserPromptFromTranscript(transcriptPath: unknown): string | und
return readLastTextFromTranscriptOnce(transcriptPath, extractUserPromptTextFromLine)
}
function extractCommandCodeUserPromptFromLine(line: string): string | undefined {
let entry: unknown
try {
entry = JSON.parse(line)
} catch {
return undefined
}
if (typeof entry !== 'object' || entry === null) {
return undefined
}
const record = entry as Record<string, unknown>
return record.role === 'user' ? extractAssistantContentText(record.content) : undefined
}
function extractCommandCodeAssistantTextFromLine(line: string): string | undefined {
let entry: unknown
try {
entry = JSON.parse(line)
} catch {
return undefined
}
if (typeof entry !== 'object' || entry === null) {
return undefined
}
const record = entry as Record<string, unknown>
if (record.role !== 'assistant') {
return undefined
}
const content = record.content
if (typeof content === 'string' && content.trim().length > 0) {
return content
}
if (Array.isArray(content)) {
const textPart = content.find(
(part) =>
typeof part === 'object' &&
part !== null &&
(part as Record<string, unknown>).type === 'text' &&
typeof (part as Record<string, unknown>).text === 'string' &&
((part as Record<string, unknown>).text as string).trim().length > 0
) as Record<string, unknown> | undefined
if (typeof textPart?.text === 'string') {
return textPart.text
}
}
return extractAssistantContentText(content)
}
function readLastCommandCodeUserPromptFromTranscript(transcriptPath: unknown): string | undefined {
if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) {
return undefined
}
return readLastTextFromTranscriptOnce(transcriptPath, extractCommandCodeUserPromptFromLine)
}
function readLastCommandCodeAssistantFromTranscript(transcriptPath: unknown): string | undefined {
if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) {
return undefined
}
return readLastTextFromTranscriptOnce(transcriptPath, extractCommandCodeAssistantTextFromLine)
}
function parseHookBodyPayloadRecord(body: unknown): Record<string, unknown> | null {
if (typeof body !== 'object' || body === null) {
return null
@@ -739,6 +801,9 @@ export function hasPendingAgentResultText(source: AgentHookSource, body: unknown
record.hook_event_name ??
record.hookEventName
if (source === 'antigravity' && eventName === 'Stop') {
if (isAntigravityStopStillBusy(record)) {
return false
}
const transcriptPath = record.transcriptPath ?? record.transcript_path
return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0
}
@@ -906,7 +971,12 @@ function extractGeminiToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
if (eventName === 'PreToolUse' || eventName === 'PostToolUse' || eventName === 'AfterTool') {
if (
eventName === 'BeforeTool' ||
eventName === 'AfterTool' ||
eventName === 'PreToolUse' ||
eventName === 'PostToolUse'
) {
const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name')
const toolInput =
deriveToolInputPreview(toolName, hookPayload.tool_input) ??
@@ -957,6 +1027,9 @@ function extractAntigravityToolFields(
)
}
if (eventName === 'Stop') {
if (isAntigravityStopStillBusy(hookPayload)) {
return {}
}
const message =
readString(hookPayload, 'last_assistant_message') ??
readLastAssistantFromTranscript(hookPayload.transcriptPath ?? hookPayload.transcript_path)
@@ -1342,6 +1415,47 @@ function extractDroidToolFields(
return {}
}
function extractCommandCodeToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
if (eventName === 'PreToolUse' || eventName === 'PostToolUse') {
const toolName =
readString(hookPayload, 'tool_name') ??
readString(hookPayload, 'toolName') ??
readString(hookPayload, 'tool_display_name')
const toolInput =
deriveToolInputPreview(toolName, hookPayload.tool_input) ??
deriveFallbackToolInputPreview(hookPayload.tool_input)
const update: ToolSnapshot = toolUpdate(
{ toolName, toolInput },
{ hasToolInputField: hasOwnField(hookPayload, 'tool_input') }
)
if (eventName === 'PostToolUse') {
const responseText =
extractToolResponseText(hookPayload.tool_response) ??
extractToolResponseText(hookPayload.tool_output)
if (responseText) {
update.lastAssistantMessage = responseText
}
}
return update
}
if (eventName === 'Stop') {
const direct = readString(hookPayload, 'last_assistant_message')
if (direct) {
return { lastAssistantMessage: direct }
}
const fromTranscript = readLastCommandCodeAssistantFromTranscript(
hookPayload.transcript_path ?? hookPayload.transcriptPath
)
if (fromTranscript) {
return { lastAssistantMessage: fromTranscript }
}
}
return {}
}
function normalizeHookEventName(value: unknown): string {
if (typeof value !== 'string') {
return ''
@@ -1529,9 +1643,12 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
case 'cursor':
return eventName === 'beforeSubmitPrompt' || eventName === 'sessionStart'
case 'pi':
case 'omp':
return eventName === 'before_agent_start'
case 'droid':
return eventName === 'UserPromptSubmit'
case 'command-code':
return false
case 'grok':
return isGrokEvent(eventName, 'user_prompt_submit')
case 'copilot': {
@@ -1569,9 +1686,12 @@ function extractToolFields(
case 'cursor':
return extractCursorToolFields(eventName, hookPayload)
case 'pi':
case 'omp':
return extractPiToolFields(eventName, hookPayload)
case 'droid':
return extractDroidToolFields(eventName, hookPayload)
case 'command-code':
return extractCommandCodeToolFields(eventName, hookPayload)
case 'grok':
return extractGrokToolFields(eventName, hookPayload)
case 'copilot':
@@ -1641,8 +1761,11 @@ function normalizeGeminiEvent(
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
// Why: Gemini CLI's native pre-tool event is BeforeTool. PreToolUse/PostToolUse
// remain accepted for legacy Antigravity-compatible payloads on this endpoint.
const stateName =
eventName === 'BeforeAgent' ||
eventName === 'BeforeTool' ||
eventName === 'AfterTool' ||
eventName === 'PreToolUse' ||
eventName === 'PostToolUse'
@@ -1680,6 +1803,10 @@ function isAntigravityFeedbackTool(toolName: string | undefined): boolean {
return toolName === 'ask_question' || toolName === 'ask_permission'
}
function isAntigravityStopStillBusy(hookPayload: Record<string, unknown>): boolean {
return hookPayload.fullyIdle === false || hookPayload.fully_idle === false
}
function normalizeAntigravityEvent(
state: HookListenerState,
eventName: unknown,
@@ -1701,11 +1828,14 @@ function normalizeAntigravityEvent(
}
const toolName = readAntigravityToolCall(hookPayload).toolName
const stopStillBusy = eventName === 'Stop' && isAntigravityStopStillBusy(hookPayload)
const stateName =
eventName === 'PreToolUse' && isAntigravityFeedbackTool(toolName)
? 'waiting'
: eventName === 'Stop'
? 'done'
? stopStillBusy
? 'working'
: 'done'
: eventName === 'PreInvocation' ||
eventName === 'PostInvocation' ||
eventName === 'PreToolUse' ||
@@ -1742,7 +1872,10 @@ function normalizeAntigravityEvent(
lastAssistantMessage: snapshot.lastAssistantMessage
})
)
if (eventName === 'Stop' && transcriptPath) {
// Why: Antigravity can emit Stop with fullyIdle=false between tool steps.
// Only a fully idle Stop is terminal; otherwise the sidebar would bounce
// done -> working during tool-heavy turns and ignore later tool updates.
if (eventName === 'Stop' && !stopStillBusy && transcriptPath) {
state.antigravityCompletedTranscriptByPaneKey.set(paneKey, transcriptPath)
}
return payload
@@ -1956,8 +2089,9 @@ function normalizeCopilotEvent(
)
}
function normalizePiEvent(
function normalizePiCompatibleEvent(
state: HookListenerState,
agentType: 'pi' | 'omp',
eventName: unknown,
promptText: string,
paneKey: string,
@@ -1982,17 +2116,17 @@ function normalizePiEvent(
const snapshot = resolveToolState(
state,
paneKey,
extractToolFields('pi', eventName, hookPayload),
{ resetOnNewTurn: isNewTurnEvent('pi', eventName) }
extractToolFields(agentType, eventName, hookPayload),
{ resetOnNewTurn: isNewTurnEvent(agentType, eventName) }
)
return parseAgentStatusPayload(
JSON.stringify({
state: stateName,
prompt: resolvePrompt(state, paneKey, promptText, {
resetOnNewTurn: isNewTurnEvent('pi', eventName)
resetOnNewTurn: isNewTurnEvent(agentType, eventName)
}),
agentType: 'pi',
agentType,
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
lastAssistantMessage: snapshot.lastAssistantMessage
@@ -2071,6 +2205,50 @@ function normalizeDroidEvent(
)
}
function normalizeCommandCodeEvent(
state: HookListenerState,
eventName: unknown,
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
const stateName =
eventName === 'PreToolUse' || eventName === 'PostToolUse'
? 'working'
: eventName === 'Stop'
? 'done'
: null
if (!stateName) {
return null
}
const effectivePrompt =
promptText ||
readLastCommandCodeUserPromptFromTranscript(
hookPayload.transcript_path ?? hookPayload.transcriptPath
) ||
''
const snapshot = resolveToolState(
state,
paneKey,
extractToolFields('command-code', eventName, hookPayload),
{ resetOnNewTurn: isNewTurnEvent('command-code', eventName) }
)
return parseAgentStatusPayload(
JSON.stringify({
state: stateName,
prompt: resolvePrompt(state, paneKey, effectivePrompt, {
resetOnNewTurn: isNewTurnEvent('command-code', eventName)
}),
agentType: 'command-code',
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
lastAssistantMessage: snapshot.lastAssistantMessage
})
)
}
function normalizeGrokEvent(
state: HookListenerState,
eventName: unknown,
@@ -2274,11 +2452,31 @@ export function normalizeHookPayload(
payload = normalizeCursorEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
break
case 'pi':
payload = normalizePiEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
payload = normalizePiCompatibleEvent(
state,
'pi',
eventName,
promptText,
paneKey,
hookPayloadRecord
)
break
case 'omp':
payload = normalizePiCompatibleEvent(
state,
'omp',
eventName,
promptText,
paneKey,
hookPayloadRecord
)
break
case 'droid':
payload = normalizeDroidEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
break
case 'command-code':
payload = normalizeCommandCodeEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
break
case 'grok':
payload = normalizeGrokEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
break
@@ -2325,7 +2523,9 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly<Record<string, AgentHookSource>>
'/hook/opencode': 'opencode',
'/hook/cursor': 'cursor',
'/hook/pi': 'pi',
'/hook/omp': 'omp',
'/hook/droid': 'droid',
'/hook/command-code': 'command-code',
'/hook/grok': 'grok',
'/hook/copilot': 'copilot',
'/hook/hermes': 'hermes'
+2
View File
@@ -38,7 +38,9 @@ export type AgentHookSource =
| 'opencode'
| 'cursor'
| 'pi'
| 'omp'
| 'droid'
| 'command-code'
| 'grok'
| 'copilot'
| 'hermes'
+1
View File
@@ -10,6 +10,7 @@ export const AGENT_HOOK_TARGETS = [
'antigravity',
'cursor',
'droid',
'command-code',
'grok',
'copilot',
'hermes'
+2
View File
@@ -19,6 +19,7 @@ const TUI_AGENT_KIND_BY_AGENT = {
autohand: 'autohand',
opencode: 'opencode',
pi: 'pi',
omp: 'omp',
gemini: 'gemini',
antigravity: 'antigravity',
aider: 'aider',
@@ -30,6 +31,7 @@ const TUI_AGENT_KIND_BY_AGENT = {
aug: 'aug',
cline: 'cline',
codebuff: 'codebuff',
'command-code': 'command-code',
continue: 'continue',
cursor: 'cursor',
droid: 'droid',
@@ -21,4 +21,20 @@ describe('agent process recognition', () => {
expect(isExpectedAgentProcess('/usr/local/bin/claude', 'claude')).toBe(true)
expect(isExpectedAgentProcess('powershell.exe', 'claude')).toBe(false)
})
it('recognizes Command Code without classifying Windows cmd.exe as an agent', () => {
expect(recognizeAgentProcess('command-code')).toEqual({
agent: 'command-code',
processName: 'command-code'
})
expect(
recognizeAgentProcess(String.raw`C:\Users\dev\AppData\Roaming\npm\command-code.cmd`)
).toEqual({
agent: 'command-code',
processName: 'command-code'
})
expect(isRecognizedAgentType('command-code')).toBe(true)
expect(isRecognizedAgentType('cmd.exe')).toBe(false)
expect(recognizeAgentProcess('cmd.exe')).toBeNull()
})
})
+2
View File
@@ -21,7 +21,9 @@ export type WellKnownAgentType =
| 'copilot'
| 'aider'
| 'pi'
| 'omp'
| 'droid'
| 'command-code'
| 'grok'
| 'hermes'
| 'unknown'
+2 -4
View File
@@ -21,15 +21,13 @@ export const AGENTS_STEPS: readonly AgentsStep[] = [
id: 'statuses',
name: 'Visibility',
subtitle: 'Agent Visibility',
description:
'Track every running agent across your workspaces, including who is working, waiting, or needs you.'
description: 'Know which agents are working, waiting, live, or blocked.'
},
{
id: 'orchestration',
name: 'Orchestration',
subtitle: 'Orchestration',
description:
'Enable agents to manage and coordinate Orca workspaces so larger tasks can move to completion.'
description: 'Enable agents to manage and coordinate Orca workspaces to execute larger tasks.'
},
{
id: 'usage',
+12
View File
@@ -0,0 +1,12 @@
// A NUL byte in the first chunk is git's own heuristic for "this is binary".
const BINARY_SNIFF_BYTES = 8192
export function isBinaryBuffer(buffer: Buffer): boolean {
const len = Math.min(buffer.length, BINARY_SNIFF_BYTES)
for (let i = 0; i < len; i += 1) {
if (buffer[i] === 0) {
return true
}
}
return false
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import {
buildBranchNamePrompt,
humanizeBranchSlug,
isAutoGeneratedCreatureBranchName,
sanitizeBranchSlug
} from './branch-name-from-work'
describe('sanitizeBranchSlug', () => {
it('lowercases and kebab-cases free-form model output', () => {
expect(sanitizeBranchSlug('Fix the auth bug')).toBe('fix-the-auth-bug')
})
it('caps at four words', () => {
expect(sanitizeBranchSlug('add dark mode toggle to settings page')).toBe('add-dark-mode-toggle')
})
it('honors a custom word cap', () => {
expect(sanitizeBranchSlug('add dark mode toggle', 2)).toBe('add-dark')
})
it('collapses punctuation, quotes, and slashes into single hyphens', () => {
expect(sanitizeBranchSlug('"feat/login_flow!!"')).toBe('feat-login-flow')
})
it('returns empty string when nothing usable remains', () => {
expect(sanitizeBranchSlug(' !!! ___ ')).toBe('')
expect(sanitizeBranchSlug('')).toBe('')
})
})
describe('isAutoGeneratedCreatureBranchName', () => {
it('matches a creature name case-insensitively', () => {
expect(isAutoGeneratedCreatureBranchName('Nautilus')).toBe(true)
expect(isAutoGeneratedCreatureBranchName('octopus')).toBe(true)
})
it('matches numbered collision variants', () => {
expect(isAutoGeneratedCreatureBranchName('Nautilus-2')).toBe(true)
expect(isAutoGeneratedCreatureBranchName('Seahorse-17')).toBe(true)
})
it('rejects user-chosen and work-derived names', () => {
expect(isAutoGeneratedCreatureBranchName('fix-auth-bug')).toBe(false)
expect(isAutoGeneratedCreatureBranchName('my-feature')).toBe(false)
expect(isAutoGeneratedCreatureBranchName('')).toBe(false)
})
})
describe('humanizeBranchSlug', () => {
it('turns a kebab slug into a readable label', () => {
expect(humanizeBranchSlug('supported-models-list')).toBe('Supported models list')
expect(humanizeBranchSlug('fix-auth')).toBe('Fix auth')
})
it('returns empty string for an empty slug', () => {
expect(humanizeBranchSlug('')).toBe('')
})
})
describe('buildBranchNamePrompt', () => {
it('includes the user prompt and omits the assistant section when absent', () => {
const prompt = buildBranchNamePrompt({ firstPrompt: 'Add a logout button' })
expect(prompt).toContain('Add a logout button')
expect(prompt).not.toContain("Agent's initial response")
})
it('includes the assistant response when present', () => {
const prompt = buildBranchNamePrompt({
firstPrompt: 'Add a logout button',
assistantMessage: "I'll wire it into the header."
})
expect(prompt).toContain("Agent's initial response")
expect(prompt).toContain("I'll wire it into the header.")
})
it('appends custom branch-name instructions when present', () => {
const prompt = buildBranchNamePrompt(
{ firstPrompt: 'Add a logout button' },
'Prefer product nouns.'
)
expect(prompt).toContain('Additional user instructions:')
expect(prompt).toContain('Prefer product nouns.')
})
})
+99
View File
@@ -0,0 +1,99 @@
import { MARINE_CREATURES } from './marine-creatures'
// Why: the work-derived branch name stays short on purpose — long, sentence-like
// branches read worse than the creature name they replace. Two-to-four words is
// the sweet spot the feature targets.
export const MAX_BRANCH_NAME_WORDS = 4
const MIN_BRANCH_NAME_WORDS = 2
const CREATURE_NAMES_LOWER = new Set(MARINE_CREATURES.map((name) => name.toLowerCase()))
function normalizeName(name: string): string {
return name.trim().toLowerCase()
}
/**
* True when `branchLeaf` (the segment after any `prefix/`) is an Orca
* auto-generated creature name, e.g. `Nautilus` or `Octopus-2`. Used as the
* eligibility gate so auto-rename only ever overwrites a name Orca itself
* picked — never a branch the user named deliberately.
*/
export function isAutoGeneratedCreatureBranchName(branchLeaf: string): boolean {
const normalized = normalizeName(branchLeaf)
if (!normalized) {
return false
}
// Collision suffixes (`-2`, `-3`, …) are appended at creation; strip one
// before checking membership so numbered creatures still qualify.
const base = normalized.replace(/-\d+$/, '')
return CREATURE_NAMES_LOWER.has(base)
}
/**
* Turn raw model output into a safe, short kebab-case branch leaf. Returns ''
* when nothing usable remains, which callers treat as "skip the rename".
*/
export function sanitizeBranchSlug(raw: string, maxWords = MAX_BRANCH_NAME_WORDS): string {
const words = raw
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.split('-')
.filter(Boolean)
if (words.length === 0) {
return ''
}
return words.slice(0, maxWords).join('-')
}
/**
* Turn a branch slug into a readable workspace display label, e.g.
* `supported-models-list` → `Supported models list`. Used to update the
* sidebar name alongside the branch so the two stay in sync.
*/
export function humanizeBranchSlug(slug: string): string {
const joined = slug.split('-').filter(Boolean).join(' ')
if (!joined) {
return ''
}
return joined.charAt(0).toUpperCase() + joined.slice(1)
}
export type BranchNameWorkContext = {
/** The user's first prompt to the agent in this workspace. */
firstPrompt: string
/** The agent's first response, when it has already arrived. Optional because
* the rename fires as soon as work begins, before a reply may exist. */
assistantMessage?: string
}
/**
* Build the text-generation prompt that asks the configured agent to summarize
* the work into a branch name. Kept in shared so the prompt is identical across
* local and SSH generation targets.
*/
export function buildBranchNamePrompt(
context: BranchNameWorkContext,
customInstructions = ''
): string {
const sections = [
'Generate a git branch name that summarizes the coding task described below.',
'Rules:',
`- Use between ${MIN_BRANCH_NAME_WORDS} and ${MAX_BRANCH_NAME_WORDS} words.`,
'- Lowercase kebab-case only (words joined by single hyphens).',
'- No slashes, no prefixes, no quotes, no trailing punctuation.',
'- Describe the work itself, not the agent or the repository.',
'- Output ONLY the branch name on a single line, nothing else.',
'',
'User request:',
context.firstPrompt.trim()
]
const assistant = context.assistantMessage?.trim()
if (assistant) {
sections.push('', "Agent's initial response:", assistant)
}
const instructions = customInstructions.trim()
if (instructions) {
sections.push('', 'Additional user instructions:', instructions)
}
return sections.join('\n')
}
@@ -23,3 +23,7 @@ export type ComputerUsePermissionSetupResult = {
permissions?: ComputerUsePermissionState[]
nextStep?: string | null
}
export type ComputerUsePermissionResetResult = ComputerUsePermissionStatusResult & {
bundleId: string | null
}
+11 -1
View File
@@ -14,12 +14,22 @@ describe('getDefaultSettings', () => {
expect(getDefaultSettings('/tmp').terminalUseSeparateLightTheme).toBe(true)
})
it('enables AI commit messages by default without pinning a separate agent', () => {
it('enables Source Control AI by default without pinning a separate agent', () => {
expect(getDefaultSettings('/tmp').commitMessageAi).toMatchObject({
enabled: true,
agentId: null,
selectedModelByAgent: {}
})
expect(getDefaultSettings('/tmp').sourceControlAi).toMatchObject({
enabled: true,
agentId: null,
selectedModelByAgent: {},
instructionsByOperation: {
commitMessage: '',
pullRequest: '',
branchName: ''
}
})
})
})
+9
View File
@@ -16,6 +16,7 @@ import type { VoiceSettings } from './speech-types'
import { cloneDefaultWorkspaceStatuses } from './workspace-statuses'
import { TASK_PROVIDERS } from './task-providers'
import { DEFAULT_WORKTREE_CARD_PROPERTIES } from './worktree-card-properties'
import { getDefaultSourceControlAiSettings } from './source-control-ai'
export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults'
export {
@@ -153,6 +154,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
nestWorkspaces: true,
workspaceDirHistory: [],
refreshLocalBaseRefOnWorktreeCreate: false,
autoRenameBranchFromWork: false,
branchPrefix: 'git-username',
branchPrefixCustom: '',
enableGitHubAttribution: false,
@@ -250,6 +252,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
opencodeWorkspaceId: '',
geminiCliOAuthEnabled: false,
agentCmdOverrides: {},
agentStatusHooksEnabled: true,
keepComputerAwakeWhileAgentsRun: false,
// Why: 'auto' runs a layout-aware probe at boot (see
// src/renderer/src/lib/keyboard-layout/*) that picks 'true' for US and
@@ -259,6 +262,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
// the box (issue #903) while US users keep Option-as-Alt readline chords.
terminalMacOptionAsAlt: 'auto',
terminalMacOptionAsAltMigrated: false,
terminalJISYenToBackslash: false,
experimentalMobile: false,
// Why: indefinite hold by default — the desktop "Restore" banner is the
// explicit return-to-desktop-size action, no wall-clock guess.
@@ -269,6 +273,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
experimentalPet: false,
experimentalActivity: false,
experimentalActivityDefaultedOffForAllUsers: true,
experimentalTerminalAttention: false,
experimentalWorktreeSymlinks: false,
// Why: local desktop remains the default server until the user explicitly
// selects a saved runtime environment.
@@ -298,6 +303,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
customPrompt: '',
customAgentCommand: ''
},
sourceControlAi: getDefaultSourceControlAiSettings(),
voice: getDefaultVoiceSettings()
}
}
@@ -329,6 +335,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
return {
schemaVersion: SCHEMA_VERSION,
repos: [],
projectGroups: [],
sparsePresetsByRepo: {},
worktreeMeta: {},
worktreeLineageById: {},
@@ -351,6 +358,8 @@ export function getDefaultUIState(): PersistedUIState {
lastActiveRepoId: null,
lastActiveWorktreeId: null,
sidebarWidth: 280,
rightSidebarOpen: true,
rightSidebarTab: 'explorer',
rightSidebarWidth: 350,
groupBy: 'repo',
sortBy: 'recent',
+58
View File
@@ -1,3 +1,5 @@
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
FEATURE_INTERACTIONS,
@@ -10,6 +12,11 @@ type DefinedFeatureInteractionId = (typeof FEATURE_INTERACTIONS)[number]['id']
type MissingFeatureInteractionId = Exclude<FeatureInteractionId, DefinedFeatureInteractionId>
type ExtraFeatureInteractionId = Exclude<DefinedFeatureInteractionId, FeatureInteractionId>
const REPO_ROOT = join(__dirname, '..', '..')
const SOURCE_ROOTS = ['src/main', 'src/renderer/src', 'src/preload']
const PRODUCTION_FILE_PATTERN = /\.(ts|tsx)$/
const TEST_FILE_PATTERN = /(?:^|\.)(test|spec)\.(ts|tsx)$/
describe('feature interactions', () => {
it('defines local interaction semantics for product education features', () => {
const catalogMatchesPublicUnion: [
@@ -95,4 +102,55 @@ describe('feature interactions', () => {
)
).toBe(false)
})
it('keeps every catalog id wired to a production writer', () => {
const productionText = collectProductionSourceText()
const missingWriters = FEATURE_INTERACTIONS.map((feature) => feature.id).filter((id) => {
const escaped = escapeRegExp(id)
const directRecord = new RegExp(
`recordFeatureInteraction(?:\\?\\.)?\\(\\s*['"]${escaped}['"]`
)
const runtimeMappingReturn = new RegExp(`return[^\\n]*['"]${escaped}['"]`)
return !directRecord.test(productionText) && !runtimeMappingReturn.test(productionText)
})
expect(missingWriters).toEqual([])
})
})
function collectProductionSourceText(): string {
const files = SOURCE_ROOTS.flatMap((root) => collectSourceFiles(join(REPO_ROOT, root)))
return files
.sort()
.map((file) => readFileSync(file, 'utf8'))
.join('\n')
}
function collectSourceFiles(directory: string): string[] {
const files: string[] = []
for (const entry of readdirSync(directory)) {
const path = join(directory, entry)
const stats = statSync(path)
if (stats.isDirectory()) {
if (entry === 'node_modules' || entry === 'dist' || entry === 'out') {
continue
}
files.push(...collectSourceFiles(path))
continue
}
const repoRelativePath = relative(REPO_ROOT, path)
if (!PRODUCTION_FILE_PATTERN.test(entry) || TEST_FILE_PATTERN.test(entry)) {
continue
}
// Why: the catalog itself proves the id exists, not that runtime code writes it.
if (repoRelativePath === 'src/shared/feature-interactions.ts') {
continue
}
files.push(path)
}
return files
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
+2 -2
View File
@@ -52,8 +52,8 @@ export type FeatureInteractionState = Partial<
Record<FeatureInteractionId, FeatureInteractionRecord>
>
// Why: education state has three separate meanings; see
// docs/reference/feature-education-state.md before adding or reusing ids here.
// Why: these ids become persisted product state; see
// docs/reference/feature-discovery-interaction-tracking.md before changing them.
export const FEATURE_INTERACTIONS = [
{
id: 'workspace-board',
+2 -2
View File
@@ -27,7 +27,7 @@ export const FEATURE_WALL_WORKFLOWS: readonly FeatureWallWorkflow[] = [
id: 'workspaces',
title: 'Workspaces',
meta: 'Isolated work · Context kept together',
lede: 'Workspaces let you quickly split tasks into isolated environments, so multiple agents can work side by side without interfering with each other. Each workspace has its own branch, terminal, and agent state, keeping parallel work organized.',
lede: 'Orca splits each task into an isolated workspace so agents can run in parallel.',
primaryTileId: 'tile-01',
relatedTileIds: ['tile-10'],
docsUrl: 'https://www.onorca.dev/docs/model/worktrees'
@@ -36,7 +36,7 @@ export const FEATURE_WALL_WORKFLOWS: readonly FeatureWallWorkflow[] = [
id: 'tasks',
title: 'Tasks',
meta: 'GitHub · Linear',
lede: "See your GitHub and Linear tasks in one place, and start a workspace for each when you're ready to build.",
lede: 'Start work directly from GitHub or Linear.',
primaryTileId: 'tile-03',
relatedTileIds: [],
docsUrl: 'https://www.onorca.dev/docs/review/linear'
+51
View File
@@ -0,0 +1,51 @@
import { lstat } from 'fs/promises'
import type { Stats } from 'fs'
import { basename, dirname } from 'path'
function isENOENT(error: unknown): boolean {
return (
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'
)
}
function caseFoldFileExplorerBasename(name: string): string {
// Why: APFS may surface canonically equivalent Unicode names in different forms.
// Node has no filesystem-native collation API here, so this is best-effort.
return name.normalize('NFC').toLowerCase()
}
function hasSameFilesystemIdentity(oldStat: Stats, newStat: Stats): boolean {
return oldStat.dev === newStat.dev && oldStat.ino === newStat.ino
}
function isCaseOnlySameParentRename(oldPath: string, newPath: string): boolean {
const oldBasename = basename(oldPath)
const newBasename = basename(newPath)
return (
dirname(oldPath) === dirname(newPath) &&
oldBasename !== newBasename &&
caseFoldFileExplorerBasename(oldBasename) === caseFoldFileExplorerBasename(newBasename)
)
}
export async function assertNoClobberRenameDestinationAvailable(
oldPath: string,
newPath: string
): Promise<void> {
let newStat: Stats
try {
newStat = await lstat(newPath)
} catch (error) {
if (isENOENT(error)) {
return
}
throw error
}
const oldStat = await lstat(oldPath)
if (hasSameFilesystemIdentity(oldStat, newStat) && isCaseOnlySameParentRename(oldPath, newPath)) {
return
}
throw new Error(`A file or folder named '${basename(newPath)}' already exists in this location`)
}
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { access, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import * as path from 'path'
import { tmpdir } from 'os'
import { removeSafeUntrackedDiscardTarget } from './git-discard-path-safety'
const tempRoots: string[] = []
async function createWorktree(): Promise<string> {
const worktreePath = await mkdtemp(path.join(tmpdir(), 'orca-discard-safety-'))
tempRoots.push(worktreePath)
return worktreePath
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('removeSafeUntrackedDiscardTarget', () => {
it('rejects empty and root targets before removing anything', async () => {
const worktreePath = await createWorktree()
const keepFile = path.join(worktreePath, 'keep.txt')
const removePath = vi.fn(async () => undefined)
await writeFile(keepFile, 'keep')
await expect(removeSafeUntrackedDiscardTarget(worktreePath, '', removePath)).rejects.toThrow(
'resolves outside the worktree'
)
await expect(removeSafeUntrackedDiscardTarget(worktreePath, '.', removePath)).rejects.toThrow(
'resolves outside the worktree'
)
await expect(
removeSafeUntrackedDiscardTarget(worktreePath, 'child/..', removePath)
).rejects.toThrow('resolves outside the worktree')
await expect(access(keepFile)).resolves.toBeUndefined()
expect(removePath).not.toHaveBeenCalled()
})
it('allows missing child paths whose nearest existing parent is the worktree', async () => {
const worktreePath = await createWorktree()
const removePath = vi.fn(async () => undefined)
await mkdir(path.join(worktreePath, 'existing'))
await expect(
removeSafeUntrackedDiscardTarget(worktreePath, 'existing/missing.txt', removePath)
).resolves.toBeUndefined()
await expect(
removeSafeUntrackedDiscardTarget(worktreePath, 'new-dir/missing.txt', removePath)
).resolves.toBeUndefined()
await expect(
removeSafeUntrackedDiscardTarget(worktreePath, 'missing.txt', removePath)
).resolves.toBeUndefined()
await expect(access(worktreePath)).resolves.toBeUndefined()
expect(removePath).toHaveBeenNthCalledWith(1, 'existing/missing.txt')
expect(removePath).toHaveBeenNthCalledWith(2, 'new-dir/missing.txt')
expect(removePath).toHaveBeenNthCalledWith(3, 'missing.txt')
})
})
+126
View File
@@ -0,0 +1,126 @@
import { lstat, realpath } from 'fs/promises'
import * as path from 'path'
function isENOENT(error: unknown): boolean {
return (
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'
)
}
function isInsideOrEqual(rootPath: string, candidatePath: string): boolean {
const relativePath = path.relative(rootPath, candidatePath)
return (
relativePath === '' ||
(relativePath !== '..' &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath))
)
}
async function assertRealPathInsideWorktree(
realWorktreePath: string,
candidatePath: string,
originalFilePath: string
): Promise<void> {
const realCandidatePath = path.resolve(await realpath(candidatePath))
if (!isInsideOrEqual(realWorktreePath, realCandidatePath)) {
throw new Error(`Path "${originalFilePath}" resolves outside the worktree`)
}
}
async function assertNearestExistingParentInsideWorktree(
realWorktreePath: string,
candidatePath: string,
originalFilePath: string
): Promise<void> {
let parentPath = path.dirname(candidatePath)
while (parentPath !== path.dirname(parentPath)) {
try {
await assertRealPathInsideWorktree(realWorktreePath, parentPath, originalFilePath)
return
} catch (error) {
if (!isENOENT(error)) {
throw error
}
parentPath = path.dirname(parentPath)
}
}
throw new Error(`Path "${originalFilePath}" resolves outside the worktree`)
}
function assertTargetIsWorktreeChild(
resolvedWorktreePath: string,
resolvedTarget: string,
originalFilePath: string
): void {
const relativeTarget = path.relative(resolvedWorktreePath, resolvedTarget)
// Why: force-removing the worktree root is never a valid untracked discard,
// even when callers accidentally pass an empty or self-referential path.
if (
relativeTarget === '' ||
relativeTarget === '.' ||
relativeTarget === '..' ||
relativeTarget.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeTarget)
) {
throw new Error(`Path "${originalFilePath}" resolves outside the worktree`)
}
}
async function validateUntrackedDiscardTarget(
worktreePath: string,
filePath: string
): Promise<string> {
const resolvedWorktreePath = path.resolve(worktreePath)
const resolvedTarget = path.resolve(worktreePath, filePath)
assertTargetIsWorktreeChild(resolvedWorktreePath, resolvedTarget, filePath)
const realWorktreePath = path.resolve(await realpath(worktreePath))
try {
const targetStats = await lstat(resolvedTarget)
// Why: discard should remove a symlink leaf itself, but symlinked parents
// must not redirect recursive removal outside the real worktree.
const pathToValidate = targetStats.isSymbolicLink()
? path.dirname(resolvedTarget)
: resolvedTarget
await assertRealPathInsideWorktree(realWorktreePath, pathToValidate, filePath)
} catch (error) {
if (!isENOENT(error)) {
throw error
}
await assertNearestExistingParentInsideWorktree(realWorktreePath, resolvedTarget, filePath)
}
return resolvedTarget
}
export async function removeSafeUntrackedDiscardTarget(
worktreePath: string,
filePath: string,
removePath: (filePath: string) => Promise<void>
): Promise<void> {
await validateUntrackedDiscardTarget(worktreePath, filePath)
await removePath(filePath)
}
export async function removeSafeUntrackedDiscardTargets(
worktreePath: string,
filePaths: readonly string[],
removePaths: (filePaths: readonly string[]) => Promise<void>,
beforeRemove?: () => Promise<void>
): Promise<void> {
await Promise.all(
filePaths.map((filePath) => validateUntrackedDiscardTarget(worktreePath, filePath))
)
// Why: bulk discard must validate every untracked path before mutating
// tracked files, then recheck before the caller's Git-bounded cleanup runs.
await beforeRemove?.()
await Promise.all(
filePaths.map((filePath) => validateUntrackedDiscardTarget(worktreePath, filePath))
)
await removePaths(filePaths)
}
+195
View File
@@ -0,0 +1,195 @@
import type { GitHistoryGraphColorId, GitHistoryItem, GitHistoryItemRef } from './git-history'
import { GIT_HISTORY_REF_COLOR, GIT_HISTORY_REMOTE_REF_COLOR } from './git-history'
import type { GitHistoryGraphNode, GitHistoryItemViewModel } from './git-history-graph'
export const GIT_HISTORY_INCOMING_CHANGES_ID = 'git-history-incoming-changes'
export const GIT_HISTORY_OUTGOING_CHANGES_ID = 'git-history-outgoing-changes'
function cloneNode(node: GitHistoryGraphNode): GitHistoryGraphNode {
return { id: node.id, color: node.color }
}
function findLastIndex<T>(items: readonly T[], predicate: (item: T) => boolean): number {
for (let index = items.length - 1; index >= 0; index -= 1) {
if (predicate(items[index] as T)) {
return index
}
}
return -1
}
function hasNode(
nodes: readonly GitHistoryGraphNode[],
id: string,
color?: GitHistoryGraphColorId
): boolean {
return nodes.some((node) => node.id === id && (color === undefined || node.color === color))
}
function remoteBoundaryInputNode(
node: GitHistoryGraphNode,
mergeBase: string
): GitHistoryGraphNode {
return node.id === mergeBase && node.color === GIT_HISTORY_REMOTE_REF_COLOR
? { ...node, id: GIT_HISTORY_INCOMING_CHANGES_ID }
: cloneNode(node)
}
function ensureIncomingRemoteLane(
inputSwimlanes: GitHistoryGraphNode[],
outputSwimlanes: GitHistoryGraphNode[],
mergeBase: string
): void {
// Why: HEAD-only history omits upstream commits, so the graph must still
// synthesize the remote lane that used to arrive from those hidden rows.
if (!hasNode(outputSwimlanes, mergeBase, GIT_HISTORY_REMOTE_REF_COLOR)) {
const localMergeBaseIndex = outputSwimlanes.findIndex(
(node) => node.id === mergeBase && node.color === GIT_HISTORY_REF_COLOR
)
const remoteMergeBaseIndex =
localMergeBaseIndex === -1 ? inputSwimlanes.length : localMergeBaseIndex + 1
outputSwimlanes.splice(remoteMergeBaseIndex, 0, {
id: mergeBase,
color: GIT_HISTORY_REMOTE_REF_COLOR
})
}
if (hasNode(inputSwimlanes, GIT_HISTORY_INCOMING_CHANGES_ID, GIT_HISTORY_REMOTE_REF_COLOR)) {
return
}
const remoteMergeBaseIndex = outputSwimlanes.findIndex(
(node) => node.id === mergeBase && node.color === GIT_HISTORY_REMOTE_REF_COLOR
)
inputSwimlanes.splice(
remoteMergeBaseIndex === -1 ? inputSwimlanes.length : remoteMergeBaseIndex,
0,
{ id: GIT_HISTORY_INCOMING_CHANGES_ID, color: GIT_HISTORY_REMOTE_REF_COLOR }
)
}
export function addIncomingOutgoingChangesHistoryItems(
viewModels: GitHistoryItemViewModel[],
currentRef?: GitHistoryItemRef,
remoteRef?: GitHistoryItemRef,
addIncomingChanges?: boolean,
addOutgoingChanges?: boolean,
mergeBase?: string
): void {
if (currentRef?.revision === remoteRef?.revision || !mergeBase) {
return
}
if (addIncomingChanges && remoteRef && remoteRef.revision !== mergeBase) {
addIncomingChangesHistoryItem(viewModels, remoteRef, mergeBase)
}
if (addOutgoingChanges && currentRef?.revision && currentRef.revision !== mergeBase) {
addOutgoingChangesHistoryItem(viewModels, currentRef)
}
}
function addIncomingChangesHistoryItem(
viewModels: GitHistoryItemViewModel[],
remoteRef: GitHistoryItemRef,
mergeBase: string
): void {
const beforeHistoryItemIndex = findLastIndex(viewModels, (viewModel) =>
viewModel.outputSwimlanes.some((node) => node.id === mergeBase)
)
const afterHistoryItemIndex = viewModels.findIndex(
(viewModel) => viewModel.historyItem.id === mergeBase
)
if (afterHistoryItemIndex === -1) {
return
}
const before = beforeHistoryItemIndex !== -1 ? viewModels[beforeHistoryItemIndex] : undefined
const incomingChangeMerged =
before?.historyItem.parentIds.length === 2 && before.historyItem.parentIds.includes(mergeBase)
if (incomingChangeMerged) {
return
}
const after = viewModels[afterHistoryItemIndex] as GitHistoryItemViewModel
const inputSwimlanes =
before?.outputSwimlanes.map((node) => remoteBoundaryInputNode(node, mergeBase)) ??
after.inputSwimlanes.map(cloneNode)
const outputSwimlanes = after.inputSwimlanes.map(cloneNode)
ensureIncomingRemoteLane(inputSwimlanes, outputSwimlanes, mergeBase)
if (before !== undefined) {
viewModels[beforeHistoryItemIndex] = {
...before,
inputSwimlanes: before.inputSwimlanes.map((node) => remoteBoundaryInputNode(node, mergeBase)),
outputSwimlanes: inputSwimlanes.map(cloneNode)
}
}
const displayIdLength = viewModels[0]?.historyItem.displayId?.length ?? 0
const incomingChangesHistoryItem: GitHistoryItem = {
id: GIT_HISTORY_INCOMING_CHANGES_ID,
displayId: '0'.repeat(displayIdLength),
parentIds: [mergeBase],
author: remoteRef.name,
subject: 'Incoming Changes',
message: ''
}
viewModels.splice(afterHistoryItemIndex, 0, {
historyItem: incomingChangesHistoryItem,
kind: 'incoming-changes',
inputSwimlanes,
outputSwimlanes
})
viewModels[afterHistoryItemIndex + 1] = {
...after,
inputSwimlanes: outputSwimlanes.map(cloneNode)
}
}
function addOutgoingChangesHistoryItem(
viewModels: GitHistoryItemViewModel[],
currentRef: GitHistoryItemRef
): void {
const currentRevision = currentRef.revision
if (!currentRevision) {
return
}
const currentRefIndex = viewModels.findIndex(
(viewModel) => viewModel.kind === 'HEAD' && viewModel.historyItem.id === currentRevision
)
if (currentRefIndex === -1) {
return
}
const displayIdLength = viewModels[0]?.historyItem.displayId?.length ?? 0
const outgoingChangesHistoryItem: GitHistoryItem = {
id: GIT_HISTORY_OUTGOING_CHANGES_ID,
displayId: '0'.repeat(displayIdLength),
parentIds: [currentRevision],
author: currentRef.name,
subject: 'Outgoing Changes',
message: ''
}
const inputSwimlanes = viewModels[currentRefIndex]!.inputSwimlanes.map(cloneNode)
const outputSwimlanes = inputSwimlanes.concat({
id: currentRevision,
color: GIT_HISTORY_REF_COLOR
})
viewModels.splice(currentRefIndex, 0, {
historyItem: outgoingChangesHistoryItem,
kind: 'outgoing-changes',
inputSwimlanes,
outputSwimlanes
})
viewModels[currentRefIndex + 1]!.inputSwimlanes.push({
id: currentRevision,
color: GIT_HISTORY_REF_COLOR
})
}
+81 -1
View File
@@ -80,7 +80,7 @@ describe('git history graph model', () => {
expect(getGitHistoryMergeParentLaneIndex(viewModels[0]!, 'B')).toBe(1)
})
it('inserts VS Code-style incoming and outgoing boundary rows at the merge base', () => {
it('inserts incoming and outgoing boundary rows at the merge base', () => {
const currentRef = branch('feature', 'A')
const remoteRef = remote('origin/feature', 'R')
const viewModels = buildGitHistoryViewModels(
@@ -115,6 +115,86 @@ describe('git history graph model', () => {
})
})
it('inserts an incoming boundary when HEAD-only history is behind upstream', () => {
const currentRef = branch('feature', 'B')
const remoteRef = remote('origin/feature', 'R')
const viewModels = buildGitHistoryViewModels(
[item('B', ['C'], [currentRef]), item('C', [])],
buildDefaultGitHistoryColorMap({ currentRef, remoteRef }),
currentRef,
remoteRef,
undefined,
true,
false,
'B'
)
expect(viewModels.map((viewModel) => viewModel.kind)).toEqual([
'incoming-changes',
'HEAD',
'node'
])
expect(viewModels[0]!.inputSwimlanes).toContainEqual({
id: GIT_HISTORY_INCOMING_CHANGES_ID,
color: GIT_HISTORY_REMOTE_REF_COLOR
})
expect(viewModels[0]!.outputSwimlanes).toContainEqual({
id: 'B',
color: GIT_HISTORY_REMOTE_REF_COLOR
})
expect(viewModels[1]!.inputSwimlanes).toContainEqual({
id: 'B',
color: GIT_HISTORY_REMOTE_REF_COLOR
})
const incomingLaneIndex = viewModels[0]!.inputSwimlanes.findIndex(
(node) => node.id === GIT_HISTORY_INCOMING_CHANGES_ID
)
expect(viewModels[0]!.outputSwimlanes[incomingLaneIndex]?.color).toBe(
GIT_HISTORY_REMOTE_REF_COLOR
)
})
it('colors incoming boundary lanes as remote when upstream commits are omitted', () => {
const currentRef = branch('feature', 'A')
const remoteRef = remote('origin/feature', 'R')
const viewModels = buildGitHistoryViewModels(
[item('A', ['B'], [currentRef]), item('B', ['C']), item('C', [])],
buildDefaultGitHistoryColorMap({ currentRef, remoteRef }),
currentRef,
remoteRef,
undefined,
true,
true,
'B'
)
expect(viewModels.map((viewModel) => viewModel.kind)).toEqual([
'outgoing-changes',
'HEAD',
'incoming-changes',
'node',
'node'
])
expect(viewModels[2]!.inputSwimlanes).toContainEqual({
id: GIT_HISTORY_INCOMING_CHANGES_ID,
color: GIT_HISTORY_REMOTE_REF_COLOR
})
expect(viewModels[2]!.outputSwimlanes).toContainEqual({
id: 'B',
color: GIT_HISTORY_REMOTE_REF_COLOR
})
expect(viewModels[3]!.inputSwimlanes).toContainEqual({
id: 'B',
color: GIT_HISTORY_REMOTE_REF_COLOR
})
const incomingLaneIndex = viewModels[2]!.inputSwimlanes.findIndex(
(node) => node.id === GIT_HISTORY_INCOMING_CHANGES_ID
)
expect(viewModels[2]!.outputSwimlanes[incomingLaneIndex]?.color).toBe(
GIT_HISTORY_REMOTE_REF_COLOR
)
})
it('assigns stable colors to current, remote, and base refs', () => {
const currentRef = branch('feature', 'A')
const remoteRef = remote('origin/feature', 'R')
+6 -101
View File
@@ -1,4 +1,9 @@
import type { GitHistoryGraphColorId, GitHistoryItem, GitHistoryItemRef } from './git-history'
import {
GIT_HISTORY_INCOMING_CHANGES_ID,
GIT_HISTORY_OUTGOING_CHANGES_ID,
addIncomingOutgoingChangesHistoryItems
} from './git-history-boundary-rows'
import {
GIT_HISTORY_BASE_REF_COLOR,
GIT_HISTORY_LANE_COLORS,
@@ -6,8 +11,7 @@ import {
GIT_HISTORY_REMOTE_REF_COLOR
} from './git-history'
export const GIT_HISTORY_INCOMING_CHANGES_ID = 'git-history-incoming-changes'
export const GIT_HISTORY_OUTGOING_CHANGES_ID = 'git-history-outgoing-changes'
export { GIT_HISTORY_INCOMING_CHANGES_ID, GIT_HISTORY_OUTGOING_CHANGES_ID }
export type GitHistoryGraphNode = {
id: string
@@ -87,105 +91,6 @@ export function compareGitHistoryRefs(
return order(ref1) - order(ref2)
}
function addIncomingOutgoingChangesHistoryItems(
viewModels: GitHistoryItemViewModel[],
currentRef?: GitHistoryItemRef,
remoteRef?: GitHistoryItemRef,
addIncomingChanges?: boolean,
addOutgoingChanges?: boolean,
mergeBase?: string
): void {
if (currentRef?.revision === remoteRef?.revision || !mergeBase) {
return
}
if (addIncomingChanges && remoteRef && remoteRef.revision !== mergeBase) {
const beforeHistoryItemIndex = findLastIndex(viewModels, (viewModel) =>
viewModel.outputSwimlanes.some((node) => node.id === mergeBase)
)
const afterHistoryItemIndex = viewModels.findIndex(
(viewModel) => viewModel.historyItem.id === mergeBase
)
if (beforeHistoryItemIndex !== -1 && afterHistoryItemIndex !== -1) {
const before = viewModels[beforeHistoryItemIndex] as GitHistoryItemViewModel
const incomingChangeMerged =
before.historyItem.parentIds.length === 2 &&
before.historyItem.parentIds.includes(mergeBase)
if (!incomingChangeMerged) {
viewModels[beforeHistoryItemIndex] = {
...before,
inputSwimlanes: before.inputSwimlanes.map((node) =>
node.id === mergeBase && node.color === GIT_HISTORY_REMOTE_REF_COLOR
? { ...node, id: GIT_HISTORY_INCOMING_CHANGES_ID }
: node
),
outputSwimlanes: before.outputSwimlanes.map((node) =>
node.id === mergeBase && node.color === GIT_HISTORY_REMOTE_REF_COLOR
? { ...node, id: GIT_HISTORY_INCOMING_CHANGES_ID }
: node
)
}
const displayIdLength = viewModels[0]?.historyItem.displayId?.length ?? 0
const incomingChangesHistoryItem: GitHistoryItem = {
id: GIT_HISTORY_INCOMING_CHANGES_ID,
displayId: '0'.repeat(displayIdLength),
parentIds: [mergeBase],
author: remoteRef.name,
subject: 'Incoming Changes',
message: ''
}
viewModels.splice(afterHistoryItemIndex, 0, {
historyItem: incomingChangesHistoryItem,
kind: 'incoming-changes',
inputSwimlanes: viewModels[beforeHistoryItemIndex]!.outputSwimlanes.map(cloneNode),
outputSwimlanes: viewModels[afterHistoryItemIndex]!.inputSwimlanes.map(cloneNode)
})
}
}
}
if (addOutgoingChanges && currentRef?.revision && currentRef.revision !== mergeBase) {
const currentRefIndex = viewModels.findIndex(
(viewModel) => viewModel.kind === 'HEAD' && viewModel.historyItem.id === currentRef.revision
)
if (currentRefIndex === -1) {
return
}
const displayIdLength = viewModels[0]?.historyItem.displayId?.length ?? 0
const outgoingChangesHistoryItem: GitHistoryItem = {
id: GIT_HISTORY_OUTGOING_CHANGES_ID,
displayId: '0'.repeat(displayIdLength),
parentIds: [currentRef.revision],
author: currentRef.name,
subject: 'Outgoing Changes',
message: ''
}
const inputSwimlanes = viewModels[currentRefIndex]!.inputSwimlanes.map(cloneNode)
const outputSwimlanes = inputSwimlanes.concat({
id: currentRef.revision,
color: GIT_HISTORY_REF_COLOR
})
viewModels.splice(currentRefIndex, 0, {
historyItem: outgoingChangesHistoryItem,
kind: 'outgoing-changes',
inputSwimlanes,
outputSwimlanes
})
viewModels[currentRefIndex + 1]!.inputSwimlanes.push({
id: currentRef.revision,
color: GIT_HISTORY_REF_COLOR
})
}
}
export function buildGitHistoryViewModels(
historyItems: GitHistoryItem[],
colorMap = new Map<string, GitHistoryGraphColorId | undefined>(),
+75 -3
View File
@@ -63,13 +63,14 @@ function createHistoryExecutor(limitRecords = 2): {
return { stdout: `${BASE_OID}\n` }
}
if (command === 'log') {
const includesRemoteRoot = args.includes(REMOTE_OID)
return {
stdout: Array.from({ length: limitRecords }, (_, index) =>
logRecord({
hash:
index === 0
? HEAD_OID
: index === 1
: includesRemoteRoot && index === 1
? REMOTE_OID
: (index % 16).toString(16).repeat(40),
parents: [BASE_OID],
@@ -129,17 +130,88 @@ describe('git history loader', () => {
'--topo-order',
'--decorate=full',
'-n51',
HEAD_OID,
REMOTE_OID
HEAD_OID
])
)
expect(logCall).not.toContain(REMOTE_OID)
expect(calls.filter((args) => args[0] === 'log')).toHaveLength(1)
expect(result.items).toHaveLength(2)
expect(result.items.map((item) => item.id)).not.toContain(REMOTE_OID)
expect(result.hasIncomingChanges).toBe(true)
expect(result.hasOutgoingChanges).toBe(true)
expect(result.mergeBase).toBe(BASE_OID)
})
it('does not list newly fetched upstream commits in old workspace history', async () => {
const upstreamOnlyOid = 'd'.repeat(40)
const calls: string[][] = []
const executor = vi.fn(async (args: string[], cwd: string) => {
expect(cwd).toBe('/repo')
calls.push(args)
const command = args[0]
if (command === 'rev-parse' && args.includes('HEAD^{commit}')) {
return { stdout: `${HEAD_OID}\n` }
}
if (command === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) {
return { stdout: `${REMOTE_OID}\n` }
}
if (command === 'rev-parse' && args.includes('origin/main^{commit}')) {
return { stdout: `${REMOTE_OID}\n` }
}
if (command === 'rev-parse' && args.includes('origin/main')) {
return { stdout: 'refs/remotes/origin/main\n' }
}
if (command === 'symbolic-ref') {
return { stdout: 'old-workspace\n' }
}
if (command === 'for-each-ref') {
return { stdout: 'refs/remotes/origin/main\0origin/main\n' }
}
if (command === 'merge-base') {
return { stdout: `${HEAD_OID}\n` }
}
if (command === 'log') {
const includesRemoteRoot = args.includes(REMOTE_OID)
return {
stdout: includesRemoteRoot
? [
logRecord({
hash: upstreamOnlyOid,
parents: [REMOTE_OID],
message: 'new upstream commit'
}),
logRecord({
hash: HEAD_OID,
decorations: 'HEAD -> refs/heads/old-workspace',
message: 'old workspace base'
})
].join('')
: logRecord({
hash: HEAD_OID,
decorations: 'HEAD -> refs/heads/old-workspace',
message: 'old workspace base'
})
}
}
throw new Error(`unexpected git command: ${args.join(' ')}`)
})
const result = await loadGitHistoryFromExecutor(executor, '/repo', {
limit: 50,
baseRef: 'origin/main'
})
const logCall = calls.find((args) => args[0] === 'log')
expect(logCall).not.toContain(REMOTE_OID)
expect(result.remoteRef?.revision).toBe(REMOTE_OID)
expect(result.baseRef).toBeUndefined()
expect(result.hasIncomingChanges).toBe(true)
expect(result.hasOutgoingChanges).toBe(false)
expect(result.items.map((item) => item.id)).toEqual([HEAD_OID])
})
it('clamps oversized limits before shelling out to git log', async () => {
const { executor, calls } = createHistoryExecutor(GIT_HISTORY_MAX_LIMIT + 1)
+4 -8
View File
@@ -183,13 +183,9 @@ export async function loadGitHistoryFromExecutor(
? rawBaseRef
: undefined
const revisions = Array.from(
new Set(
[currentRef.revision, remoteRef?.revision, baseRef?.revision].filter(
(revision): revision is string => Boolean(revision)
)
)
)
// Why: this panel is scoped to the active workspace. Upstream and base refs
// stay as comparison metadata so old workspaces do not list newly fetched upstream/base commits.
const historyRevisions = [headOid]
let mergeBase: string | undefined
if (remoteRef?.revision && currentRef.revision && remoteRef.revision !== currentRef.revision) {
@@ -209,7 +205,7 @@ export async function loadGitHistoryFromExecutor(
'--topo-order',
'--decorate=full',
`-n${limit + 1}`,
...revisions
...historyRevisions
],
cwd
)
+5
View File
@@ -32,6 +32,11 @@ export type GitUncommittedEntry = {
conflictKind?: GitConflictKind
conflictStatus?: GitConflictResolutionStatus
conflictStatusSource?: GitConflictStatusSource
// Working-tree line counts for this entry's staging area (staged vs unstaged
// diffs are reported separately). Untracked files count their full contents
// as additions. Undefined for binary files and when the diff is unavailable.
added?: number
removed?: number
}
export type GitStatusEntry = GitUncommittedEntry
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { lstatMock, readFileMock } = vi.hoisted(() => ({
lstatMock: vi.fn(),
readFileMock: vi.fn()
}))
vi.mock('fs/promises', () => ({ lstat: lstatMock, readFile: readFileMock }))
import {
applyLineStats,
collectUntrackedAdditions,
MAX_UNTRACKED_LINE_COUNT_BYTES,
parseNumstat
} from './git-uncommitted-line-stats'
function mockFileStat(size: number, mtimeMs = 1) {
return {
size,
mtimeMs,
ctimeMs: mtimeMs,
isFile: () => true,
isSymbolicLink: () => false
}
}
describe('parseNumstat', () => {
it('parses added/removed counts keyed by path', () => {
const stats = parseNumstat('3\t4\tsrc/app.ts\n10\t0\tsrc/new.ts\n')
expect(stats.get('src/app.ts')).toEqual({ added: 3, removed: 4 })
expect(stats.get('src/new.ts')).toEqual({ added: 10, removed: 0 })
})
it('treats binary "-" columns as undefined counts', () => {
expect(parseNumstat('-\t-\tassets/logo.png\n').get('assets/logo.png')).toEqual({
added: undefined,
removed: undefined
})
})
it('keys renames to the post-rename path', () => {
const braced = parseNumstat('2\t1\tsrc/{old => new}/file.ts\n')
expect(braced.get('src/new/file.ts')).toEqual({ added: 2, removed: 1 })
const plain = parseNumstat('2\t1\told.ts => new.ts\n')
expect(plain.get('new.ts')).toEqual({ added: 2, removed: 1 })
})
it('ignores blank lines', () => {
expect(parseNumstat('').size).toBe(0)
})
})
describe('collectUntrackedAdditions', () => {
beforeEach(() => {
lstatMock.mockReset()
readFileMock.mockReset()
})
it('counts file lines as additions, with or without a trailing newline', async () => {
lstatMock.mockImplementation((target: string) =>
Promise.resolve(mockFileStat(String(target).endsWith('trailing.ts') ? 6 : 5))
)
readFileMock.mockImplementation((target: string) =>
Promise.resolve(
String(target).endsWith('trailing.ts') ? Buffer.from('a\nb\nc\n') : Buffer.from('a\nb\nc')
)
)
const stats = await collectUntrackedAdditions('/repo', ['trailing.ts', 'no-trailing.ts'])
expect(stats.get('trailing.ts')).toEqual({ added: 3 })
expect(stats.get('no-trailing.ts')).toEqual({ added: 3 })
})
it('reports an empty file as zero additions', async () => {
lstatMock.mockResolvedValue(mockFileStat(0))
readFileMock.mockResolvedValue(Buffer.from(''))
expect((await collectUntrackedAdditions('/repo', ['empty.ts'])).get('empty.ts')).toEqual({
added: 0
})
})
it('omits counts for binary files', async () => {
lstatMock.mockResolvedValue(mockFileStat(3))
readFileMock.mockResolvedValue(Buffer.from([0x00, 0x01, 0x02]))
expect((await collectUntrackedAdditions('/repo', ['bin.dat'])).get('bin.dat')).toEqual({})
})
it('counts untracked symbolic links without following the target', async () => {
lstatMock.mockResolvedValue({
size: 4,
mtimeMs: 2,
ctimeMs: 2,
isFile: () => false,
isSymbolicLink: () => true
})
expect((await collectUntrackedAdditions('/repo', ['link.txt'])).get('link.txt')).toEqual({
added: 1
})
expect(readFileMock).not.toHaveBeenCalled()
})
it('skips oversized untracked files instead of reading them during status polling', async () => {
lstatMock.mockResolvedValue(mockFileStat(MAX_UNTRACKED_LINE_COUNT_BYTES + 1, 3))
expect((await collectUntrackedAdditions('/repo', ['large.log'])).get('large.log')).toEqual({})
expect(readFileMock).not.toHaveBeenCalled()
})
it('reuses cached counts while size and mtime are unchanged', async () => {
lstatMock.mockResolvedValue(mockFileStat(5, 4))
readFileMock.mockResolvedValue(Buffer.from('a\nb\nc'))
await collectUntrackedAdditions('/repo', ['cached.ts'])
const stats = await collectUntrackedAdditions('/repo', ['cached.ts'])
expect(stats.get('cached.ts')).toEqual({ added: 3 })
expect(readFileMock).toHaveBeenCalledTimes(1)
})
})
describe('applyLineStats', () => {
it('copies defined counts onto the entry', () => {
const entry: { added?: number; removed?: number } = {}
applyLineStats(entry, { added: 5, removed: 2 })
expect(entry).toEqual({ added: 5, removed: 2 })
})
it('leaves the entry untouched for undefined counts or missing stats', () => {
const entry: { added?: number; removed?: number } = {}
applyLineStats(entry, { added: undefined, removed: undefined })
applyLineStats(entry, undefined)
expect(entry).toEqual({})
})
})
+159
View File
@@ -0,0 +1,159 @@
import { lstat, readFile } from 'fs/promises'
import * as path from 'path'
import { isBinaryBuffer } from './binary-buffer'
export type GitLineStats = { added?: number; removed?: number }
// Limits how many untracked files we read at once when counting their lines,
// so a worktree with thousands of new files cannot exhaust file descriptors.
const UNTRACKED_READ_CONCURRENCY = 8
// Keep status polling cheap: large untracked files are commonly generated
// assets, and reading them every poll can stall the source-control sidebar.
export const MAX_UNTRACKED_LINE_COUNT_BYTES = 2 * 1024 * 1024
const UNTRACKED_STATS_CACHE_MAX_ENTRIES = 2048
const NEWLINE_BYTE = 0x0a
type CachedUntrackedStats = {
size: number
mtimeMs: number
ctimeMs: number
stats: GitLineStats
}
const untrackedStatsCache = new Map<string, CachedUntrackedStats>()
function parseNumstatCount(value: string): number | undefined {
// git reports binary files as '-' in the numstat columns.
if (value === '-') {
return undefined
}
const count = Number.parseInt(value, 10)
return Number.isFinite(count) ? count : undefined
}
// `git diff -M` reports renames in the numstat path column as `old => new` or
// `dir/{old => new}/file`; normalize to the post-rename path so it keys to the
// porcelain status entry, which always reports the new path.
function normalizeNumstatPath(rawPath: string): string {
const braced = /^(.*)\{(.+) => (.+)\}(.*)$/.exec(rawPath)
if (braced) {
return `${braced[1]}${braced[3]}${braced[4]}`
}
const marker = ' => '
const markerIndex = rawPath.lastIndexOf(marker)
return markerIndex === -1 ? rawPath : rawPath.slice(markerIndex + marker.length)
}
export function parseNumstat(stdout: string): Map<string, GitLineStats> {
const stats = new Map<string, GitLineStats>()
for (const line of stdout.split(/\r?\n/)) {
if (!line) {
continue
}
const parts = line.split('\t')
const rawPath = parts.slice(2).join('\t')
if (!rawPath) {
continue
}
stats.set(normalizeNumstatPath(rawPath), {
added: parseNumstatCount(parts[0] ?? ''),
removed: parseNumstatCount(parts[1] ?? '')
})
}
return stats
}
async function countFileAdditions(absolutePath: string): Promise<GitLineStats> {
try {
const fileStat = await lstat(absolutePath)
const cached = untrackedStatsCache.get(absolutePath)
if (
cached &&
cached.size === fileStat.size &&
cached.mtimeMs === fileStat.mtimeMs &&
cached.ctimeMs === fileStat.ctimeMs
) {
return cached.stats
}
if (fileStat.isSymbolicLink()) {
return rememberUntrackedStats(absolutePath, fileStat, { added: 1 })
}
if (!fileStat.isFile() || fileStat.size > MAX_UNTRACKED_LINE_COUNT_BYTES) {
return rememberUntrackedStats(absolutePath, fileStat, {})
}
const buffer = await readFile(absolutePath)
if (isBinaryBuffer(buffer)) {
return rememberUntrackedStats(absolutePath, fileStat, {})
}
if (buffer.length === 0) {
return rememberUntrackedStats(absolutePath, fileStat, { added: 0 })
}
let newlineCount = 0
for (let i = 0; i < buffer.length; i += 1) {
if (buffer[i] === NEWLINE_BYTE) {
newlineCount += 1
}
}
// A trailing newline marks the final line as complete; without one the last
// partial line still counts as an added line (matching git's numstat).
const endsWithNewline = buffer.at(-1) === NEWLINE_BYTE
return rememberUntrackedStats(absolutePath, fileStat, {
added: endsWithNewline ? newlineCount : newlineCount + 1
})
} catch {
return {}
}
}
function rememberUntrackedStats(
absolutePath: string,
fileStat: { size: number; mtimeMs: number; ctimeMs: number },
stats: GitLineStats
): GitLineStats {
untrackedStatsCache.set(absolutePath, {
size: fileStat.size,
mtimeMs: fileStat.mtimeMs,
ctimeMs: fileStat.ctimeMs,
stats
})
if (untrackedStatsCache.size > UNTRACKED_STATS_CACHE_MAX_ENTRIES) {
const oldestKey = untrackedStatsCache.keys().next().value
if (oldestKey) {
untrackedStatsCache.delete(oldestKey)
}
}
return stats
}
// Untracked files have no git-tracked baseline, so `git diff` ignores them.
// We count their contents directly to show an additions magnitude.
export async function collectUntrackedAdditions(
worktreePath: string,
untrackedPaths: readonly string[]
): Promise<Map<string, GitLineStats>> {
const result = new Map<string, GitLineStats>()
for (let i = 0; i < untrackedPaths.length; i += UNTRACKED_READ_CONCURRENCY) {
const chunk = untrackedPaths.slice(i, i + UNTRACKED_READ_CONCURRENCY)
await Promise.all(
chunk.map(async (relativePath) => {
result.set(relativePath, await countFileAdditions(path.join(worktreePath, relativePath)))
})
)
}
return result
}
export function applyLineStats(
entry: { added?: number; removed?: number },
stats: GitLineStats | undefined
): void {
if (!stats) {
return
}
if (stats.added !== undefined) {
entry.added = stats.added
}
if (stats.removed !== undefined) {
entry.removed = stats.removed
}
}
+1
View File
@@ -48,6 +48,7 @@ export type CreateHostedReviewInput = {
body?: string
draft?: boolean
worktreePath?: string
useTemplate?: boolean
}
export type CreateHostedReviewArgs = CreateHostedReviewInput & {
+136 -1
View File
@@ -9,6 +9,7 @@ import {
keybindingFromInput,
keybindingFromInputForAction,
keybindingMatchesAction,
keybindingMatchesInput,
normalizeKeybinding,
normalizeKeybindingListForAction,
normalizeKeybindingList
@@ -81,6 +82,39 @@ describe('keybindings', () => {
expect(formatKeybindingList([], 'win32')).toBe('Unassigned')
})
it('preserves explicit numpad shortcut tokens', () => {
const numpadAdd = {
key: '+',
code: 'NumpadAdd',
control: false,
meta: true,
alt: false,
shift: false
}
expect(keybindingFromInput(numpadAdd, 'darwin')).toEqual({
ok: true,
value: 'Mod+NumpadAdd'
})
expect(keybindingMatchesAction('zoom.in', numpadAdd, 'darwin')).toBe(true)
expect(
keybindingMatchesAction(
'zoom.out',
{
...numpadAdd,
key: '-',
code: 'NumpadSubtract'
},
'darwin'
)
).toBe(true)
})
it('defines a default shortcut for opening markdown notes', () => {
expect(getEffectiveKeybindingsForAction('tab.openMarkdown', 'darwin')).toEqual(['Mod+Shift+O'])
expect(formatKeybindingList(['Mod+Shift+O'], 'darwin')).toBe('⌘⇧O')
})
it('uses overrides as the complete effective binding list for an action', () => {
const overrides = {
'worktree.quickOpen': ['Ctrl+Alt+O', 'not-a-shortcut']
@@ -192,7 +226,7 @@ describe('keybindings', () => {
expect(
keybindingMatchesAction(
'floatingTerminal.toggle',
{ key: 't', code: 'KeyT', control: true, meta: false, alt: true, shift: false },
{ key: 'a', code: 'KeyA', control: true, meta: false, alt: true, shift: false },
'linux',
undefined,
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
@@ -255,6 +289,107 @@ describe('keybindings', () => {
).toBe(true)
})
it('matches file explorer undo and redo by produced logical key', () => {
expect(getEffectiveKeybindingsForAction('fileExplorer.undo', 'darwin')).toEqual(['Mod+Z'])
expect(getEffectiveKeybindingsForAction('fileExplorer.redo', 'darwin')).toEqual(['Mod+Shift+Z'])
expect(getEffectiveKeybindingsForAction('fileExplorer.redo', 'linux')).toEqual([
'Mod+Shift+Z',
'Ctrl+Y'
])
expect(
keybindingMatchesAction(
'fileExplorer.undo',
{ key: 'z', code: 'Semicolon', control: false, meta: true, alt: false, shift: false },
'darwin'
)
).toBe(true)
expect(
keybindingMatchesAction(
'fileExplorer.undo',
{ key: ';', code: 'KeyZ', control: false, meta: true, alt: false, shift: false },
'darwin'
)
).toBe(false)
expect(
keybindingMatchesAction(
'fileExplorer.redo',
{ key: 'Z', code: 'Semicolon', control: false, meta: true, alt: false, shift: true },
'darwin'
)
).toBe(true)
expect(
keybindingMatchesAction(
'fileExplorer.redo',
{ key: 'y', code: 'KeyF', control: true, meta: false, alt: false, shift: false },
'linux'
)
).toBe(true)
expect(
keybindingMatchesAction(
'fileExplorer.redo',
{ key: 'f', code: 'KeyY', control: true, meta: false, alt: false, shift: false },
'linux'
)
).toBe(false)
})
it('matches non-QWERTY shortcuts by the produced logical key', () => {
const dvorakPhysicalW = {
key: ',',
code: 'KeyW',
control: false,
meta: true,
alt: false,
shift: false
}
const dvorakPhysicalComma = {
key: 'w',
code: 'Comma',
control: false,
meta: true,
alt: false,
shift: false
}
expect(keybindingMatchesAction('app.settings', dvorakPhysicalW, 'darwin')).toBe(true)
expect(keybindingMatchesAction('tab.close', dvorakPhysicalW, 'darwin')).toBe(false)
expect(keybindingMatchesAction('tab.close', dvorakPhysicalComma, 'darwin')).toBe(true)
expect(keybindingMatchesAction('app.settings', dvorakPhysicalComma, 'darwin')).toBe(false)
expect(keybindingFromInput(dvorakPhysicalW, 'darwin')).toEqual({
ok: true,
value: 'Mod+Comma'
})
expect(keybindingFromInput(dvorakPhysicalComma, 'darwin')).toEqual({
ok: true,
value: 'Mod+W'
})
})
it('uses shifted punctuation aliases only while Shift is pressed', () => {
const shiftedComma = {
key: '<',
code: 'Comma',
control: false,
meta: true,
alt: false,
shift: true
}
expect(keybindingMatchesInput('Mod+Shift+Comma', shiftedComma, 'darwin')).toBe(true)
expect(keybindingFromInput(shiftedComma, 'darwin')).toEqual({
ok: true,
value: 'Mod+Shift+Comma'
})
expect(
keybindingMatchesInput(
'Mod+Comma',
{ ...shiftedComma, code: 'IntlBackslash', shift: false },
'darwin'
)
).toBe(false)
})
it('matches logical bracket shortcuts on JIS keyboards without changing code fallback', () => {
const jisLeftBracket = {
key: '[',
+176 -74
View File
@@ -50,6 +50,7 @@ export type KeybindingActionId =
| 'tab.newTerminal'
| 'tab.newBrowser'
| 'tab.newMarkdown'
| 'tab.openMarkdown'
| 'tab.close'
| 'tab.reopenClosed'
| 'tab.nextSameType'
@@ -68,10 +69,11 @@ export type KeybindingActionId =
| 'editor.save'
| 'editor.markdownPreview'
| 'editor.copyContext'
| 'fileExplorer.undo'
| 'fileExplorer.redo'
| 'fileExplorer.copyPath'
| 'fileExplorer.copyRelativePath'
| 'fileExplorer.delete'
| 'composer.addAttachment'
| 'settings.search'
| 'terminal.copySelection'
| 'terminal.paste'
@@ -317,7 +319,7 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
group: 'Global',
scope: 'global',
searchKeywords: ['shortcut', 'floating terminal', 'terminal'],
defaultBindings: platformBindings(['Mod+Alt+T']),
defaultBindings: platformBindings(['Mod+Alt+A']),
allowInTerminal: true
},
{
@@ -386,6 +388,14 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
searchKeywords: ['shortcut', 'tab', 'markdown', 'file', 'new'],
defaultBindings: platformBindings(['Mod+Shift+M'])
},
{
id: 'tab.openMarkdown',
title: 'Open markdown tab',
group: 'Tabs',
scope: 'tabs',
searchKeywords: ['shortcut', 'tab', 'markdown', 'file', 'open'],
defaultBindings: platformBindings(['Mod+Shift+O'])
},
{
id: 'tab.close',
title: 'Close active tab',
@@ -533,6 +543,26 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
searchKeywords: ['shortcut', 'editor', 'copy', 'context'],
defaultBindings: platformBindings(['Mod+Alt+C'])
},
{
id: 'fileExplorer.undo',
title: 'Undo file operation',
group: 'File Explorer',
scope: 'fileExplorer',
searchKeywords: ['shortcut', 'file explorer', 'undo'],
defaultBindings: platformBindings(['Mod+Z'])
},
{
id: 'fileExplorer.redo',
title: 'Redo file operation',
group: 'File Explorer',
scope: 'fileExplorer',
searchKeywords: ['shortcut', 'file explorer', 'redo'],
defaultBindings: {
darwin: ['Mod+Shift+Z'],
linux: ['Mod+Shift+Z', 'Ctrl+Y'],
win32: ['Mod+Shift+Z', 'Ctrl+Y']
}
},
{
id: 'fileExplorer.copyPath',
title: 'Copy file path',
@@ -566,14 +596,6 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
},
allowBareKeybindings: true
},
{
id: 'composer.addAttachment',
title: 'Add Attachment',
group: 'Composer',
scope: 'composer',
searchKeywords: ['shortcut', 'composer', 'attachment', 'upload'],
defaultBindings: platformBindings(['Mod+U'])
},
{
id: 'settings.search',
title: 'Search Settings',
@@ -725,6 +747,9 @@ function hasModifier(
}
function normalizeKeyToken(token: string): string | null {
if (token === ' ') {
return 'Space'
}
const trimmed = token.trim()
if (!trimmed) {
return null
@@ -785,6 +810,8 @@ function normalizeKeyToken(token: string): string | null {
BRACKETRIGHT: 'BracketRight',
NUMPADADD: 'NumpadAdd',
NUMPADSUBTRACT: 'NumpadSubtract',
ADD: 'NumpadAdd',
SUBTRACT: 'NumpadSubtract',
COMMA: 'Comma',
PERIOD: 'Period',
SLASH: 'Slash',
@@ -1005,13 +1032,57 @@ const MODIFIER_KEYS = new Set([
'SymbolLock'
])
function keyTokenFromInput(input: KeybindingInput): string | null {
const code = input.code ?? ''
const key = input.key ?? ''
const PUNCTUATION_KEY_TOKENS = new Set([
'BracketLeft',
'BracketRight',
'Minus',
'Underscore',
'Equal',
'Plus',
'Comma',
'Period',
'Slash',
'Backslash',
'Semicolon',
'Quote',
'Backquote'
])
const PHYSICAL_CODE_FALLBACK_KEYS = new Set(['', 'Dead', 'Unidentified'])
const SHIFTED_PUNCTUATION_KEY_TOKENS: Record<string, string> = {
'<': 'Comma',
'>': 'Period',
'?': 'Slash',
'|': 'Backslash',
':': 'Semicolon',
'"': 'Quote',
'~': 'Backquote'
}
function logicalKeyTokenFromInput(input: KeybindingInput): string | null {
const key = input.key ?? ''
if (MODIFIER_KEYS.has(key)) {
return null
}
const normalizedKey = normalizeKeyToken(key)
if (normalizedKey) {
return normalizedKey
}
if (hasModifier(input, 'shift')) {
return SHIFTED_PUNCTUATION_KEY_TOKENS[key] ?? null
}
return null
}
function canUsePhysicalCodeFallback(input: KeybindingInput): boolean {
// Why: layout-aware shortcuts must trust real logical keys; physical code is
// only a fallback when the platform cannot report the produced key.
return PHYSICAL_CODE_FALLBACK_KEYS.has(input.key ?? '')
}
function physicalCodeKeyTokenFromInput(input: KeybindingInput): string | null {
const code = input.code ?? ''
if (code.startsWith('Key') && code.length === 4) {
return code.slice(3).toUpperCase()
}
@@ -1019,7 +1090,27 @@ function keyTokenFromInput(input: KeybindingInput): string | null {
return code.slice(5)
}
return normalizeKeyToken(key) ?? normalizeKeyToken(code)
return normalizeKeyToken(code)
}
function numpadCodeKeyTokenFromInput(input: KeybindingInput): string | null {
const code = input.code ?? ''
return code === 'NumpadAdd' || code === 'NumpadSubtract' ? normalizeKeyToken(code) : null
}
function keyTokenFromInput(input: KeybindingInput): string | null {
const numpadKey = numpadCodeKeyTokenFromInput(input)
if (numpadKey) {
return numpadKey
}
const logicalKey = logicalKeyTokenFromInput(input)
if (logicalKey) {
return logicalKey
}
if (!canUsePhysicalCodeFallback(input)) {
return null
}
return physicalCodeKeyTokenFromInput(input)
}
function keybindingFromInputWithOptions(
@@ -1161,39 +1252,58 @@ function modifierStateMatches(
)
}
function letterKeyMatches(input: KeybindingInput, letter: string): boolean {
const key = (input.key ?? '').toLowerCase()
if (key.length === 1 && key >= 'a' && key <= 'z') {
return key === letter.toLowerCase()
function shouldUseMacOptionLetterPhysicalFallback(
parsed: ParsedKeybinding,
input: KeybindingInput,
platform: NodeJS.Platform
): boolean {
// Why: macOS Option+letter can report composed characters (Option+A -> å),
// leaving no logical Latin key for app shortcuts that intentionally use Alt.
return (
getKeybindingPlatform(platform) === 'darwin' &&
parsed.alt &&
hasModifier(input, 'alt') &&
logicalKeyTokenFromInput(input) === null
)
}
function letterKeyMatches(
input: KeybindingInput,
letter: string,
parsed: ParsedKeybinding,
platform: NodeJS.Platform
): boolean {
const logicalKey = logicalKeyTokenFromInput(input)
if (logicalKey && logicalKey.length === 1 && logicalKey >= 'A' && logicalKey <= 'Z') {
return logicalKey === letter.toUpperCase()
}
return input.code === `Key${letter.toUpperCase()}`
return (
(canUsePhysicalCodeFallback(input) ||
shouldUseMacOptionLetterPhysicalFallback(parsed, input, platform)) &&
input.code === `Key${letter.toUpperCase()}`
)
}
function digitKeyMatches(input: KeybindingInput, digit: string): boolean {
const logicalKey = logicalKeyTokenFromInput(input)
if (logicalKey && logicalKey.length === 1 && logicalKey >= '0' && logicalKey <= '9') {
return logicalKey === digit
}
return canUsePhysicalCodeFallback(input) && input.code === `Digit${digit}`
}
function isPunctuationKeyToken(token: string | null): token is string {
return token !== null && PUNCTUATION_KEY_TOKENS.has(token)
}
function semanticPunctuationKey(input: KeybindingInput): string | null {
switch (input.key) {
case '[':
case '{':
return 'BracketLeft'
case ']':
case '}':
return 'BracketRight'
case '\\':
case '|':
return 'Backslash'
default:
return null
}
const logicalKey = logicalKeyTokenFromInput(input)
return isPunctuationKeyToken(logicalKey) ? logicalKey : null
}
function physicalPunctuationKey(input: KeybindingInput): string | null {
switch (input.code) {
case 'BracketLeft':
case 'BracketRight':
case 'Backslash':
return input.code
default:
return null
}
const physicalKey = physicalCodeKeyTokenFromInput(input)
return isPunctuationKeyToken(physicalKey) ? physicalKey : null
}
function shouldUseSemanticPunctuation(
@@ -1224,45 +1334,37 @@ function keyMatches(
platform: NodeJS.Platform
): boolean {
if (parsedKey.length === 1 && parsedKey >= 'A' && parsedKey <= 'Z') {
return letterKeyMatches(input, parsedKey)
return letterKeyMatches(input, parsedKey, parsed, platform)
}
if (parsedKey.length === 1 && parsedKey >= '0' && parsedKey <= '9') {
return input.key === parsedKey || input.code === `Digit${parsedKey}`
return digitKeyMatches(input, parsedKey)
}
const key = input.key ?? ''
const code = input.code ?? ''
switch (parsedKey) {
case 'BracketLeft':
case 'BracketRight':
case 'Backslash': {
// Why: shortcut labels name logical punctuation, but international
// layouts can report the same character from different physical codes.
const semanticKey = semanticPunctuationKey(input)
if (semanticKey !== null && shouldUseSemanticPunctuation(parsed, input, platform)) {
return semanticKey === parsedKey
}
return code === parsedKey
}
case 'Minus':
// Why: shifted "_" is terminal undo/readline input. Users who want it
// as zoom-out can bind it explicitly instead of having the default steal it.
return key === '-' || key === 'Minus' || code === 'Minus'
case 'Underscore':
return key === '_' || key === 'Underscore'
case 'Equal':
return key === '=' || key === 'Equal' || code === 'Equal'
case 'Plus':
return key === '+' || key === 'Plus'
case 'NumpadAdd':
return code === 'NumpadAdd' || key === 'Add'
case 'NumpadSubtract':
return code === 'NumpadSubtract' || key === 'Subtract'
case 'Enter':
return key === 'Enter' && (code === 'Enter' || code === 'NumpadEnter' || code === '')
default:
return key === parsedKey || code === parsedKey
if (parsedKey === 'NumpadAdd' || parsedKey === 'NumpadSubtract') {
return (
numpadCodeKeyTokenFromInput(input) === parsedKey ||
logicalKeyTokenFromInput(input) === parsedKey
)
}
if (isPunctuationKeyToken(parsedKey)) {
// Why: shortcut labels name logical punctuation, but international
// layouts can report the same character from different physical codes.
const semanticKey = semanticPunctuationKey(input)
if (semanticKey !== null) {
if (!shouldUseSemanticPunctuation(parsed, input, platform)) {
return false
}
return semanticKey === parsedKey
}
return canUsePhysicalCodeFallback(input) && physicalPunctuationKey(input) === parsedKey
}
const logicalKey = logicalKeyTokenFromInput(input)
if (logicalKey !== null) {
return logicalKey === parsedKey
}
return canUsePhysicalCodeFallback(input) && physicalCodeKeyTokenFromInput(input) === parsedKey
}
export function keybindingMatchesInput(
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { buildLinearTeamUrl, getLinearOrganizationUrlKeyFromIssueUrl } from './linear-links'
describe('linear links', () => {
it('builds team URLs from workspace and team keys', () => {
expect(buildLinearTeamUrl({ organizationUrlKey: 'acme', teamKey: 'ENG' })).toBe(
'https://linear.app/acme/team/ENG/all'
)
})
it('encodes URL path segments', () => {
expect(buildLinearTeamUrl({ organizationUrlKey: 'acme inc', teamKey: 'A/B' })).toBe(
'https://linear.app/acme%20inc/team/A%2FB/all'
)
})
it('extracts the workspace URL key from Linear issue URLs', () => {
expect(getLinearOrganizationUrlKeyFromIssueUrl('https://linear.app/acme/issue/ENG-1')).toBe(
'acme'
)
})
})
+26
View File
@@ -0,0 +1,26 @@
export function buildLinearTeamUrl(args: {
organizationUrlKey?: string | null
teamKey?: string | null
}): string | null {
const organizationUrlKey = args.organizationUrlKey?.trim()
const teamKey = args.teamKey?.trim()
if (!organizationUrlKey || !teamKey) {
return null
}
return `https://linear.app/${encodeURIComponent(organizationUrlKey)}/team/${encodeURIComponent(teamKey)}/all`
}
export function getLinearOrganizationUrlKeyFromIssueUrl(issueUrl?: string | null): string | null {
if (!issueUrl) {
return null
}
try {
const parsed = new URL(issueUrl)
if (parsed.hostname !== 'linear.app') {
return null
}
return parsed.pathname.split('/').filter(Boolean)[0] ?? null
} catch {
return null
}
}
+265
View File
@@ -0,0 +1,265 @@
// Why: the auto-generated workspace name pool lives in shared (not renderer)
// so the main process can recognize an Orca-generated branch name when deciding
// whether auto-rename-from-work is allowed to overwrite it.
export const MARINE_CREATURES = [
'Nautilus',
'Seahorse',
'Starfish',
'Coral',
'Narwhal',
'Jellyfish',
'Octopus',
'Manta',
'Dolphin',
'Manatee',
'Cuttlefish',
'Anemone',
'Urchin',
'Triton',
'Nudibranch',
'Coelacanth',
'Oarfish',
'Beluga',
'Dugong',
'Porpoise',
'Stingray',
'Hammerhead',
'Wobbegong',
'Sawfish',
'Chimaera',
'Anglerfish',
'Viperfish',
'Dragonfish',
'Axolotl',
'Otter',
'Seadragon',
'Seahare',
'Squid',
'Argonaut',
'Sponge',
'Barnacle',
'Cowrie',
'Guitarfish',
'Lamprey',
'Isopod',
'Lionfish',
'Clownfish',
'Angelfish',
'Butterflyfish',
'Parrotfish',
'Pufferfish',
'Moonfish',
'Firefish',
'Unicornfish',
'Rainbowfish',
'Betta',
'Discus',
'Arowana',
'Koi',
'Piranha',
'Barracuda',
'Moray',
'Sunfish',
'Lanternfish',
'Archerfish',
'Mudskipper',
'Hatchetfish',
'Knifefish',
'Leaffish',
'Glassfish',
'Ropefish',
'Bichir',
'Tigerfish',
'Cardinalfish',
'Lungfish',
'Opah',
'Frogfish',
'Stonefish',
'Cutlassfish',
'Paddlefish',
'Arapaima',
'Mandarin',
'Blobfish',
'Thresher',
'Vaquita',
'Pipefish',
'Guppy',
'Tetra',
'Danio',
'Cichlid',
'Oscar',
'Gourami',
'Killifish',
'Rasbora',
'Pleco',
'Goldfish',
'Molly',
'Platy',
'Barb',
'Loach',
'Medaka',
'Pacu',
'Filefish',
'Boxfish',
'Cowfish',
'Surgeonfish',
'Damselfish',
'Wrasse',
'Goby',
'Blenny',
'Conger',
'Sculpin',
'Darter',
'Remora',
'Pilotfish',
'Trumpetfish',
'Cornetfish',
'Jawfish',
'Toadfish',
'Pearlfish',
'Driftfish',
'Lumpfish',
'Snailfish',
'Stickleback',
'Halfbeak',
'Snipefish',
'Pencilfish',
'Snakehead',
'Tripletail',
'Lookdown',
'Sweetlips',
'Squirrelfish',
'Soldierfish',
'Rabbitfish',
'Hawkfish',
'Bannerfish',
'Batfish',
'Chromis',
'Anthias',
'Fusilier',
'Sweeper',
'Emperor',
'Ponyfish',
'Kelpfish',
'Weever',
'Pearlside',
'Opaleye',
'Ballyhoo',
'Needlefish',
'Triggerfish',
'Gar',
'Tarpon',
'Sailfish',
'Conch',
'Walrus',
'Seal',
'Penguin',
'Hagfish',
'Gulper',
'Hydra',
'Krill',
'Salp',
'Tunicate',
'Crinoid',
'Polyp',
'Limpet',
'Whelk',
'Bowfin',
'Minnow',
'Gudgeon',
'Goldeye',
'Mooneye',
'Soapfish',
'Leatherjacket',
'Rudderfish',
'Sabrefish',
'Puffin',
'Albatross',
'Osprey',
'Pelican',
'Petrel',
'Gannet',
'Tern',
'Cormorant',
'Leatherback',
'Hawksbill',
'Loggerhead',
'Dottyback',
'Basslet',
'Gramma',
'Hamlet',
'Hogfish',
'Dartfish',
'Foxface',
'Shrimpfish',
'Pyrosome',
'Seapen',
'Medusa',
'Candlefish',
'Silverside',
'Shiner',
'Eelpout',
'Madtom',
'Shovelnose',
'Sandlance',
'Tubesnout',
'Marlin',
'Bonefish',
'Dace',
'Rudd',
'Spirula',
'Permit',
'Grunt',
'Trevally',
'Croaker',
'Drum',
'Ladyfish',
'Bullhead',
'Torpedo',
'Skate',
'Hermit',
'Fiddler',
'Fireworm',
'Tubeworm',
'Amphipod',
'Copepod',
'Pteropod',
'Seaslug',
'Swordtail',
'Bluegill',
'Pickerel',
'Redhorse',
'Logperch',
'Goblin',
'Catshark',
'Harlequin',
'Murex',
'Volute',
'Wentletrap',
'Nerite',
'Stargazer',
'Handfish',
'Flyingfish',
'Waspfish',
'Skua',
'Shearwater',
'Guillemot',
'Razorbill',
'Auk',
'Fulmar',
'Murre',
'Velvetfish',
'Prowfish',
'Sandperch',
'Bobtail',
'Chiton',
'Featherstar',
'Nereid',
'Siren',
'Kraken',
'Selkie',
'Leviathan',
'Wolffish',
'Wreckfish',
'Horseshoe'
] as const
@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest'
import { eventSchemas } from './telemetry-events'
const attemptId = '2fbac1e3-5094-45b4-80a6-90281e6e9e09'
describe('nested repo import telemetry schemas', () => {
it('accepts scan, action, and result telemetry payloads', () => {
expect(
eventSchemas.add_repo_nested_scan_result.safeParse({
attempt_id: attemptId,
surface: 'onboarding',
runtime_kind: 'local',
result: 'review_shown',
selected_path_kind: 'non_git_folder',
found_count: 3,
found_count_bucket: '2-3',
truncated: false,
timed_out: false
}).success
).toBe(true)
expect(
eventSchemas.add_repo_nested_import_action.safeParse({
attempt_id: attemptId,
surface: 'sidebar',
runtime_kind: 'ssh',
action: 'import_group',
found_count: 3,
found_count_bucket: '2-3',
selected_count: 3,
selected_count_bucket: '2-3',
all_selected: true
}).success
).toBe(true)
expect(
eventSchemas.add_repo_nested_import_result.safeParse({
attempt_id: attemptId,
surface: 'onboarding',
runtime_kind: 'runtime',
mode: 'group',
outcome: 'success',
found_count: 3,
found_count_bucket: '2-3',
selected_count: 3,
selected_count_bucket: '2-3',
imported_count: 3,
imported_count_bucket: '2-3',
already_known_count: 0,
already_known_count_bucket: '0',
failed_count: 0,
failed_count_bucket: '0',
all_selected: true
}).success
).toBe(true)
})
it('rejects raw paths, repo names, and raw errors via .strict()', () => {
expect(
eventSchemas.add_repo_nested_scan_result.safeParse({
attempt_id: attemptId,
surface: 'onboarding',
runtime_kind: 'local',
result: 'review_shown',
selected_path_kind: 'non_git_folder',
found_count: 1,
found_count_bucket: '1',
truncated: false,
timed_out: false,
selected_path: '/Users/alice/work/platform'
}).success
).toBe(false)
expect(
eventSchemas.add_repo_nested_import_action.safeParse({
attempt_id: attemptId,
surface: 'sidebar',
runtime_kind: 'local',
action: 'import_group',
found_count: 1,
found_count_bucket: '1',
selected_count: 1,
selected_count_bucket: '1',
all_selected: true,
repo_name: 'secret-service'
}).success
).toBe(false)
expect(
eventSchemas.add_repo_nested_import_result.safeParse({
attempt_id: attemptId,
surface: 'sidebar',
runtime_kind: 'ssh',
mode: 'group',
outcome: 'failed',
found_count: 1,
found_count_bucket: '1',
selected_count: 1,
selected_count_bucket: '1',
imported_count: 0,
imported_count_bucket: '0',
already_known_count: 0,
already_known_count_bucket: '0',
failed_count: 1,
failed_count_bucket: '1',
all_selected: true,
error_message: 'failed at /Users/alice/work/platform'
}).success
).toBe(false)
})
it('rejects unbounded imported counts', () => {
const parsed = eventSchemas.add_repo_nested_import_result.safeParse({
attempt_id: attemptId,
surface: 'onboarding',
runtime_kind: 'local',
mode: 'separate',
outcome: 'success',
found_count: 501,
found_count_bucket: '16+',
selected_count: 501,
selected_count_bucket: '16+',
imported_count: 501,
imported_count_bucket: '16+',
already_known_count: 0,
already_known_count_bucket: '0',
failed_count: 0,
failed_count_bucket: '0',
all_selected: true
})
expect(parsed.success).toBe(false)
})
it('rejects mismatched exact counts and buckets', () => {
expect(
eventSchemas.add_repo_nested_import_action.safeParse({
attempt_id: attemptId,
surface: 'sidebar',
runtime_kind: 'local',
action: 'import_group',
found_count: 1,
found_count_bucket: '16+',
selected_count: 1,
selected_count_bucket: '1',
all_selected: true
}).success
).toBe(false)
expect(
eventSchemas.add_repo_nested_import_result.safeParse({
attempt_id: attemptId,
surface: 'sidebar',
runtime_kind: 'local',
mode: 'group',
outcome: 'success',
found_count: 2,
found_count_bucket: '2-3',
selected_count: 2,
selected_count_bucket: '2-3',
imported_count: 2,
imported_count_bucket: '16+',
already_known_count: 0,
already_known_count_bucket: '0',
failed_count: 0,
failed_count_bucket: '0',
all_selected: true
}).success
).toBe(false)
})
})
+221
View File
@@ -0,0 +1,221 @@
import { describe, expect, it } from 'vitest'
import {
NESTED_REPO_TELEMETRY_MAX_REPO_COUNT,
bucketNestedRepoTelemetryCount,
buildNestedRepoImportActionTelemetry,
buildNestedRepoImportResultTelemetry,
buildNestedRepoScanTelemetry,
capNestedRepoTelemetryCount,
createNestedRepoTelemetryAttemptId,
shouldEmitNestedRepoImportSubmitTelemetry
} from './nested-repo-telemetry'
import type { NestedRepoScanResult, ProjectGroupImportResult } from './types'
const scanResult: NestedRepoScanResult = {
selectedPath: '/workspace/platform',
selectedPathKind: 'non_git_folder',
repos: [
{ path: '/workspace/platform/apps/web', displayName: 'web', depth: 2 },
{ path: '/workspace/platform/services/api', displayName: 'api', depth: 2 },
{ path: '/workspace/platform/services/billing', displayName: 'billing', depth: 2 }
],
truncated: false,
timedOut: false,
durationMs: 42,
maxDepth: 3
}
const attemptId = '2fbac1e3-5094-45b4-80a6-90281e6e9e09'
const nextAttemptId = 'd22bb9e0-b7f8-480a-8a2a-9b34f84f2c42'
describe('nested repo telemetry payloads', () => {
it('caps and buckets repo counts for low-cardinality breakdowns', () => {
expect(capNestedRepoTelemetryCount(-1)).toBe(0)
expect(capNestedRepoTelemetryCount(2.9)).toBe(2)
expect(capNestedRepoTelemetryCount(Number.NaN)).toBe(0)
expect(capNestedRepoTelemetryCount(999)).toBe(NESTED_REPO_TELEMETRY_MAX_REPO_COUNT)
expect(bucketNestedRepoTelemetryCount(0)).toBe('0')
expect(bucketNestedRepoTelemetryCount(1)).toBe('1')
expect(bucketNestedRepoTelemetryCount(3)).toBe('2-3')
expect(bucketNestedRepoTelemetryCount(7)).toBe('4-7')
expect(bucketNestedRepoTelemetryCount(15)).toBe('8-15')
expect(bucketNestedRepoTelemetryCount(16)).toBe('16+')
})
it('classifies a scan that should show nested repo review', () => {
expect(
buildNestedRepoScanTelemetry({
attemptId,
surface: 'onboarding',
runtimeKind: 'local',
scan: scanResult
})
).toEqual({
attempt_id: attemptId,
surface: 'onboarding',
runtime_kind: 'local',
result: 'review_shown',
selected_path_kind: 'non_git_folder',
found_count: 3,
found_count_bucket: '2-3',
truncated: false,
timed_out: false
})
})
it('records import action selection without raw path details', () => {
expect(
buildNestedRepoImportActionTelemetry({
attemptId,
surface: 'sidebar',
runtimeKind: 'ssh',
action: 'import_group',
foundCount: 3,
selectedCount: 2
})
).toEqual({
attempt_id: attemptId,
surface: 'sidebar',
runtime_kind: 'ssh',
action: 'import_group',
found_count: 3,
found_count_bucket: '2-3',
selected_count: 2,
selected_count_bucket: '2-3',
all_selected: false
})
})
it('computes all_selected from raw counts before caps are applied', () => {
const action = buildNestedRepoImportActionTelemetry({
attemptId,
surface: 'sidebar',
runtimeKind: 'local',
action: 'import_group',
foundCount: 600,
selectedCount: 500
})
const result = buildNestedRepoImportResultTelemetry({
attemptId,
surface: 'sidebar',
runtimeKind: 'local',
mode: 'group',
foundCount: 600,
selectedCount: 500,
result: { importedCount: 500, alreadyKnownCount: 0, failedCount: 0, projects: [] }
})
expect(action.found_count).toBe(500)
expect(action.selected_count).toBe(500)
expect(action.all_selected).toBe(false)
expect(result.all_selected).toBe(false)
})
it('keeps exact imported counts on import result payloads', () => {
const result: ProjectGroupImportResult = {
importedCount: 2,
alreadyKnownCount: 1,
failedCount: 1,
projects: [
{ path: '/workspace/platform/apps/web', projectId: 'web', status: 'imported' },
{ path: '/workspace/platform/services/api', projectId: 'api', status: 'imported' },
{
path: '/workspace/platform/services/billing',
projectId: 'billing',
status: 'already-known'
},
{ path: '/workspace/platform/tools/cli', status: 'failed', error: 'Not a git repo' }
]
}
expect(
buildNestedRepoImportResultTelemetry({
attemptId,
surface: 'onboarding',
runtimeKind: 'runtime',
mode: 'group',
foundCount: 4,
selectedCount: 4,
result
})
).toMatchObject({
attempt_id: attemptId,
surface: 'onboarding',
runtime_kind: 'runtime',
mode: 'group',
outcome: 'partial_failure',
found_count: 4,
selected_count: 4,
imported_count: 2,
already_known_count: 1,
failed_count: 1,
all_selected: true
})
})
it('generates non-persistent random attempt ids', () => {
const first = createNestedRepoTelemetryAttemptId()
const second = createNestedRepoTelemetryAttemptId()
expect(first).toMatch(/^[0-9a-f-]{36}$/)
expect(second).not.toBe(first)
})
it('threads one attempt id across scan, action, and result and allows a new scan id', () => {
const scan = buildNestedRepoScanTelemetry({
attemptId,
surface: 'sidebar',
runtimeKind: 'local',
scan: scanResult
})
const action = buildNestedRepoImportActionTelemetry({
attemptId,
surface: 'sidebar',
runtimeKind: 'local',
action: 'import_separate',
foundCount: 3,
selectedCount: 3
})
const result = buildNestedRepoImportResultTelemetry({
attemptId,
surface: 'sidebar',
runtimeKind: 'local',
mode: 'separate',
foundCount: 3,
selectedCount: 3,
result: { importedCount: 3, alreadyKnownCount: 0, failedCount: 0, projects: [] }
})
const nextScan = buildNestedRepoScanTelemetry({
attemptId: nextAttemptId,
surface: 'sidebar',
runtimeKind: 'local',
scan: scanResult
})
expect(action.attempt_id).toBe(scan.attempt_id)
expect(result.attempt_id).toBe(scan.attempt_id)
expect(nextScan.attempt_id).not.toBe(scan.attempt_id)
})
it('prevents zero-selection submit telemetry', () => {
expect(
shouldEmitNestedRepoImportSubmitTelemetry({
attemptId,
selectedCount: 0
})
).toBe(false)
expect(
shouldEmitNestedRepoImportSubmitTelemetry({
attemptId,
selectedCount: 1,
isBusy: true
})
).toBe(false)
expect(
shouldEmitNestedRepoImportSubmitTelemetry({
attemptId,
selectedCount: 1
})
).toBe(true)
})
})
+228
View File
@@ -0,0 +1,228 @@
import type {
NestedRepoScanResult,
ProjectGroupImportMode,
ProjectGroupImportResult
} from './types'
export const NESTED_REPO_TELEMETRY_MAX_REPO_COUNT = 500
export const NESTED_REPO_TELEMETRY_SURFACES = ['onboarding', 'sidebar'] as const
export type NestedRepoTelemetrySurface = (typeof NESTED_REPO_TELEMETRY_SURFACES)[number]
export const NESTED_REPO_TELEMETRY_RUNTIME_KINDS = ['local', 'runtime', 'ssh'] as const
export type NestedRepoTelemetryRuntimeKind = (typeof NESTED_REPO_TELEMETRY_RUNTIME_KINDS)[number]
export const NESTED_REPO_SCAN_RESULTS = [
'review_shown',
'git_repo',
'no_nested_repos',
'scan_failed'
] as const
export type NestedRepoScanTelemetryResult = (typeof NESTED_REPO_SCAN_RESULTS)[number]
export const NESTED_REPO_IMPORT_ACTIONS = ['import_group', 'import_separate', 'back'] as const
export type NestedRepoImportTelemetryAction = (typeof NESTED_REPO_IMPORT_ACTIONS)[number]
export const NESTED_REPO_IMPORT_OUTCOMES = ['success', 'partial_failure', 'failed'] as const
export type NestedRepoImportTelemetryOutcome = (typeof NESTED_REPO_IMPORT_OUTCOMES)[number]
export const NESTED_REPO_COUNT_BUCKETS = ['0', '1', '2-3', '4-7', '8-15', '16+'] as const
export type NestedRepoCountBucket = (typeof NESTED_REPO_COUNT_BUCKETS)[number]
type NestedRepoTelemetryBase = {
attempt_id: string
surface: NestedRepoTelemetrySurface
runtime_kind: NestedRepoTelemetryRuntimeKind
}
export type NestedRepoScanTelemetry = NestedRepoTelemetryBase & {
result: NestedRepoScanTelemetryResult
selected_path_kind?: NestedRepoScanResult['selectedPathKind']
found_count: number
found_count_bucket: NestedRepoCountBucket
truncated: boolean
timed_out: boolean
}
export type NestedRepoImportActionTelemetry = NestedRepoTelemetryBase & {
action: NestedRepoImportTelemetryAction
found_count: number
found_count_bucket: NestedRepoCountBucket
selected_count: number
selected_count_bucket: NestedRepoCountBucket
all_selected: boolean
}
export type NestedRepoImportResultTelemetry = NestedRepoTelemetryBase & {
mode: ProjectGroupImportMode
outcome: NestedRepoImportTelemetryOutcome
found_count: number
found_count_bucket: NestedRepoCountBucket
selected_count: number
selected_count_bucket: NestedRepoCountBucket
imported_count: number
imported_count_bucket: NestedRepoCountBucket
already_known_count: number
already_known_count_bucket: NestedRepoCountBucket
failed_count: number
failed_count_bucket: NestedRepoCountBucket
all_selected: boolean
}
export function capNestedRepoTelemetryCount(count: number): number {
if (!Number.isFinite(count)) {
return 0
}
return Math.max(0, Math.min(NESTED_REPO_TELEMETRY_MAX_REPO_COUNT, Math.floor(count)))
}
function normalizeNestedRepoTelemetryCount(count: number): number {
if (!Number.isFinite(count)) {
return 0
}
return Math.max(0, Math.floor(count))
}
export function bucketNestedRepoTelemetryCount(count: number): NestedRepoCountBucket {
const capped = capNestedRepoTelemetryCount(count)
if (capped === 0) {
return '0'
}
if (capped === 1) {
return '1'
}
if (capped <= 3) {
return '2-3'
}
if (capped <= 7) {
return '4-7'
}
if (capped <= 15) {
return '8-15'
}
return '16+'
}
export function shouldEmitNestedRepoImportSubmitTelemetry(args: {
attemptId: string | null
selectedCount: number
isBusy?: boolean
}): boolean {
return Boolean(args.attemptId && args.selectedCount > 0 && !args.isBusy)
}
export function createNestedRepoTelemetryAttemptId(): string {
const cryptoApi = globalThis.crypto
if (typeof cryptoApi?.randomUUID === 'function') {
return cryptoApi.randomUUID()
}
const bytes = new Uint8Array(16)
if (typeof cryptoApi?.getRandomValues === 'function') {
cryptoApi.getRandomValues(bytes)
} else {
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Math.floor(Math.random() * 256)
}
}
// Why: keep the fallback schema-compatible without deriving from any stable repo input.
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'))
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
}
export function buildNestedRepoScanTelemetry(args: {
attemptId: string
surface: NestedRepoTelemetrySurface
runtimeKind: NestedRepoTelemetryRuntimeKind
scan: NestedRepoScanResult | null
}): NestedRepoScanTelemetry {
const foundCount = capNestedRepoTelemetryCount(args.scan?.repos.length ?? 0)
const result: NestedRepoScanTelemetryResult =
args.scan === null
? 'scan_failed'
: args.scan.selectedPathKind === 'git_repo'
? 'git_repo'
: foundCount > 0
? 'review_shown'
: 'no_nested_repos'
return {
attempt_id: args.attemptId,
surface: args.surface,
runtime_kind: args.runtimeKind,
result,
...(args.scan ? { selected_path_kind: args.scan.selectedPathKind } : {}),
found_count: foundCount,
found_count_bucket: bucketNestedRepoTelemetryCount(foundCount),
truncated: args.scan?.truncated ?? false,
timed_out: args.scan?.timedOut ?? false
}
}
export function buildNestedRepoImportActionTelemetry(args: {
attemptId: string
surface: NestedRepoTelemetrySurface
runtimeKind: NestedRepoTelemetryRuntimeKind
action: NestedRepoImportTelemetryAction
foundCount: number
selectedCount: number
}): NestedRepoImportActionTelemetry {
const rawFoundCount = normalizeNestedRepoTelemetryCount(args.foundCount)
const rawSelectedCount = normalizeNestedRepoTelemetryCount(args.selectedCount)
const foundCount = capNestedRepoTelemetryCount(args.foundCount)
const selectedCount = capNestedRepoTelemetryCount(args.selectedCount)
return {
attempt_id: args.attemptId,
surface: args.surface,
runtime_kind: args.runtimeKind,
action: args.action,
found_count: foundCount,
found_count_bucket: bucketNestedRepoTelemetryCount(foundCount),
selected_count: selectedCount,
selected_count_bucket: bucketNestedRepoTelemetryCount(selectedCount),
all_selected: rawFoundCount > 0 && rawSelectedCount === rawFoundCount
}
}
export function buildNestedRepoImportResultTelemetry(args: {
attemptId: string
surface: NestedRepoTelemetrySurface
runtimeKind: NestedRepoTelemetryRuntimeKind
mode: ProjectGroupImportMode
foundCount: number
selectedCount: number
result: ProjectGroupImportResult | null
}): NestedRepoImportResultTelemetry {
const rawFoundCount = normalizeNestedRepoTelemetryCount(args.foundCount)
const rawSelectedCount = normalizeNestedRepoTelemetryCount(args.selectedCount)
const foundCount = capNestedRepoTelemetryCount(args.foundCount)
const selectedCount = capNestedRepoTelemetryCount(args.selectedCount)
const importedCount = capNestedRepoTelemetryCount(args.result?.importedCount ?? 0)
const alreadyKnownCount = capNestedRepoTelemetryCount(args.result?.alreadyKnownCount ?? 0)
const failedCount = capNestedRepoTelemetryCount(args.result?.failedCount ?? selectedCount)
const acceptedCount = importedCount + alreadyKnownCount
const outcome: NestedRepoImportTelemetryOutcome =
acceptedCount === 0 ? 'failed' : failedCount > 0 ? 'partial_failure' : 'success'
return {
attempt_id: args.attemptId,
surface: args.surface,
runtime_kind: args.runtimeKind,
mode: args.mode,
outcome,
found_count: foundCount,
found_count_bucket: bucketNestedRepoTelemetryCount(foundCount),
selected_count: selectedCount,
selected_count_bucket: bucketNestedRepoTelemetryCount(selectedCount),
imported_count: importedCount,
imported_count_bucket: bucketNestedRepoTelemetryCount(importedCount),
already_known_count: alreadyKnownCount,
already_known_count_bucket: bucketNestedRepoTelemetryCount(alreadyKnownCount),
failed_count: failedCount,
failed_count_bucket: bucketNestedRepoTelemetryCount(failedCount),
all_selected: rawFoundCount > 0 && rawSelectedCount === rawFoundCount
}
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { detectPiAgentKindFromCommand } from './pi-agent-kind'
describe('detectPiAgentKindFromCommand', () => {
it('returns "pi" for undefined or empty commands', () => {
expect(detectPiAgentKindFromCommand(undefined)).toBe('pi')
expect(detectPiAgentKindFromCommand('')).toBe('pi')
})
it('returns "pi" for a bare pi launch', () => {
expect(detectPiAgentKindFromCommand('pi')).toBe('pi')
expect(detectPiAgentKindFromCommand('pi --resume')).toBe('pi')
})
it('returns "omp" for a bare omp launch', () => {
expect(detectPiAgentKindFromCommand('omp')).toBe('omp')
expect(detectPiAgentKindFromCommand('omp -v')).toBe('omp')
expect(detectPiAgentKindFromCommand('omp.sh')).toBe('omp')
})
it('returns "omp" for omp launched via an absolute path', () => {
expect(detectPiAgentKindFromCommand('/usr/local/bin/omp')).toBe('omp')
expect(detectPiAgentKindFromCommand('~/bin/omp.sh')).toBe('omp')
})
it('returns "pi" for pi launched via an absolute path', () => {
expect(detectPiAgentKindFromCommand('/usr/local/bin/pi')).toBe('pi')
})
it('does not confuse "pi" with substrings like pip / mpi / python', () => {
// Why: regression guard for the word-boundary regex. Without
// boundary protection, any command containing the letters "pi"
// would be classified as a Pi launch.
expect(detectPiAgentKindFromCommand('pip install foo')).toBe('pi')
expect(detectPiAgentKindFromCommand('mpirun -n 4 ./app')).toBe('pi')
expect(detectPiAgentKindFromCommand('python3 script.py')).toBe('pi')
})
it('does not confuse "omp" with substrings like comp / pomp', () => {
// Why: regression guard for the OMP boundary; falls back to 'pi'
// when no match, which matches the pre-launch default.
expect(detectPiAgentKindFromCommand('compile this')).toBe('pi')
expect(detectPiAgentKindFromCommand('pomp.exe')).toBe('pi')
})
it('matches case-insensitively on Windows-style executables', () => {
expect(detectPiAgentKindFromCommand('OMP.EXE')).toBe('omp')
expect(detectPiAgentKindFromCommand('PI.CMD')).toBe('pi')
})
})
+60
View File
@@ -0,0 +1,60 @@
import { TUI_AGENT_CONFIG } from './tui-agent-config'
/**
* Pi-compatible agent kinds. Both Pi and OMP (omp.sh) consume the same
* `PI_CODING_AGENT_DIR` env contract and the same extension API, but each
* defaults its on-disk config dir to a different `~/.<kind>/agent` path.
* The Orca per-PTY overlay needs to know which agent is being launched so it
* mirrors the user's actual source dir for THAT agent, with no cross-agent
* fallback (otherwise switching agents in the same workspace silently shadows
* the other agent's user extensions).
*/
export type PiAgentKind = 'pi' | 'omp'
const OMP_LAUNCH_CMD = TUI_AGENT_CONFIG.omp.launchCmd
// Why: regex carved to avoid matching `pi` inside `pip`, `mpi`, `api`,
// `python`, or `omp` inside `comp`, `omp.sh` (acceptable - that's literally
// the binary), `omp-foo`, etc. The leading boundary excludes alnum/underscore
// AND `-`/`.`/`/`/`\\` so that `~/bin/pi` or `./omp` still match but
// `mpi`/`pomp` do not. Trailing boundary allows whitespace, end-of-string,
// shell separators, or argv-style flags (`pi -v`, `omp --help`).
const BOUNDARY_BEFORE = `(?:^|[\\s;&|('"\`])`
const BOUNDARY_AFTER = `(?:$|[\\s;&|)'"\`])`
const PATH_PREFIX = `(?:[^\\s;&|('"\`]*[\\\\/])?`
function makeLaunchCmdRegex(launchCmd: string): RegExp {
// Why: launchCmd may be a multi-token string ("hermes --tui"); only the
// first token is the binary name. Use that for matching.
const binary = launchCmd.split(/\s+/, 1)[0]
const escaped = binary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return new RegExp(
`${BOUNDARY_BEFORE}${PATH_PREFIX}${escaped}(?:\\.cmd|\\.exe|\\.sh)?${BOUNDARY_AFTER}`,
'i'
)
}
const OMP_REGEX = makeLaunchCmdRegex(OMP_LAUNCH_CMD)
/**
* Identify the Pi-compatible agent kind a launch command targets.
*
* Returns 'omp' when the command launches OMP (`omp` / `omp.sh`), otherwise
* defaults to 'pi'. Defaulting to 'pi' preserves prior behavior for the
* non-launch case (e.g. bare shells that may later invoke `pi`) where the
* `~/.pi/agent` overlay was always materialized.
*
* NEVER cross-fall-back: a missing source dir for the resolved kind is the
* overlay's "no source, just Orca extensions" branch - the other agent's
* dir MUST NOT be substituted.
*/
export function detectPiAgentKindFromCommand(command: string | undefined): PiAgentKind {
if (typeof command === 'string' && OMP_REGEX.test(command)) {
return 'omp'
}
// Why: PI launches and the no-command (bare-shell) fallback both resolve to
// 'pi'. A bare shell that later invokes `pi` keeps the historical default;
// if it later invokes `omp`, the status extension re-routes at runtime based
// on the executable name so attribution still lands on OMP.
return 'pi'
}
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
clearMissingProjectGroupMemberships,
createProjectGroup,
getNextProjectGroupOrder,
getProjectGroupSubtreeIds,
normalizeProjectGroupName,
normalizeProjectGroups
} from './project-groups'
import type { Repo } from './types'
function repo(overrides: Partial<Repo>): Repo {
return {
id: overrides.id ?? 'repo-1',
path: overrides.path ?? '/repo',
displayName: overrides.displayName ?? 'repo',
badgeColor: '#999',
addedAt: 1,
kind: 'git',
...overrides
}
}
describe('project-groups', () => {
it('creates a durable project group with normalized defaults', () => {
const group = createProjectGroup({
name: ' Platform ',
parentPath: '/srv/platform',
createdFrom: 'folder-scan',
tabOrder: 3,
now: 100
})
expect(group).toMatchObject({
name: 'Platform',
parentPath: '/srv/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 3,
isCollapsed: false,
color: null,
createdAt: 100,
updatedAt: 100
})
})
it('trims empty group names to a fallback', () => {
expect(normalizeProjectGroupName(' ', 'Existing')).toBe('Existing')
})
it('normalizes persisted groups and drops malformed entries', () => {
const groups = normalizeProjectGroups([
{ id: 'b', name: 'B', tabOrder: 2 },
{
id: 'a',
name: 'A',
tabOrder: 1,
parentGroupId: 'missing',
createdFrom: 'folder-scan',
isCollapsed: true
},
{ id: 'a', name: 'duplicate' },
{ name: 'missing id' }
])
expect(groups.map((group) => group.id)).toEqual(['a', 'b'])
expect(groups[0]).toMatchObject({
createdFrom: 'folder-scan',
isCollapsed: true,
parentGroupId: null
})
})
it('clears repo memberships whose group no longer exists', () => {
const groups = [createProjectGroup({ name: 'Known', createdFrom: 'manual', tabOrder: 0 })]
const repos = clearMissingProjectGroupMemberships(
[
repo({ id: 'known', projectGroupId: groups[0].id }),
repo({ id: 'missing', projectGroupId: 'x' })
],
groups
)
expect(repos.find((entry) => entry.id === 'known')?.projectGroupId).toBe(groups[0].id)
expect(repos.find((entry) => entry.id === 'missing')?.projectGroupId).toBeNull()
})
it('computes the next order inside a group independently from ungrouped repos', () => {
expect(
getNextProjectGroupOrder(
[
repo({ id: 'a', projectGroupId: 'g', projectGroupOrder: 2 }),
repo({ id: 'b', projectGroupId: null, projectGroupOrder: 9 })
],
'g'
)
).toBe(3)
})
it('collects descendant group ids for subtree deletion', () => {
expect(
[
...getProjectGroupSubtreeIds(
[
{ id: 'root', parentGroupId: null },
{ id: 'child', parentGroupId: 'root' },
{ id: 'grandchild', parentGroupId: 'child' },
{ id: 'sibling', parentGroupId: null }
],
'root'
)
].sort()
).toEqual(['child', 'grandchild', 'root'])
})
})
+139
View File
@@ -0,0 +1,139 @@
import type { Repo, ProjectGroup, ProjectGroupCreatedFrom } from './types'
export const UNGROUPED_PROJECT_GROUP_KEY = 'project-group:ungrouped'
function createProjectGroupId(): string {
const randomUUID = globalThis.crypto?.randomUUID
if (randomUUID) {
return randomUUID.call(globalThis.crypto)
}
return `project-group-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
export function normalizeProjectGroupName(name: string, fallback = 'Untitled group'): string {
const trimmed = name.trim()
return trimmed.length > 0 ? trimmed : fallback
}
export function createProjectGroup(input: {
name: string
parentPath?: string | null
parentGroupId?: string | null
createdFrom: ProjectGroupCreatedFrom
tabOrder: number
now?: number
}): ProjectGroup {
const now = input.now ?? Date.now()
return {
id: createProjectGroupId(),
name: normalizeProjectGroupName(input.name),
parentPath: input.parentPath ?? null,
parentGroupId: input.parentGroupId ?? null,
createdFrom: input.createdFrom,
tabOrder: input.tabOrder,
isCollapsed: false,
color: null,
createdAt: now,
updatedAt: now
}
}
export function normalizeProjectGroups(value: unknown): ProjectGroup[] {
if (!Array.isArray(value)) {
return []
}
const groups: ProjectGroup[] = []
const seen = new Set<string>()
for (const candidate of value) {
if (!candidate || typeof candidate !== 'object') {
continue
}
const raw = candidate as Partial<ProjectGroup>
if (typeof raw.id !== 'string' || seen.has(raw.id)) {
continue
}
seen.add(raw.id)
const now = Date.now()
groups.push({
id: raw.id,
name: normalizeProjectGroupName(typeof raw.name === 'string' ? raw.name : ''),
parentPath: typeof raw.parentPath === 'string' ? raw.parentPath : null,
parentGroupId: typeof raw.parentGroupId === 'string' ? raw.parentGroupId : null,
createdFrom:
raw.createdFrom === 'manual' ||
raw.createdFrom === 'folder-scan' ||
raw.createdFrom === 'migration'
? raw.createdFrom
: 'manual',
tabOrder:
typeof raw.tabOrder === 'number' && Number.isFinite(raw.tabOrder) ? raw.tabOrder : 0,
isCollapsed: raw.isCollapsed === true,
color: typeof raw.color === 'string' ? raw.color : null,
createdAt:
typeof raw.createdAt === 'number' && Number.isFinite(raw.createdAt) ? raw.createdAt : now,
updatedAt:
typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : now
})
}
groups.sort(
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
)
const groupIds = new Set(groups.map((group) => group.id))
for (const group of groups) {
if (group.parentGroupId === group.id || !groupIds.has(group.parentGroupId ?? '')) {
group.parentGroupId = null
}
}
return groups
}
export function clearMissingProjectGroupMemberships(repos: Repo[], groups: ProjectGroup[]): Repo[] {
const groupIds = new Set(groups.map((group) => group.id))
return repos.map((repo) =>
repo.projectGroupId && !groupIds.has(repo.projectGroupId)
? { ...repo, projectGroupId: null }
: repo
)
}
export function getProjectGroupSubtreeIds(
groups: readonly Pick<ProjectGroup, 'id' | 'parentGroupId'>[],
rootGroupId: string
): Set<string> {
const childGroupsByParentId = new Map<string, string[]>()
for (const group of groups) {
if (!group.parentGroupId) {
continue
}
childGroupsByParentId.set(group.parentGroupId, [
...(childGroupsByParentId.get(group.parentGroupId) ?? []),
group.id
])
}
const subtreeIds = new Set<string>()
const pending = [rootGroupId]
while (pending.length > 0) {
const groupId = pending.pop()!
if (subtreeIds.has(groupId)) {
continue
}
subtreeIds.add(groupId)
pending.push(...(childGroupsByParentId.get(groupId) ?? []))
}
return subtreeIds
}
export function getNextProjectGroupOrder(repos: readonly Repo[], groupId: string | null): number {
let max = -1
for (const repo of repos) {
if ((repo.projectGroupId ?? null) !== groupId) {
continue
}
const order = repo.projectGroupOrder
if (typeof order === 'number' && Number.isFinite(order)) {
max = Math.max(max, order)
}
}
return max + 1
}
+10 -2
View File
@@ -64,13 +64,21 @@ export function buildPullRequestFieldsPrompt(
const trimmedInstructions = customInstructions.trim()
if (!trimmedInstructions) {
return base
return [
base,
'',
'Final output requirement:',
'Return compact JSON only with keys base, title, body, and draft. No prose or code fences.'
].join('\n')
}
return [
base,
'',
'Additional instructions from user:',
limitSection(trimmedInstructions, 4_000)
limitSection(trimmedInstructions, 4_000),
'',
'Final output requirement:',
'Return compact JSON only with keys base, title, body, and draft. No prose or code fences.'
].join('\n')
}
+11
View File
@@ -46,6 +46,17 @@ describe('sanitizeRepoIcon', () => {
src: 'data:image/png;base64,aGVsbG8=',
source: 'upload'
})
expect(
sanitizeRepoIcon({
type: 'image',
src: 'data:image/png;base64,aGVsbG8=',
source: 'file'
})
).toEqual({
type: 'image',
src: 'data:image/png;base64,aGVsbG8=',
source: 'file'
})
})
it('keeps null as an explicit reset', () => {
+20 -3
View File
@@ -1,4 +1,4 @@
export type RepoIconImageSource = 'upload' | 'favicon' | 'github'
export type RepoIconImageSource = 'upload' | 'file' | 'favicon' | 'github'
export type RepoIcon =
| { type: 'lucide'; name: string }
@@ -10,10 +10,27 @@ export const MAX_REPO_ICON_DATA_URL_LENGTH = 400 * 1024
const LUCIDE_ICON_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9]*$/
const isRepoIconImageSource = (value: string): value is RepoIconImageSource =>
value === 'upload' || value === 'favicon' || value === 'github'
value === 'upload' || value === 'file' || value === 'favicon' || value === 'github'
export function faviconUrlFromWebsite(rawUrl: string): string | null {
const trimmed = rawUrl.trim()
if (!trimmed) {
return null
}
try {
const url = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`)
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname) {
return null
}
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.hostname)}&sz=64`
} catch {
return null
}
}
function isSupportedImageSrc(src: string, source: RepoIconImageSource): boolean {
if (source === 'upload') {
if (source === 'upload' || source === 'file') {
return /^data:image\/png;base64,[A-Za-z0-9+/=\s]+$/i.test(src)
}
+3 -5
View File
@@ -16,21 +16,19 @@ export const REVIEW_STEPS: readonly ReviewStep[] = [
id: 'notes',
name: 'Notes',
subtitle: 'Notes & diffs',
description:
'Review diffs, leave notes on exact changed lines, and send focused feedback back to an agent.'
description: 'Send focused review notes to an agent.'
},
{
id: 'pr-view',
name: 'PR checks',
subtitle: 'PR checks & comments',
description: 'See PR details, CI, comments, conflicts, and merge readiness from the Checks tab.'
description: 'See PR status in the Checks tab.'
},
{
id: 'ship',
name: 'Ship with AI',
subtitle: 'Ship with AI',
description:
"Use Orca's built-in AI flow to draft commit messages and pull requests from the diff, with everything editable before submission."
description: 'Let AI prepare commit and PR drafts for you.'
}
] as const
@@ -0,0 +1,25 @@
export const ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY = '__orcaFeatureInteractionSource'
export const ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE = 'browser-pane-ui'
export function withBrowserPaneUiRuntimeRpcSource(value: unknown): unknown {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return {
[ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY]: ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE
}
}
return {
...value,
[ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY]: ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE
}
}
export function isBrowserPaneUiRuntimeRpcParams(value: unknown): boolean {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
(value as Record<string, unknown>)[ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY] ===
ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE
)
}
+7
View File
@@ -8,6 +8,7 @@ import type {
GitWorktreeInfo,
Repo,
TabGroupLayoutNode,
TerminalColorOverrides,
TerminalLayoutSnapshot,
Worktree,
WorktreeLineage,
@@ -104,11 +105,17 @@ export type RuntimeMobileSessionTerminalTab = {
parentTabId: string
leafId: string
ptyId?: string | null
terminalTheme?: RuntimeMobileTerminalTheme
agentStatus?: AgentStatusEntry | null
parentLayout?: TerminalLayoutSnapshot
isActive: boolean
}
export type RuntimeMobileTerminalTheme = {
mode: 'dark' | 'light'
theme: TerminalColorOverrides
}
export type RuntimeMobileSessionMarkdownTab = {
type: 'markdown'
id: string
+43
View File
@@ -0,0 +1,43 @@
import type { CommitMessageAiModelCapability, TuiAgent } from './types'
export type SourceControlAiOperation = 'commitMessage' | 'pullRequest' | 'branchName'
export type SourceControlAiModelChoice = {
selectedModelByAgent?: Partial<Record<TuiAgent, string>>
selectedModelByAgentByHost?: Partial<Record<string, Partial<Record<TuiAgent, string>>>>
selectedThinkingByModel?: Record<string, string>
}
export type SourceControlAiPrCreationDefaults = {
draft?: boolean
useTemplate?: boolean
generateDetailsOnOpen?: boolean
openAfterCreate?: boolean
}
export type SourceControlAiSettings = {
enabled: boolean
agentId: TuiAgent | 'custom' | null
selectedModelByAgent: Partial<Record<TuiAgent, string>>
selectedModelByAgentByHost?: Partial<Record<string, Partial<Record<TuiAgent, string>>>>
discoveredModelsByAgent?: Partial<Record<TuiAgent, CommitMessageAiModelCapability[]>>
discoveredModelsByAgentByHost?: Partial<
Record<string, Partial<Record<TuiAgent, CommitMessageAiModelCapability[]>>>
>
selectedThinkingByModel: Record<string, string>
customAgentCommand: string
instructionsByOperation: Partial<Record<SourceControlAiOperation, string>>
modelOverridesByOperation?: Partial<Record<SourceControlAiOperation, SourceControlAiModelChoice>>
prCreationDefaults?: SourceControlAiPrCreationDefaults
}
export type RepoSourceControlAiOverrides = {
modelOverridesByOperation?: Partial<Record<SourceControlAiOperation, SourceControlAiModelChoice>>
instructionsByOperation?: Partial<Record<SourceControlAiOperation, string | null>>
prCreationDefaults?: {
draft?: boolean | null
useTemplate?: boolean | null
generateDetailsOnOpen?: boolean | null
openAfterCreate?: boolean | null
}
}
+645
View File
@@ -0,0 +1,645 @@
/* eslint-disable max-lines -- Why: source-control AI precedence regressions
are easier to audit when the resolver tests stay beside the shared contract. */
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from './constants'
import {
clearSourceControlAiModelChoiceForHost,
hasConfiguredSourceControlAiInstructions,
mergeLegacyCommitMessageAiIntoSourceControlAi,
normalizeRepoSourceControlAiOverrides,
projectSourceControlAiToLegacyCommitMessageAi,
readSourceControlAiModelChoiceForHost,
resolveSourceControlAiForOperation,
resolveSourceControlAiPrCreationDefaults,
selectSourceControlAiModelChoiceForHost,
sourceControlAiSettingsFromLegacy
} from './source-control-ai'
import type {
RepoSourceControlAiOverrides,
SourceControlAiOperation
} from './source-control-ai-types'
import type { GlobalSettings } from './types'
function settings(): GlobalSettings {
const base = getDefaultSettings('/tmp')
return {
...base,
defaultTuiAgent: 'codex' as const,
sourceControlAi: {
...base.sourceControlAi!,
enabled: true,
agentId: 'codex' as const,
selectedModelByAgent: { codex: 'gpt-5.5' },
selectedThinkingByModel: { 'gpt-5.5': 'medium', 'gpt-5.4': 'high' },
instructionsByOperation: {
commitMessage: 'Global commit style',
pullRequest: 'Global PR style',
branchName: 'Global branch style'
}
}
}
}
function resolve(operation: SourceControlAiOperation, overrides?: RepoSourceControlAiOverrides) {
const result = resolveSourceControlAiForOperation({
settings: settings(),
repo: overrides ? { sourceControlAi: overrides } : null,
operation,
discoveryHostKey: 'local',
prCreationProductDefaults: {
draft: false,
useTemplate: false,
generateDetailsOnOpen: false,
openAfterCreate: false
}
})
expect(result.ok).toBe(true)
if (!result.ok) {
throw new Error(result.error)
}
return result.value
}
describe('source-control AI resolution', () => {
it('uses the global default model for every operation', () => {
expect(resolve('commitMessage').params.model).toBe('gpt-5.5')
expect(resolve('pullRequest').params.model).toBe('gpt-5.5')
expect(resolve('branchName').params.model).toBe('gpt-5.5')
})
it('resolves PR defaults even when Source Control AI generation is disabled', () => {
const base = settings()
base.sourceControlAi = {
...base.sourceControlAi!,
enabled: false,
prCreationDefaults: {
draft: true,
useTemplate: true,
generateDetailsOnOpen: false,
openAfterCreate: false
}
}
const generation = resolveSourceControlAiForOperation({
settings: base,
repo: null,
operation: 'pullRequest'
})
expect(generation.ok).toBe(false)
expect(
resolveSourceControlAiPrCreationDefaults({
settings: base,
repo: {
sourceControlAi: {
prCreationDefaults: {
draft: null,
generateDetailsOnOpen: true,
openAfterCreate: true
}
}
},
prCreationProductDefaults: {
draft: false,
useTemplate: false,
generateDetailsOnOpen: false,
openAfterCreate: false
}
})
).toEqual({
draft: true,
useTemplate: true,
generateDetailsOnOpen: true,
openAfterCreate: true
})
})
it('resolves PR defaults even when generation config is invalid', () => {
const base = settings()
base.sourceControlAi = {
...base.sourceControlAi!,
agentId: 'custom',
customAgentCommand: '',
prCreationDefaults: {
draft: false,
useTemplate: true,
generateDetailsOnOpen: true,
openAfterCreate: false
}
}
const generation = resolveSourceControlAiForOperation({
settings: base,
repo: null,
operation: 'pullRequest'
})
expect(generation.ok).toBe(false)
expect(
resolveSourceControlAiPrCreationDefaults({
settings: base,
repo: { sourceControlAi: { prCreationDefaults: { draft: true } } }
})
).toEqual({
draft: true,
useTemplate: true,
generateDetailsOnOpen: true,
openAfterCreate: false
})
})
it('treats a normalized null agent as default instead of using stale legacy agent', () => {
const base = settings()
base.defaultTuiAgent = 'codex'
base.commitMessageAi = {
...base.commitMessageAi!,
agentId: 'claude',
selectedModelByAgent: { claude: 'opus' },
selectedThinkingByModel: { opus: 'max' }
}
base.sourceControlAi = {
...base.sourceControlAi!,
agentId: null,
selectedModelByAgent: { codex: 'gpt-5.4' }
}
const result = resolveSourceControlAiForOperation({
settings: base,
repo: null,
operation: 'commitMessage',
discoveryHostKey: 'local'
})
expect(result.ok && result.value.params.agentId).toBe('codex')
expect(result.ok && result.value.params.model).toBe('gpt-5.4')
})
it('lets a global operation model override win over the global default', () => {
const base = settings()
base.sourceControlAi!.modelOverridesByOperation = {
pullRequest: { selectedModelByAgent: { codex: 'gpt-5.4' } }
}
const result = resolveSourceControlAiForOperation({
settings: base,
repo: null,
operation: 'pullRequest',
discoveryHostKey: 'local'
})
expect(result.ok && result.value.params.model).toBe('gpt-5.4')
})
it('lets a repo operation model override win over global operation override', () => {
const base = settings()
base.sourceControlAi!.modelOverridesByOperation = {
commitMessage: { selectedModelByAgent: { codex: 'gpt-5.4' } }
}
const result = resolveSourceControlAiForOperation({
settings: base,
repo: {
sourceControlAi: {
modelOverridesByOperation: {
commitMessage: { selectedModelByAgent: { codex: 'gpt-5.4-mini' } }
}
}
},
operation: 'commitMessage',
discoveryHostKey: 'local'
})
expect(result.ok && result.value.params.model).toBe('gpt-5.4-mini')
})
it('resolves thinking effort with override precedence and model default fallback', () => {
expect(resolve('commitMessage').params.thinkingLevel).toBe('medium')
expect(
resolve('commitMessage', {
modelOverridesByOperation: {
commitMessage: {
selectedModelByAgent: { codex: 'gpt-5.4' },
selectedThinkingByModel: { 'gpt-5.4': 'xhigh' }
}
}
}).params.thinkingLevel
).toBe('xhigh')
const base = settings()
base.sourceControlAi!.selectedThinkingByModel = {
'gpt-5.5': 'unsupported'
} as Record<string, string>
const result = resolveSourceControlAiForOperation({
settings: base,
repo: null,
operation: 'commitMessage',
discoveryHostKey: 'local'
})
expect(result.ok && result.value.params.thinkingLevel).toBe('low')
})
it('resolves repo instructions as replacement overrides, including explicit empty', () => {
expect(resolve('commitMessage').params.customPrompt).toBe('Global commit style')
expect(
resolve('commitMessage', {
instructionsByOperation: { commitMessage: null }
}).params.customPrompt
).toBe('Global commit style')
expect(
resolve('commitMessage', {
instructionsByOperation: { commitMessage: '' }
}).params.customPrompt
).toBe('')
expect(
resolve('commitMessage', {
instructionsByOperation: { commitMessage: 'Repo commit style' }
}).params.customPrompt
).toBe('Repo commit style')
expect(resolve('branchName').params.customPrompt).toBe('Global branch style')
expect(
resolve('branchName', {
instructionsByOperation: { branchName: 'Repo branch style' }
}).params.customPrompt
).toBe('Repo branch style')
})
it('does not treat null repo instructions as configured overrides', () => {
const base = settings()
base.sourceControlAi = {
...base.sourceControlAi!,
instructionsByOperation: {
commitMessage: '',
pullRequest: '',
branchName: ''
}
}
expect(
hasConfiguredSourceControlAiInstructions({
settings: base,
repo: { sourceControlAi: { instructionsByOperation: { commitMessage: null } } },
operation: 'commitMessage'
})
).toBe(false)
expect(
hasConfiguredSourceControlAiInstructions({
settings: base,
repo: { sourceControlAi: { instructionsByOperation: { commitMessage: '' } } },
operation: 'commitMessage'
})
).toBe(true)
})
it('resolves repo tri-state PR defaults through inherit on and off', () => {
expect(resolve('pullRequest').prCreationDefaults.draft).toBe(false)
expect(
resolve('pullRequest', {
prCreationDefaults: { draft: true, openAfterCreate: false }
}).prCreationDefaults
).toMatchObject({ draft: true, openAfterCreate: false })
})
it('maps legacy custom prompt to released split instructions', () => {
const migrated = sourceControlAiSettingsFromLegacy({
enabled: true,
agentId: 'codex',
selectedModelByAgent: { codex: 'gpt-5.5' },
selectedThinkingByModel: {},
customPrompt: 'Legacy commit prompt',
customAgentCommand: ''
})
expect(migrated.instructionsByOperation.commitMessage).toBe('Legacy commit prompt')
expect(migrated.instructionsByOperation.pullRequest).toBe('')
expect(migrated.instructionsByOperation.branchName).toBe('Legacy commit prompt')
})
it('merges legacy commit-message updates without wiping PR-only settings', () => {
const base = settings().sourceControlAi!
const merged = mergeLegacyCommitMessageAiIntoSourceControlAi(base, {
enabled: false,
agentId: 'claude',
selectedModelByAgent: { claude: 'sonnet' },
selectedThinkingByModel: { sonnet: 'medium' },
customPrompt: 'Legacy commit prompt',
customAgentCommand: 'claude'
})
expect(merged).toMatchObject({
enabled: false,
agentId: 'claude',
selectedModelByAgent: { codex: 'gpt-5.5' },
selectedThinkingByModel: { 'gpt-5.5': 'medium', 'gpt-5.4': 'high' },
customAgentCommand: 'claude',
instructionsByOperation: {
commitMessage: 'Legacy commit prompt',
pullRequest: 'Global PR style',
branchName: 'Legacy commit prompt'
}
})
expect(merged.modelOverridesByOperation?.commitMessage).toEqual({
selectedModelByAgent: { claude: 'sonnet' },
selectedThinkingByModel: { sonnet: 'medium' }
})
})
it('can map explicit legacy PR generation instructions for old runtime callers', () => {
const merged = mergeLegacyCommitMessageAiIntoSourceControlAi(
undefined,
{
enabled: true,
agentId: 'codex',
selectedModelByAgent: { codex: 'gpt-5.5' },
selectedThinkingByModel: {},
customPrompt: 'Legacy PR prompt',
customAgentCommand: ''
},
{ pullRequestInstructionsFromLegacy: true }
)
expect(merged.instructionsByOperation.pullRequest).toBe('Legacy PR prompt')
})
it('projects commit-message operation model overrides into legacy settings', () => {
const legacy = projectSourceControlAiToLegacyCommitMessageAi({
...settings().sourceControlAi!,
selectedModelByAgent: { codex: 'gpt-5.5', claude: 'sonnet' },
selectedModelByAgentByHost: {
local: { codex: 'gpt-5.5' },
'ssh:conn-1': { codex: 'gpt-5.5', claude: 'sonnet' }
},
selectedThinkingByModel: {
'gpt-5.4': 'high',
'gpt-5.5': 'medium'
},
modelOverridesByOperation: {
commitMessage: {
selectedModelByAgent: { codex: 'gpt-5.4' },
selectedModelByAgentByHost: {
local: { codex: 'gpt-5.4' },
'ssh:conn-1': { codex: 'gpt-5.4-mini' }
},
selectedThinkingByModel: {
'gpt-5.4': 'xhigh',
'gpt-5.4-mini': 'medium'
}
},
pullRequest: {
selectedModelByAgent: { codex: 'gpt-5.2' },
selectedThinkingByModel: { 'gpt-5.2': 'low' }
}
}
})
expect(legacy.selectedModelByAgent).toMatchObject({
codex: 'gpt-5.4',
claude: 'sonnet'
})
expect(legacy.selectedModelByAgentByHost).toMatchObject({
local: { codex: 'gpt-5.4' },
'ssh:conn-1': { codex: 'gpt-5.4-mini', claude: 'sonnet' }
})
expect(legacy.selectedThinkingByModel).toMatchObject({
'gpt-5.4': 'xhigh',
'gpt-5.4-mini': 'medium',
'gpt-5.5': 'medium'
})
expect(legacy.selectedThinkingByModel['gpt-5.2']).toBeUndefined()
})
it('merges projected legacy commit-message models without changing PR defaults', () => {
const source = {
...settings().sourceControlAi!,
selectedModelByAgent: { codex: 'gpt-5.5' },
selectedThinkingByModel: { 'gpt-5.5': 'medium' },
modelOverridesByOperation: {
commitMessage: {
selectedModelByAgent: { codex: 'gpt-5.4' },
selectedThinkingByModel: { 'gpt-5.4': 'high' }
}
}
}
const legacy = projectSourceControlAiToLegacyCommitMessageAi(source)
const merged = mergeLegacyCommitMessageAiIntoSourceControlAi(source, legacy)
expect(merged.selectedModelByAgent.codex).toBe('gpt-5.5')
expect(merged.modelOverridesByOperation?.commitMessage?.selectedModelByAgent?.codex).toBe(
'gpt-5.4'
)
const base = settings()
base.sourceControlAi = merged
expect(
resolveSourceControlAiForOperation({
settings: base,
repo: null,
operation: 'commitMessage',
discoveryHostKey: 'local'
})
).toMatchObject({ ok: true, value: { params: { model: 'gpt-5.4' } } })
expect(
resolveSourceControlAiForOperation({
settings: base,
repo: null,
operation: 'pullRequest',
discoveryHostKey: 'local'
})
).toMatchObject({ ok: true, value: { params: { model: 'gpt-5.5' } } })
})
it('does not synthesize a commit-message override from projected global defaults', () => {
const source = {
...settings().sourceControlAi!,
selectedModelByAgent: { codex: 'gpt-5.5' },
selectedThinkingByModel: { 'gpt-5.5': 'medium' },
modelOverridesByOperation: undefined
}
const legacy = projectSourceControlAiToLegacyCommitMessageAi(source)
const merged = mergeLegacyCommitMessageAiIntoSourceControlAi(source, legacy)
expect(merged.selectedModelByAgent.codex).toBe('gpt-5.5')
expect(merged.modelOverridesByOperation?.commitMessage).toBeUndefined()
})
it('merges only rollback legacy model deltas into commit-message overrides', () => {
const source = {
...settings().sourceControlAi!,
selectedModelByAgent: { codex: 'gpt-5.5', claude: 'sonnet' },
selectedModelByAgentByHost: {
local: { codex: 'gpt-5.5', claude: 'sonnet' }
},
selectedThinkingByModel: {
'gpt-5.5': 'medium',
sonnet: 'high'
},
modelOverridesByOperation: undefined
}
const legacy = projectSourceControlAiToLegacyCommitMessageAi(source)
legacy.selectedModelByAgent = {
...legacy.selectedModelByAgent,
codex: 'gpt-5.4'
}
legacy.selectedModelByAgentByHost = {
...legacy.selectedModelByAgentByHost,
local: {
...legacy.selectedModelByAgentByHost?.local,
codex: 'gpt-5.4'
}
}
const merged = mergeLegacyCommitMessageAiIntoSourceControlAi(source, legacy)
expect(merged.selectedModelByAgent).toEqual({ codex: 'gpt-5.5', claude: 'sonnet' })
expect(merged.modelOverridesByOperation?.commitMessage).toEqual({
selectedModelByAgent: { codex: 'gpt-5.4' },
selectedModelByAgentByHost: { local: { codex: 'gpt-5.4' } }
})
})
it('removes projected commit-message overrides cleared by legacy settings', () => {
const source = {
...settings().sourceControlAi!,
selectedModelByAgent: { codex: 'gpt-5.5' },
selectedThinkingByModel: { 'gpt-5.5': 'medium' },
modelOverridesByOperation: {
commitMessage: {
selectedModelByAgent: { codex: 'gpt-5.4' },
selectedThinkingByModel: { 'gpt-5.4': 'high' }
}
}
}
const legacy = projectSourceControlAiToLegacyCommitMessageAi(source)
delete legacy.selectedModelByAgent.codex
delete legacy.selectedThinkingByModel['gpt-5.4']
const merged = mergeLegacyCommitMessageAiIntoSourceControlAi(source, legacy)
expect(merged.modelOverridesByOperation?.commitMessage).toBeUndefined()
})
it('reads and selects host-scoped model choices with local fallback rules', () => {
const localChoice = selectSourceControlAiModelChoiceForHost(
undefined,
'local',
'codex',
'gpt-5.4'
)
expect(localChoice).toEqual({
selectedModelByAgent: { codex: 'gpt-5.4' },
selectedModelByAgentByHost: { local: { codex: 'gpt-5.4' } }
})
const remoteChoice = selectSourceControlAiModelChoiceForHost(
localChoice,
'ssh:conn-1',
'codex',
'remote-model'
)
expect(readSourceControlAiModelChoiceForHost(remoteChoice, 'local', 'codex')).toBe('gpt-5.4')
expect(readSourceControlAiModelChoiceForHost(remoteChoice, 'ssh:conn-1', 'codex')).toBe(
'remote-model'
)
expect(
readSourceControlAiModelChoiceForHost(remoteChoice, 'ssh:conn-2', 'codex')
).toBeUndefined()
expect(
readSourceControlAiModelChoiceForHost(
{ selectedModelByAgent: { codex: 'global-model' } },
'local',
'codex'
)
).toBe('global-model')
})
it('clears only the selected host model override when inheriting', () => {
const cleared = clearSourceControlAiModelChoiceForHost(
{
selectedModelByAgent: { codex: 'local-model' },
selectedModelByAgentByHost: {
local: { codex: 'local-model' },
'ssh:conn-1': { codex: 'remote-model' }
},
selectedThinkingByModel: { 'remote-model': 'high' }
},
'local',
'codex'
)
expect(cleared).toEqual({
selectedModelByAgentByHost: {
'ssh:conn-1': { codex: 'remote-model' }
},
selectedThinkingByModel: { 'remote-model': 'high' }
})
})
it('normalizes repo overrides defensively and preserves explicit inherit sentinels', () => {
const normalized = normalizeRepoSourceControlAiOverrides({
modelOverridesByOperation: {
commitMessage: {
selectedModelByAgent: {
codex: 'gpt-5.4',
claude: 42,
constructor: 'polluted'
},
selectedModelByAgentByHost: {
local: { codex: 'gpt-5.4' },
'ssh:conn-1': { codex: 'remote-model', claude: false },
malformed: 'not-a-record',
prototype: { codex: 'polluted' }
},
selectedThinkingByModel: {
'gpt-5.4': 'xhigh',
'remote-model': 'high',
bad: true,
constructor: 'polluted'
}
},
pullRequest: {
selectedModelByAgent: []
},
branchName: {
selectedModelByAgent: { codex: 'gpt-5.4' }
},
unknown: {
selectedModelByAgent: { codex: 'ignored' }
}
},
instructionsByOperation: {
commitMessage: null,
pullRequest: '',
branchName: 'branch style',
unknown: 'ignored'
},
prCreationDefaults: {
draft: true,
useTemplate: null,
generateDetailsOnOpen: 'yes',
openAfterCreate: false
}
})
expect(normalized).toEqual({
modelOverridesByOperation: {
commitMessage: {
selectedModelByAgent: { codex: 'gpt-5.4' },
selectedModelByAgentByHost: {
local: { codex: 'gpt-5.4' },
'ssh:conn-1': { codex: 'remote-model' }
},
selectedThinkingByModel: {
'gpt-5.4': 'xhigh',
'remote-model': 'high'
}
},
branchName: {
selectedModelByAgent: { codex: 'gpt-5.4' }
}
},
instructionsByOperation: {
commitMessage: null,
pullRequest: '',
branchName: 'branch style'
},
prCreationDefaults: {
draft: true,
useTemplate: null,
openAfterCreate: false
}
})
expect(normalizeRepoSourceControlAiOverrides(null)).toBeUndefined()
expect(normalizeRepoSourceControlAiOverrides([])).toBeUndefined()
})
})
+861
View File
@@ -0,0 +1,861 @@
/* eslint-disable max-lines -- Why: defaults, migration compatibility, and
operation resolution stay together so source-control AI precedence rules
cannot drift across commit-message, PR, repo, local, SSH, and runtime paths. */
import {
CUSTOM_AGENT_ID,
getCommitMessageAgentSpec,
getCommitMessageModel,
isCustomAgentId,
resolveCommitMessageAgentChoice
} from './commit-message-agent-spec'
import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from './commit-message-host-key'
import type {
CommitMessageAiModelCapability,
CommitMessageAiSettings,
GlobalSettings,
Repo,
TuiAgent
} from './types'
import type {
RepoSourceControlAiOverrides,
SourceControlAiModelChoice,
SourceControlAiOperation,
SourceControlAiPrCreationDefaults,
SourceControlAiSettings
} from './source-control-ai-types'
export const DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS: Required<SourceControlAiPrCreationDefaults> =
{
draft: false,
useTemplate: false,
generateDetailsOnOpen: false,
openAfterCreate: false
}
export type ResolvedSourceControlAiGenerationParams = {
agentId: TuiAgent | 'custom'
model: string
thinkingLevel?: string
customPrompt?: string
customAgentCommand?: string
agentCommandOverride?: string
}
export type ResolvedSourceControlAiOperation = {
enabled: boolean
params: ResolvedSourceControlAiGenerationParams
prCreationDefaults: Required<SourceControlAiPrCreationDefaults>
}
export type ResolveSourceControlAiResult =
| { ok: true; value: ResolvedSourceControlAiOperation }
| { ok: false; error: string }
type ResolveSourceControlAiInput = {
settings: Pick<
GlobalSettings,
'defaultTuiAgent' | 'agentCmdOverrides' | 'commitMessageAi' | 'sourceControlAi'
>
repo?: Pick<Repo, 'sourceControlAi'> | null
operation: SourceControlAiOperation
discoveryHostKey?: string
prCreationProductDefaults?: SourceControlAiPrCreationDefaults
}
export type ResolveSourceControlAiPrCreationDefaultsInput = {
settings: Pick<GlobalSettings, 'commitMessageAi' | 'sourceControlAi'>
repo?: Pick<Repo, 'sourceControlAi'> | null
prCreationProductDefaults?: SourceControlAiPrCreationDefaults
}
const OPERATION_LABEL: Record<SourceControlAiOperation, string> = {
commitMessage: 'commit messages',
pullRequest: 'pull request details',
branchName: 'branch names'
}
const SOURCE_CONTROL_AI_OPERATIONS = ['commitMessage', 'pullRequest', 'branchName'] as const
const PR_CREATION_DEFAULT_KEYS = [
'draft',
'useTemplate',
'generateDetailsOnOpen',
'openAfterCreate'
] as const
function copyRecord<T>(value: T | undefined): T | undefined {
return value === undefined ? undefined : structuredClone(value)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasEntries(value: Record<string, unknown> | null | undefined): boolean {
return Object.keys(value ?? {}).length > 0
}
function isSafeRecordKey(key: string): boolean {
return key !== '' && key !== '__proto__' && key !== 'constructor' && key !== 'prototype'
}
function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) {
return undefined
}
const normalized: Record<string, string> = {}
for (const [key, item] of Object.entries(value)) {
if (isSafeRecordKey(key) && typeof item === 'string') {
normalized[key] = item
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined
}
function normalizeAgentModelRecord(value: unknown): Partial<Record<TuiAgent, string>> | undefined {
return normalizeStringRecord(value) as Partial<Record<TuiAgent, string>> | undefined
}
function normalizeHostAgentModelRecord(
value: unknown
): Partial<Record<string, Partial<Record<TuiAgent, string>>>> | undefined {
if (!isRecord(value)) {
return undefined
}
const normalized: Partial<Record<string, Partial<Record<TuiAgent, string>>>> = {}
for (const [hostKey, hostModels] of Object.entries(value)) {
if (!isSafeRecordKey(hostKey)) {
continue
}
const normalizedHostModels = normalizeAgentModelRecord(hostModels)
if (normalizedHostModels) {
normalized[hostKey] = normalizedHostModels
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined
}
function normalizeSourceControlAiModelChoice(
value: unknown
): SourceControlAiModelChoice | undefined {
if (!isRecord(value)) {
return undefined
}
const choice: SourceControlAiModelChoice = {}
const selectedModelByAgent = normalizeAgentModelRecord(value.selectedModelByAgent)
if (selectedModelByAgent) {
choice.selectedModelByAgent = selectedModelByAgent
}
const selectedModelByAgentByHost = normalizeHostAgentModelRecord(value.selectedModelByAgentByHost)
if (selectedModelByAgentByHost) {
choice.selectedModelByAgentByHost = selectedModelByAgentByHost
}
const selectedThinkingByModel = normalizeStringRecord(value.selectedThinkingByModel)
if (selectedThinkingByModel) {
choice.selectedThinkingByModel = selectedThinkingByModel
}
return Object.keys(choice).length > 0 ? choice : undefined
}
function normalizeOperationRecord<T>(
value: unknown,
normalizeValue: (value: unknown) => T | undefined
): Partial<Record<SourceControlAiOperation, T>> | undefined {
if (!isRecord(value)) {
return undefined
}
const normalized: Partial<Record<SourceControlAiOperation, T>> = {}
for (const operation of SOURCE_CONTROL_AI_OPERATIONS) {
if (!Object.prototype.hasOwnProperty.call(value, operation)) {
continue
}
const normalizedValue = normalizeValue(value[operation])
if (normalizedValue !== undefined) {
normalized[operation] = normalizedValue
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined
}
function normalizeRepoInstruction(value: unknown): string | null | undefined {
return typeof value === 'string' || value === null ? value : undefined
}
function normalizeRepoPrCreationDefaults(
value: unknown
): RepoSourceControlAiOverrides['prCreationDefaults'] {
if (!isRecord(value)) {
return undefined
}
const normalized: NonNullable<RepoSourceControlAiOverrides['prCreationDefaults']> = {}
for (const key of PR_CREATION_DEFAULT_KEYS) {
const item = value[key]
if (typeof item === 'boolean' || item === null) {
normalized[key] = item
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined
}
export function normalizeRepoSourceControlAiOverrides(
value: unknown
): RepoSourceControlAiOverrides | undefined {
if (!isRecord(value)) {
return undefined
}
const normalized: RepoSourceControlAiOverrides = {}
const modelOverridesByOperation = normalizeOperationRecord(
value.modelOverridesByOperation,
normalizeSourceControlAiModelChoice
)
if (modelOverridesByOperation) {
normalized.modelOverridesByOperation = modelOverridesByOperation
}
const instructionsByOperation = normalizeOperationRecord(
value.instructionsByOperation,
normalizeRepoInstruction
)
if (instructionsByOperation) {
normalized.instructionsByOperation = instructionsByOperation
}
const prCreationDefaults = normalizeRepoPrCreationDefaults(value.prCreationDefaults)
if (prCreationDefaults) {
normalized.prCreationDefaults = prCreationDefaults
}
return normalized
}
export function getDefaultSourceControlAiSettings(): SourceControlAiSettings {
return {
enabled: true,
agentId: null,
selectedModelByAgent: {},
selectedModelByAgentByHost: {},
discoveredModelsByAgent: {},
discoveredModelsByAgentByHost: {},
selectedThinkingByModel: {},
customAgentCommand: '',
instructionsByOperation: {
commitMessage: '',
pullRequest: '',
branchName: ''
},
prCreationDefaults: { ...DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS }
}
}
export function sourceControlAiSettingsFromLegacy(
legacy: CommitMessageAiSettings | null | undefined
): SourceControlAiSettings {
const defaults = getDefaultSourceControlAiSettings()
if (!legacy) {
return defaults
}
return {
...defaults,
enabled: legacy.enabled,
agentId: legacy.agentId,
selectedModelByAgent: { ...legacy.selectedModelByAgent },
selectedModelByAgentByHost: copyRecord(legacy.selectedModelByAgentByHost) ?? {},
discoveredModelsByAgent: copyRecord(legacy.discoveredModelsByAgent) ?? {},
discoveredModelsByAgentByHost: copyRecord(legacy.discoveredModelsByAgentByHost) ?? {},
selectedThinkingByModel: { ...legacy.selectedThinkingByModel },
customAgentCommand: legacy.customAgentCommand,
instructionsByOperation: {
commitMessage: legacy.customPrompt ?? '',
// Why: the legacy prompt covered commit generation and branch auto-rename;
// the first split must preserve that guidance for both released paths.
pullRequest: '',
branchName: legacy.customPrompt ?? ''
}
}
}
function mergeSelectedModelByAgentByHost(
base: Partial<Record<string, Partial<Record<TuiAgent, string>>>> | undefined,
override: Partial<Record<string, Partial<Record<TuiAgent, string>>>> | undefined
): Partial<Record<string, Partial<Record<TuiAgent, string>>>> {
const merged = copyRecord(base) ?? {}
for (const [hostKey, hostModels] of Object.entries(override ?? {})) {
merged[hostKey] = {
...merged[hostKey],
...hostModels
}
}
return merged
}
function mergeLegacyModelSelectionDelta<T>(
existing: Record<string, T> | null | undefined,
legacy: Record<string, T> | null | undefined,
projected: Record<string, T> | null | undefined
): Record<string, T> | undefined {
const merged: Record<string, T> = { ...existing }
let changed = false
const keys = new Set([...Object.keys(legacy ?? {}), ...Object.keys(projected ?? {})])
for (const key of keys) {
const legacyHasKey = Object.prototype.hasOwnProperty.call(legacy ?? {}, key)
const legacyValue = legacy?.[key]
if (JSON.stringify(projected?.[key]) === JSON.stringify(legacyValue)) {
continue
}
changed = true
if (legacyHasKey && legacyValue !== undefined) {
merged[key] = legacyValue
} else {
delete merged[key]
}
}
return changed ? merged : (existing ?? undefined)
}
function mergeLegacyHostModelSelectionDelta(
existing: Partial<Record<string, Partial<Record<TuiAgent, string>>>> | null | undefined,
legacy: Partial<Record<string, Partial<Record<TuiAgent, string>>>> | null | undefined,
projected: Partial<Record<string, Partial<Record<TuiAgent, string>>>> | null | undefined
): Partial<Record<string, Partial<Record<TuiAgent, string>>>> | undefined {
const merged = copyRecord(existing) ?? {}
let changed = false
const hostKeys = new Set([...Object.keys(legacy ?? {}), ...Object.keys(projected ?? {})])
for (const hostKey of hostKeys) {
const nextHostModels = mergeLegacyModelSelectionDelta(
merged[hostKey],
legacy?.[hostKey],
projected?.[hostKey]
)
if (nextHostModels !== merged[hostKey]) {
changed = true
}
if (nextHostModels && Object.keys(nextHostModels).length > 0) {
merged[hostKey] = nextHostModels
} else {
delete merged[hostKey]
}
}
return changed ? merged : (existing ?? undefined)
}
export function mergeLegacyCommitMessageAiIntoSourceControlAi(
sourceControlAi: SourceControlAiSettings | null | undefined,
legacy: CommitMessageAiSettings | null | undefined,
options: { pullRequestInstructionsFromLegacy?: boolean } = {}
): SourceControlAiSettings {
// Why: older runtimes and rollback builds still write commitMessageAi; merge
// those writes into the new shape without wiping PR-only settings.
const base = normalizeSourceControlAiSettings(sourceControlAi, legacy)
if (!legacy) {
return base
}
if (sourceControlAi) {
const existingCommitChoice = base.modelOverridesByOperation?.commitMessage
const projectedLegacy = projectSourceControlAiToLegacyCommitMessageAi(base)
const selectedModelByAgent = mergeLegacyModelSelectionDelta(
existingCommitChoice?.selectedModelByAgent,
legacy.selectedModelByAgent,
projectedLegacy.selectedModelByAgent
)
const selectedModelByAgentByHost = mergeLegacyHostModelSelectionDelta(
existingCommitChoice?.selectedModelByAgentByHost,
legacy.selectedModelByAgentByHost,
projectedLegacy.selectedModelByAgentByHost
)
const selectedThinkingByModel = mergeLegacyModelSelectionDelta(
existingCommitChoice?.selectedThinkingByModel,
legacy.selectedThinkingByModel,
projectedLegacy.selectedThinkingByModel
)
const shouldMergeLegacyModels =
selectedModelByAgent !== existingCommitChoice?.selectedModelByAgent ||
selectedModelByAgentByHost !== existingCommitChoice?.selectedModelByAgentByHost ||
selectedThinkingByModel !== existingCommitChoice?.selectedThinkingByModel
const nextModelOverridesByOperation = { ...base.modelOverridesByOperation }
if (shouldMergeLegacyModels) {
const nextCommitChoice: SourceControlAiModelChoice = {}
if (hasEntries(selectedModelByAgent)) {
nextCommitChoice.selectedModelByAgent = selectedModelByAgent
}
if (hasEntries(selectedModelByAgentByHost)) {
nextCommitChoice.selectedModelByAgentByHost = selectedModelByAgentByHost
}
if (hasEntries(selectedThinkingByModel)) {
nextCommitChoice.selectedThinkingByModel = selectedThinkingByModel
}
if (Object.keys(nextCommitChoice).length > 0) {
nextModelOverridesByOperation.commitMessage = nextCommitChoice
} else {
delete nextModelOverridesByOperation.commitMessage
}
}
// Why: rollback builds write commitMessageAi, while new builds project
// commit-message overrides there. Keep those model choices scoped to
// commit-message generation so PR defaults cannot drift on reload.
return normalizeSourceControlAiSettings(
{
...base,
enabled: legacy.enabled,
agentId: legacy.agentId,
discoveredModelsByAgent: copyRecord(legacy.discoveredModelsByAgent) ?? {},
discoveredModelsByAgentByHost: copyRecord(legacy.discoveredModelsByAgentByHost) ?? {},
customAgentCommand: legacy.customAgentCommand,
instructionsByOperation: {
...base.instructionsByOperation,
commitMessage: legacy.customPrompt ?? '',
branchName: legacy.customPrompt ?? '',
...(options.pullRequestInstructionsFromLegacy
? { pullRequest: legacy.customPrompt ?? '' }
: {})
},
modelOverridesByOperation: nextModelOverridesByOperation
},
legacy
)
}
return normalizeSourceControlAiSettings(
{
...base,
enabled: legacy.enabled,
agentId: legacy.agentId,
selectedModelByAgent: { ...legacy.selectedModelByAgent },
selectedModelByAgentByHost: copyRecord(legacy.selectedModelByAgentByHost) ?? {},
discoveredModelsByAgent: copyRecord(legacy.discoveredModelsByAgent) ?? {},
discoveredModelsByAgentByHost: copyRecord(legacy.discoveredModelsByAgentByHost) ?? {},
selectedThinkingByModel: { ...legacy.selectedThinkingByModel },
customAgentCommand: legacy.customAgentCommand,
instructionsByOperation: {
...base.instructionsByOperation,
commitMessage: legacy.customPrompt ?? '',
branchName: legacy.customPrompt ?? '',
...(options.pullRequestInstructionsFromLegacy
? { pullRequest: legacy.customPrompt ?? '' }
: {})
}
},
legacy
)
}
export function normalizeSourceControlAiSettings(
value: SourceControlAiSettings | null | undefined,
legacy?: CommitMessageAiSettings | null
): SourceControlAiSettings {
const base = value ?? sourceControlAiSettingsFromLegacy(legacy)
const defaults = getDefaultSourceControlAiSettings()
return {
...defaults,
...base,
selectedModelByAgent: { ...defaults.selectedModelByAgent, ...base.selectedModelByAgent },
selectedModelByAgentByHost:
copyRecord(base.selectedModelByAgentByHost) ?? defaults.selectedModelByAgentByHost,
discoveredModelsByAgent:
copyRecord(base.discoveredModelsByAgent) ?? defaults.discoveredModelsByAgent,
discoveredModelsByAgentByHost:
copyRecord(base.discoveredModelsByAgentByHost) ?? defaults.discoveredModelsByAgentByHost,
selectedThinkingByModel: {
...defaults.selectedThinkingByModel,
...base.selectedThinkingByModel
},
instructionsByOperation: {
...defaults.instructionsByOperation,
...base.instructionsByOperation
},
modelOverridesByOperation: copyRecord(base.modelOverridesByOperation),
prCreationDefaults: {
...defaults.prCreationDefaults,
...base.prCreationDefaults
}
}
}
export function readSourceControlAiModelChoiceForHost(
choice: SourceControlAiModelChoice | null | undefined,
hostKey: string,
agentId: TuiAgent
): string | undefined {
return (
choice?.selectedModelByAgentByHost?.[hostKey]?.[agentId] ??
(hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY
? choice?.selectedModelByAgent?.[agentId]
: undefined)
)
}
export function selectSourceControlAiModelChoiceForHost(
choice: SourceControlAiModelChoice | undefined,
hostKey: string,
agentId: TuiAgent,
modelId: string
): SourceControlAiModelChoice {
const hostSelectedModels = choice?.selectedModelByAgentByHost?.[hostKey] ?? {}
return {
...choice,
selectedModelByAgent:
hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY
? {
...choice?.selectedModelByAgent,
[agentId]: modelId
}
: choice?.selectedModelByAgent,
selectedModelByAgentByHost: {
...choice?.selectedModelByAgentByHost,
[hostKey]: {
...hostSelectedModels,
[agentId]: modelId
}
}
}
}
export function clearSourceControlAiModelChoiceForHost(
choice: SourceControlAiModelChoice | undefined,
hostKey: string,
agentId: TuiAgent
): SourceControlAiModelChoice | undefined {
if (!choice) {
return undefined
}
// Why: model choices are host-scoped; clearing one "Use global" selector
// must not erase a different SSH/runtime host's override.
const selectedModelByAgent = { ...choice.selectedModelByAgent }
if (hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY) {
delete selectedModelByAgent[agentId]
}
const selectedModelByAgentByHost = { ...choice.selectedModelByAgentByHost }
const hostModels = { ...selectedModelByAgentByHost[hostKey] }
delete hostModels[agentId]
if (Object.keys(hostModels).length > 0) {
selectedModelByAgentByHost[hostKey] = hostModels
} else {
delete selectedModelByAgentByHost[hostKey]
}
const nextChoice: SourceControlAiModelChoice = {}
if (Object.keys(selectedModelByAgent).length > 0) {
nextChoice.selectedModelByAgent = selectedModelByAgent
}
if (Object.keys(selectedModelByAgentByHost).length > 0) {
nextChoice.selectedModelByAgentByHost = selectedModelByAgentByHost
}
const hasModelSelection =
nextChoice.selectedModelByAgent !== undefined ||
nextChoice.selectedModelByAgentByHost !== undefined
if (hasModelSelection && Object.keys(choice.selectedThinkingByModel ?? {}).length > 0) {
nextChoice.selectedThinkingByModel = choice.selectedThinkingByModel
}
return hasModelSelection ? nextChoice : undefined
}
export function projectSourceControlAiToLegacyCommitMessageAi(
sourceControlAi: SourceControlAiSettings,
previousLegacy?: CommitMessageAiSettings | null
): CommitMessageAiSettings {
const commitMessageChoice = sourceControlAi.modelOverridesByOperation?.commitMessage
return {
enabled: sourceControlAi.enabled,
agentId: sourceControlAi.agentId,
selectedModelByAgent: {
...sourceControlAi.selectedModelByAgent,
...commitMessageChoice?.selectedModelByAgent
},
selectedModelByAgentByHost: mergeSelectedModelByAgentByHost(
sourceControlAi.selectedModelByAgentByHost,
commitMessageChoice?.selectedModelByAgentByHost
),
discoveredModelsByAgent: copyRecord(sourceControlAi.discoveredModelsByAgent) ?? {},
discoveredModelsByAgentByHost: copyRecord(sourceControlAi.discoveredModelsByAgentByHost) ?? {},
selectedThinkingByModel: {
...sourceControlAi.selectedThinkingByModel,
...commitMessageChoice?.selectedThinkingByModel
},
customPrompt:
sourceControlAi.instructionsByOperation.commitMessage ?? previousLegacy?.customPrompt ?? '',
customAgentCommand: sourceControlAi.customAgentCommand
}
}
function readDefaultSelectedModelId(
settings: Pick<SourceControlAiSettings, 'selectedModelByAgent' | 'selectedModelByAgentByHost'>,
hostKey: string,
agentId: TuiAgent
): string | undefined {
return readSourceControlAiModelChoiceForHost(
{
selectedModelByAgent: settings.selectedModelByAgent,
selectedModelByAgentByHost: settings.selectedModelByAgentByHost
},
hostKey,
agentId
)
}
function getDiscoveredModels(
source: SourceControlAiSettings,
legacy: CommitMessageAiSettings | null | undefined,
hostKey: string,
agentId: TuiAgent
): CommitMessageAiModelCapability[] {
return (
source.discoveredModelsByAgentByHost?.[hostKey]?.[agentId] ??
(hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY
? (source.discoveredModelsByAgent?.[agentId] ??
legacy?.discoveredModelsByAgentByHost?.[hostKey]?.[agentId] ??
legacy?.discoveredModelsByAgent?.[agentId] ??
[])
: (legacy?.discoveredModelsByAgentByHost?.[hostKey]?.[agentId] ?? []))
)
}
function selectPersistedModelId(args: {
source: SourceControlAiSettings
legacy: CommitMessageAiSettings | null | undefined
repoOverrides: RepoSourceControlAiOverrides | null | undefined
operation: SourceControlAiOperation
hostKey: string
agentId: TuiAgent
defaultModelId: string
}): string {
const { source, legacy, repoOverrides, operation, hostKey, agentId, defaultModelId } = args
return (
readSourceControlAiModelChoiceForHost(
repoOverrides?.modelOverridesByOperation?.[operation],
hostKey,
agentId
) ??
readSourceControlAiModelChoiceForHost(
source.modelOverridesByOperation?.[operation],
hostKey,
agentId
) ??
readDefaultSelectedModelId(source, hostKey, agentId) ??
legacy?.selectedModelByAgentByHost?.[hostKey]?.[agentId] ??
(hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY
? legacy?.selectedModelByAgent?.[agentId]
: undefined) ??
defaultModelId
)
}
function resolveThinkingLevel(args: {
model: CommitMessageAiModelCapability
source: SourceControlAiSettings
legacy: CommitMessageAiSettings | null | undefined
repoOverrides: RepoSourceControlAiOverrides | null | undefined
operation: SourceControlAiOperation
}): string | undefined {
const { model, source, legacy, repoOverrides, operation } = args
if (!model.thinkingLevels?.length) {
return undefined
}
const persisted =
repoOverrides?.modelOverridesByOperation?.[operation]?.selectedThinkingByModel?.[model.id] ??
source.modelOverridesByOperation?.[operation]?.selectedThinkingByModel?.[model.id] ??
source.selectedThinkingByModel[model.id] ??
legacy?.selectedThinkingByModel?.[model.id]
return model.thinkingLevels.some((level) => level.id === persisted)
? persisted
: model.defaultThinkingLevel
}
function hasOwnInstruction(
instructions: Partial<Record<SourceControlAiOperation, string | null>> | null | undefined,
operation: SourceControlAiOperation
): boolean {
return Object.prototype.hasOwnProperty.call(instructions ?? {}, operation)
}
function readRepoInstructionOverride(
instructions: RepoSourceControlAiOverrides['instructionsByOperation'],
operation: SourceControlAiOperation
): string | undefined {
if (!hasOwnInstruction(instructions, operation)) {
return undefined
}
const instruction = instructions?.[operation]
return typeof instruction === 'string' ? instruction : undefined
}
export function resolveSourceControlAiInstructions(args: {
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'>
repo?: Pick<Repo, 'sourceControlAi'> | null
operation: SourceControlAiOperation
}): string {
const source = normalizeSourceControlAiSettings(
args.settings.sourceControlAi,
args.settings.commitMessageAi
)
const repoOverrides = normalizeRepoSourceControlAiOverrides(args.repo?.sourceControlAi)
const repoInstruction = readRepoInstructionOverride(
repoOverrides?.instructionsByOperation,
args.operation
)
if (repoInstruction !== undefined) {
return repoInstruction.trim()
}
const globalInstruction = source.instructionsByOperation[args.operation]
if (typeof globalInstruction === 'string') {
return globalInstruction.trim()
}
return args.operation === 'commitMessage'
? (args.settings.commitMessageAi?.customPrompt ?? '').trim()
: ''
}
export function hasConfiguredSourceControlAiInstructions(args: {
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'>
repo?: Pick<Repo, 'sourceControlAi'> | null
operation: SourceControlAiOperation
}): boolean {
const repoOverrides = normalizeRepoSourceControlAiOverrides(args.repo?.sourceControlAi)
const repoInstruction = readRepoInstructionOverride(
repoOverrides?.instructionsByOperation,
args.operation
)
if (repoInstruction !== undefined) {
return true
}
return resolveSourceControlAiInstructions(args).length > 0
}
function resolvePrCreationDefaults(
source: SourceControlAiSettings,
repoOverrides: RepoSourceControlAiOverrides | null | undefined,
productDefaults: SourceControlAiPrCreationDefaults | undefined
): Required<SourceControlAiPrCreationDefaults> {
const base = {
...DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
...productDefaults,
...source.prCreationDefaults
}
const repoDefaults = repoOverrides?.prCreationDefaults
if (!repoDefaults) {
return base
}
return {
draft: repoDefaults.draft ?? base.draft,
useTemplate: repoDefaults.useTemplate ?? base.useTemplate,
generateDetailsOnOpen: repoDefaults.generateDetailsOnOpen ?? base.generateDetailsOnOpen,
openAfterCreate: repoDefaults.openAfterCreate ?? base.openAfterCreate
}
}
export function resolveSourceControlAiPrCreationDefaults(
input: ResolveSourceControlAiPrCreationDefaultsInput
): Required<SourceControlAiPrCreationDefaults> {
const source = normalizeSourceControlAiSettings(
input.settings.sourceControlAi,
input.settings.commitMessageAi
)
return resolvePrCreationDefaults(
source,
normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi),
input.prCreationProductDefaults
)
}
export function resolveSourceControlAiForOperation(
input: ResolveSourceControlAiInput
): ResolveSourceControlAiResult {
const legacy = input.settings.commitMessageAi
const source = normalizeSourceControlAiSettings(input.settings.sourceControlAi, legacy)
if (!source.enabled) {
return {
ok: false,
error: 'Enable Source Control AI in Settings -> Git.'
}
}
// Why: a normalized null means "use the current default agent"; stale legacy
// commitMessageAi should not make that choice sticky again.
const agentChoice = resolveCommitMessageAgentChoice(
source.agentId,
input.settings.defaultTuiAgent
)
if (!agentChoice) {
return {
ok: false,
error:
`Default agent "${input.settings.defaultTuiAgent}" does not support Source Control AI. ` +
'Choose Claude, Codex, or Custom in Settings -> Git -> Source Control AI.'
}
}
const repoOverrides = normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi)
const prCreationDefaults = resolvePrCreationDefaults(
source,
repoOverrides,
input.prCreationProductDefaults
)
if (isCustomAgentId(agentChoice)) {
const customAgentCommand = source.customAgentCommand.trim()
if (!customAgentCommand) {
return {
ok: false,
error: 'Custom command is empty. Add one in Settings -> Git -> Source Control AI.'
}
}
return {
ok: true,
value: {
enabled: true,
params: {
agentId: CUSTOM_AGENT_ID,
model: '',
customPrompt: resolveSourceControlAiInstructions(input),
customAgentCommand
},
prCreationDefaults
}
}
}
const agentId = agentChoice
const spec = getCommitMessageAgentSpec(agentId)
if (!spec) {
return {
ok: false,
error: `Agent "${agentId}" does not support Source Control AI ${OPERATION_LABEL[input.operation]}.`
}
}
const hostKey = input.discoveryHostKey ?? LOCAL_COMMIT_MESSAGE_HOST_KEY
const persistedModelId = selectPersistedModelId({
source,
legacy,
repoOverrides,
operation: input.operation,
hostKey,
agentId,
defaultModelId: spec.defaultModelId
})
const discoveredModels = getDiscoveredModels(source, legacy, hostKey, agentId)
const model =
spec.models.find((candidate) => candidate.id === persistedModelId) ??
discoveredModels.find((candidate) => candidate.id === persistedModelId) ??
getCommitMessageModel(agentId, spec.defaultModelId)
if (!model) {
return { ok: false, error: `No model is available for ${spec.label}.` }
}
const thinkingLevel = resolveThinkingLevel({
model,
source,
legacy,
repoOverrides,
operation: input.operation
})
const agentCommandOverride = input.settings.agentCmdOverrides?.[agentId]?.trim()
return {
ok: true,
value: {
enabled: true,
params: {
agentId,
model: model.id,
thinkingLevel,
customPrompt: resolveSourceControlAiInstructions(input),
...(agentCommandOverride ? { agentCommandOverride } : {})
},
prCreationDefaults
}
}
}
+54
View File
@@ -0,0 +1,54 @@
const SSH_PTY_ID_PREFIX = 'ssh:'
const SSH_PTY_ID_SEPARATOR = '@@'
// Why: SSH relays allocate target-local ids like "pty-1"; app-wide routing
// needs the target id embedded so two relays cannot collide after restore.
export type ParsedSshPtyId = {
connectionId: string
relayPtyId: string
}
export function parseAppSshPtyId(ptyId: string): ParsedSshPtyId | null {
if (!ptyId.startsWith(SSH_PTY_ID_PREFIX)) {
return null
}
const separatorIndex = ptyId.indexOf(SSH_PTY_ID_SEPARATOR, SSH_PTY_ID_PREFIX.length)
if (separatorIndex === -1) {
return null
}
const encodedConnectionId = ptyId.slice(SSH_PTY_ID_PREFIX.length, separatorIndex)
const relayPtyId = ptyId.slice(separatorIndex + SSH_PTY_ID_SEPARATOR.length)
if (!encodedConnectionId || !relayPtyId) {
return null
}
try {
return {
connectionId: decodeURIComponent(encodedConnectionId),
relayPtyId
}
} catch {
return null
}
}
export function toAppSshPtyId(connectionId: string, relayPtyId: string): string {
const parsed = parseAppSshPtyId(relayPtyId)
if (parsed) {
if (parsed.connectionId !== connectionId) {
throw new Error(`PTY ${relayPtyId} belongs to SSH connection "${parsed.connectionId}"`)
}
return relayPtyId
}
return `${SSH_PTY_ID_PREFIX}${encodeURIComponent(connectionId)}${SSH_PTY_ID_SEPARATOR}${relayPtyId}`
}
export function toRelaySshPtyId(connectionId: string, ptyId: string): string {
const parsed = parseAppSshPtyId(ptyId)
if (!parsed) {
return ptyId
}
if (parsed.connectionId !== connectionId) {
throw new Error(`PTY ${ptyId} belongs to SSH connection "${parsed.connectionId}"`)
}
return parsed.relayPtyId
}
+1
View File
@@ -3,6 +3,7 @@
export const MIN_SSH_RELAY_GRACE_PERIOD_SECONDS = 60
export const MAX_SSH_RELAY_GRACE_PERIOD_SECONDS = 7 * 24 * 60 * 60
export const DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS = 3 * 60 * 60
export const SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD = 'relay.configureGraceTime'
export type SshTarget = {
id: string
+12
View File
@@ -0,0 +1,12 @@
/**
* Escape special regex characters in a string for use in RegExp constructor.
*
* Why: When building a regex from user input or file paths, special regex
* characters (. * + ? ^ $ { } ( ) | [ ] \) must be escaped to match literally.
*
* @param str - String to escape
* @returns Escaped string safe for use in new RegExp()
*/
export function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
+124 -7
View File
@@ -23,6 +23,16 @@ import {
} from './feature-education-telemetry'
import { SETUP_SCRIPT_IMPORT_PROVIDERS } from './setup-script-import-providers'
import { WORKSPACE_SOURCE_VALUES, type WorkspaceSource } from './workspace-source'
import {
NESTED_REPO_COUNT_BUCKETS,
NESTED_REPO_IMPORT_ACTIONS,
NESTED_REPO_IMPORT_OUTCOMES,
NESTED_REPO_SCAN_RESULTS,
NESTED_REPO_TELEMETRY_MAX_REPO_COUNT,
NESTED_REPO_TELEMETRY_RUNTIME_KINDS,
NESTED_REPO_TELEMETRY_SURFACES,
bucketNestedRepoTelemetryCount
} from './nested-repo-telemetry'
import { AGENT_HOOK_TARGETS } from './agent-hook-types'
import { ONBOARDING_FINAL_STEP } from './constants'
@@ -48,6 +58,7 @@ export const AGENT_KIND_VALUES = [
'autohand',
'opencode',
'pi',
'omp',
'gemini',
'antigravity',
'aider',
@@ -59,6 +70,7 @@ export const AGENT_KIND_VALUES = [
'aug',
'cline',
'codebuff',
'command-code',
'continue',
'cursor',
'droid',
@@ -97,11 +109,12 @@ export type ErrorClass = z.infer<typeof errorClassSchema>
export const repoMethodSchema = z.enum(['folder_picker', 'clone_url', 'drag_drop'])
export type RepoMethod = z.infer<typeof repoMethodSchema>
// Five Setup-step affordances the user can pick after `repo_added` fires (see
// Setup-step affordances the user can pick after `repo_added` fires (see
// AddRepoSetupStep). One enum because every value lives on the same screen and
// the funnel question is "which one did they pick" — adding a sixth value
// later is additive-safe per the schema-evolution doctrine below.
// the funnel question is "which one did they pick" — adding values later is
// additive-safe per the schema-evolution doctrine below.
export const addRepoSetupStepActionSchema = z.enum([
'open_primary',
'create_worktree',
'configure',
'skip',
@@ -151,6 +164,7 @@ export const launchSourceSchema = z.enum([
'diff_notes_send',
'notes_send',
'conflict_resolution',
'source_control_recovery',
'unknown'
])
export type LaunchSource = z.infer<typeof launchSourceSchema>
@@ -228,6 +242,7 @@ export const SETTINGS_CHANGED_WHITELIST = [
'experimentalMobile',
'experimentalPet',
'experimentalActivity',
'experimentalTerminalAttention',
'experimentalWorktreeSymlinks',
'geminiCliOAuthEnabled'
] as const satisfies readonly BooleanGlobalSettingsKey[]
@@ -581,11 +596,107 @@ void _onboardingChecklistItemSyncCheck
// `'cohort' in schema.shape`, so there is no parallel hand-maintained list.
const cohortSchema = z.enum(['fresh_install', 'upgrade_backfill']).optional()
const nestedRepoTelemetrySurfaceSchema = z.enum(NESTED_REPO_TELEMETRY_SURFACES)
const nestedRepoTelemetryRuntimeKindSchema = z.enum(NESTED_REPO_TELEMETRY_RUNTIME_KINDS)
const nestedRepoCountSchema = z.number().int().min(0).max(NESTED_REPO_TELEMETRY_MAX_REPO_COUNT)
const nestedRepoCountBucketSchema = z.enum(NESTED_REPO_COUNT_BUCKETS)
const nestedRepoScanResultSchema = z.enum(NESTED_REPO_SCAN_RESULTS)
const nestedRepoImportActionSchema = z.enum(NESTED_REPO_IMPORT_ACTIONS)
const nestedRepoImportOutcomeSchema = z.enum(NESTED_REPO_IMPORT_OUTCOMES)
const nestedRepoScanPathKindSchema = z.enum(['git_repo', 'non_git_folder'])
const nestedRepoImportModeSchema = z.enum(['group', 'separate'])
const nestedRepoAttemptIdSchema = z.string().uuid()
function validateNestedRepoCountBucket(
props: Record<string, unknown>,
countKey: string,
bucketKey: string,
ctx: z.RefinementCtx
): void {
const count = props[countKey]
const bucket = props[bucketKey]
if (typeof count !== 'number' || typeof bucket !== 'string') {
return
}
if (bucketNestedRepoTelemetryCount(count) !== bucket) {
ctx.addIssue({
code: 'custom',
path: [bucketKey],
message: `${bucketKey} must match ${countKey}`
})
}
}
function validateNestedRepoCountBuckets(
props: Record<string, unknown>,
ctx: z.RefinementCtx
): void {
validateNestedRepoCountBucket(props, 'found_count', 'found_count_bucket', ctx)
validateNestedRepoCountBucket(props, 'selected_count', 'selected_count_bucket', ctx)
validateNestedRepoCountBucket(props, 'imported_count', 'imported_count_bucket', ctx)
validateNestedRepoCountBucket(props, 'already_known_count', 'already_known_count_bucket', ctx)
validateNestedRepoCountBucket(props, 'failed_count', 'failed_count_bucket', ctx)
}
const nestedRepoTelemetryBaseSchema = {
// Why: high-cardinality by design, but random and non-persistent. It lets
// dashboards count scan -> action -> result attempts without path-derived IDs.
attempt_id: nestedRepoAttemptIdSchema,
surface: nestedRepoTelemetrySurfaceSchema,
runtime_kind: nestedRepoTelemetryRuntimeKindSchema,
nth_repo_added: nthRepoAddedSchema
} as const
const addRepoNestedScanResultSchema = z
.object({
...nestedRepoTelemetryBaseSchema,
result: nestedRepoScanResultSchema,
selected_path_kind: nestedRepoScanPathKindSchema.optional(),
found_count: nestedRepoCountSchema,
found_count_bucket: nestedRepoCountBucketSchema,
truncated: z.boolean(),
timed_out: z.boolean()
})
.strict()
.superRefine(validateNestedRepoCountBuckets)
const addRepoNestedImportActionSchema = z
.object({
...nestedRepoTelemetryBaseSchema,
action: nestedRepoImportActionSchema,
found_count: nestedRepoCountSchema,
found_count_bucket: nestedRepoCountBucketSchema,
selected_count: nestedRepoCountSchema,
selected_count_bucket: nestedRepoCountBucketSchema,
all_selected: z.boolean()
})
.strict()
.superRefine(validateNestedRepoCountBuckets)
const addRepoNestedImportResultSchema = z
.object({
...nestedRepoTelemetryBaseSchema,
mode: nestedRepoImportModeSchema,
outcome: nestedRepoImportOutcomeSchema,
found_count: nestedRepoCountSchema,
found_count_bucket: nestedRepoCountBucketSchema,
selected_count: nestedRepoCountSchema,
selected_count_bucket: nestedRepoCountBucketSchema,
imported_count: nestedRepoCountSchema,
imported_count_bucket: nestedRepoCountBucketSchema,
already_known_count: nestedRepoCountSchema,
already_known_count_bucket: nestedRepoCountBucketSchema,
failed_count: nestedRepoCountSchema,
failed_count_bucket: nestedRepoCountBucketSchema,
all_selected: z.boolean()
})
.strict()
.superRefine(validateNestedRepoCountBuckets)
// `'button' | 'keyboard'` records whether the user advanced via a footer
// button click or via Cmd/Ctrl+Enter. Skip and dismiss don't have a keyboard
// path today (the field will only ever be `'button'` for those events) but
// the uniform shape lets a future keyboard skip arrive without a schema
// migration.
// button click, Cmd/Ctrl+Enter, or an equivalent keyboard exit like Escape.
// The uniform shape lets keyboard skip/dismiss paths arrive without a
// schema migration.
const advancedViaSchema = z.enum(['button', 'keyboard']).optional()
const onboardingStartedSchema = z
@@ -927,6 +1038,9 @@ export const eventSchemas = {
repo_added: repoAddedSchema,
add_repo_setup_step_action: addRepoSetupStepActionEventSchema,
add_repo_existing_workspaces_detected: addRepoExistingWorkspacesDetectedSchema,
add_repo_nested_scan_result: addRepoNestedScanResultSchema,
add_repo_nested_import_action: addRepoNestedImportActionSchema,
add_repo_nested_import_result: addRepoNestedImportResultSchema,
workspace_created: workspaceCreatedSchema,
workspace_create_failed: workspaceCreateFailedSchema,
setup_script_prompt_shown: setupScriptPromptShownSchema,
@@ -1018,6 +1132,9 @@ type _CohortExtendedRoster =
| 'repo_added'
| 'add_repo_setup_step_action'
| 'add_repo_existing_workspaces_detected'
| 'add_repo_nested_scan_result'
| 'add_repo_nested_import_action'
| 'add_repo_nested_import_result'
| 'workspace_created'
| 'workspace_create_failed'
| 'setup_script_prompt_shown'
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
buildTerminalQuickCommandInput,
flattenTerminalQuickCommand,
getDefaultTerminalQuickCommands,
normalizeTerminalQuickCommands,
terminalQuickCommandMatchesRepo
@@ -168,3 +169,55 @@ describe('terminal quick commands', () => {
).toBe('git status')
})
})
describe('flattenTerminalQuickCommand', () => {
it('returns the same object when there are no line breaks', () => {
const command = {
id: 'test',
label: 'Test',
command: 'git status',
appendEnter: true
} as const
expect(flattenTerminalQuickCommand(command)).toBe(command)
})
it('replaces newlines with semicolons and spaces', () => {
const result = flattenTerminalQuickCommand({
id: 'test',
label: 'Test',
command: 'cd packages\nbun run build\ncd ..',
appendEnter: true
})
expect(result.command).toBe('cd packages; bun run build; cd ..')
})
it('collapses consecutive newlines into a single separator', () => {
const result = flattenTerminalQuickCommand({
id: 'test',
label: 'Test',
command: 'echo one\n\n\necho two',
appendEnter: true
})
expect(result.command).toBe('echo one; echo two')
})
it('handles Windows-style CRLF endings', () => {
const result = flattenTerminalQuickCommand({
id: 'test',
label: 'Test',
command: 'echo one\r\necho two',
appendEnter: true
})
expect(result.command).toBe('echo one; echo two')
})
it('drops empty edge lines without leaving dangling separators', () => {
const result = flattenTerminalQuickCommand({
id: 'test',
label: 'Test',
command: '\n echo one \n\n echo two\n',
appendEnter: true
})
expect(result.command).toBe('echo one; echo two')
})
})
+18
View File
@@ -96,3 +96,21 @@ export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCom
export function buildTerminalQuickCommandInput(command: TerminalQuickCommand): string {
return command.appendEnter ? `${command.command}\r` : command.command
}
const LINE_BREAK_RE = /\r\n|\r|\n/
// Why: quick-command lines are independent shell commands; one shell command
// list prevents foreground programs from reading later lines as stdin.
export function flattenTerminalQuickCommand(command: TerminalQuickCommand): TerminalQuickCommand {
if (!LINE_BREAK_RE.test(command.command)) {
return command
}
return {
...command,
command: command.command
.split(LINE_BREAK_RE)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('; ')
}
}
+31
View File
@@ -106,6 +106,23 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
// user-visible behavior as `claude --prefill <text>`.
draftPromptEnvVar: 'ORCA_PI_PREFILL'
},
omp: {
// Why: OMP (omp.sh) is a Pi fork with its own binary (`omp`), brand,
// default config dir (~/.omp/agent), and overlay tree. It re-uses
// Pi's argv prompt-injection contract because the OMP binary inherits
// Pi's command-line parser, but every Orca-owned env var (overlay
// shadow, prefill) is scoped to OMP - see ORCA_OMP_* in
// src/main/pi/titlebar-extension-service.ts. The one var that MUST
// stay shared is `PI_CODING_AGENT_DIR`: OMP's CHANGELOG documents
// the deliberate rename of `OMP_CODING_AGENT_DIR` -> `PI_CODING_AGENT_DIR`
// (packages/ai/CHANGELOG.md), so the binary itself reads the PI-prefixed
// name and we have to set that to point at the OMP overlay dir.
detectCmd: 'omp',
launchCmd: 'omp',
expectedProcess: 'omp',
promptInjectionMode: 'argv',
draftPromptEnvVar: 'ORCA_OMP_PREFILL'
},
gemini: {
detectCmd: 'gemini',
launchCmd: 'gemini',
@@ -179,6 +196,20 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
expectedProcess: 'codebuff',
promptInjectionMode: 'stdin-after-start'
},
'command-code': {
// Why: `npm i -g command-code` installs two binaries — `command-code` and
// the shorter alias `cmd`. Use the full `command-code` name so detection
// does not collide with Windows' built-in `cmd.exe` shell, which
// agent-process-recognition normalizes to `cmd` after stripping the .exe.
detectCmd: 'command-code',
// Why: Command Code's documented positional prompt starts the turn, while
// paste-after-start can leave the prompt sitting in the composer. `--trust`
// mirrors the preflight trust behavior Orca applies to other first-run
// TUIs so launch prompts do not consume the task text.
launchCmd: 'command-code --trust',
expectedProcess: 'command-code',
promptInjectionMode: 'argv'
},
continue: {
detectCmd: 'continue',
launchCmd: 'continue',
+1
View File
@@ -9,6 +9,7 @@ describe('pickTuiAgent', () => {
it('falls back in desktop catalog order when the preference is absent or stale', () => {
expect(pickTuiAgent(null, ['cursor', 'codex'])).toBe('codex')
expect(pickTuiAgent('gemini', ['cursor', 'codex'])).toBe('codex')
expect(pickTuiAgent(null, ['continue', 'command-code'])).toBe('command-code')
})
it('respects the explicit blank terminal preference', () => {
+2
View File
@@ -9,6 +9,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [
'copilot',
'opencode',
'pi',
'omp',
'gemini',
'antigravity',
'aider',
@@ -21,6 +22,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [
'autohand',
'cline',
'codebuff',
'command-code',
'continue',
'cursor',
'droid',
+65
View File
@@ -36,6 +36,51 @@ describe('tui agent startup plans', () => {
expect(plan?.launchCommand).toBe('claude "fix ^"quoted^" ^& ^%PATH^%"')
})
it('does not launch Codex with the Orca profile when agent status hooks are enabled', () => {
const plan = buildAgentStartupPlan({
agent: 'codex',
prompt: 'fix it',
cmdOverrides: {},
platform: 'linux'
})
expect(plan?.launchCommand).toBe("codex 'fix it'")
})
it('launches Claude without Orca settings injection', () => {
const plan = buildAgentStartupPlan({
agent: 'claude',
prompt: 'fix it',
cmdOverrides: {},
platform: 'linux'
})
expect(plan?.launchCommand).toBe("claude 'fix it'")
expect(plan?.launchCommand).not.toContain('--settings')
})
it('leaves Claude command overrides untouched', () => {
const plan = buildAgentStartupPlan({
agent: 'claude',
prompt: 'fix it',
cmdOverrides: { claude: 'claude --dangerously-skip-permissions' },
platform: 'linux'
})
expect(plan?.launchCommand).toBe("claude --dangerously-skip-permissions 'fix it'")
})
it('leaves Codex command overrides untouched', () => {
const plan = buildAgentStartupPlan({
agent: 'codex',
prompt: 'fix it',
cmdOverrides: { codex: 'codex --profile work' },
platform: 'linux'
})
expect(plan?.launchCommand).toBe("codex --profile work 'fix it'")
})
it('clears draft environment variables with the target shell syntax', () => {
expect(
buildAgentDraftLaunchPlan({
@@ -56,4 +101,24 @@ describe('tui agent startup plans', () => {
})?.launchCommand
).toBe('pi & set "ORCA_PI_PREFILL="')
})
it('returns an OMP draft plan with ORCA_OMP_PREFILL (OMP-scoped, not Pi-shared)', () => {
// Why: OMP owns its own overlay tree, bundled prefill extension, and
// prefill env var. The OMP overlay's orca-prefill.ts reads
// ORCA_OMP_PREFILL — see src/main/pi/titlebar-extension-service.ts —
// so a draft plan for OMP MUST emit that name. A regression here would
// either silently drop the draft (Pi var ignored by OMP overlay) or
// honor a stale Pi-PTY draft from a previous launch.
const plan = buildAgentDraftLaunchPlan({
agent: 'omp',
draft: 'fix the omp regression',
cmdOverrides: {},
platform: 'linux'
})
expect(plan).not.toBeNull()
expect(plan?.env).toEqual({ ORCA_OMP_PREFILL: 'fix the omp regression' })
expect(plan?.expectedProcess).toBe('omp')
expect(plan?.launchCommand).toBe('omp; unset ORCA_OMP_PREFILL')
})
})
+25 -2
View File
@@ -44,6 +44,21 @@ function commandSeparator(shell: AgentStartupShell): string {
return shell === 'cmd' ? ' & ' : '; '
}
function resolveBaseCommand(args: {
agent: TuiAgent
cmdOverrides: Partial<Record<TuiAgent, string>>
shell: AgentStartupShell
}): string {
const override = args.cmdOverrides[args.agent]
if (override) {
return override
}
const command = TUI_AGENT_CONFIG[args.agent].launchCmd
// Why: Codex status hooks live in Orca's runtime CODEX_HOME; adding
// --profile-v2 makes Codex load a second hook representation and warn.
return command
}
export function buildAgentStartupPlan(args: {
agent: TuiAgent
prompt: string
@@ -56,7 +71,11 @@ export function buildAgentStartupPlan(args: {
const shell = resolveStartupShell(platform, args.shell)
const trimmedPrompt = prompt.trim()
const config = TUI_AGENT_CONFIG[agent]
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
const baseCommand = resolveBaseCommand({
agent,
cmdOverrides,
shell
})
if (!trimmedPrompt) {
if (!allowEmptyPromptLaunch) {
@@ -137,7 +156,11 @@ export function buildAgentDraftLaunchPlan(args: {
if (!trimmed) {
return null
}
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
const baseCommand = resolveBaseCommand({
agent,
cmdOverrides,
shell
})
if (config.draftPromptFlag) {
const quoted = quoteStartupArg(trimmed, shell)
return {
+98
View File
@@ -18,6 +18,10 @@ import type { FeatureInteractionState } from './feature-interactions'
import type { GitBranchChangeStatus } from './git-status-types'
import type { KeybindingOverrides, TerminalShortcutPolicy } from './keybindings'
import type { RepoIcon } from './repo-icon'
import type {
RepoSourceControlAiOverrides,
SourceControlAiSettings
} from './source-control-ai-types'
// Re-exported for backward compat with renderer call sites that import
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
@@ -102,6 +106,66 @@ export type Repo = {
* "what to link", the global flag is the "whether to link at all" switch.
* Undefined/empty means no symlinks are created for this repo. */
symlinkPaths?: string[]
/** Durable sidebar-only repo organization. Execution remains repo-scoped. */
projectGroupId?: string | null
/** User-authored ordering inside the project group or ungrouped bucket. */
projectGroupOrder?: number
/** Repo-specific source-control AI overrides. Missing fields inherit global settings. */
sourceControlAi?: RepoSourceControlAiOverrides
}
export type ProjectGroupCreatedFrom = 'manual' | 'folder-scan' | 'migration'
export type ProjectGroup = {
id: string
name: string
parentPath: string | null
parentGroupId: string | null
createdFrom: ProjectGroupCreatedFrom
tabOrder: number
isCollapsed: boolean
color: string | null
createdAt: number
updatedAt: number
}
export type NestedRepoScanOptions = {
maxDepth?: number
maxRepos?: number
timeoutMs?: number
}
export type NestedRepoCandidate = {
path: string
displayName: string
depth: number
}
export type NestedRepoScanResult = {
selectedPath: string
selectedPathKind: 'git_repo' | 'non_git_folder'
repos: NestedRepoCandidate[]
truncated: boolean
timedOut: boolean
durationMs: number
maxDepth: number
}
export type ProjectGroupImportMode = 'group' | 'separate'
export type ProjectGroupImportProjectResult = {
path: string
projectId?: string
status: 'imported' | 'already-known' | 'failed'
error?: string
}
export type ProjectGroupImportResult = {
group?: ProjectGroup
projects: ProjectGroupImportProjectResult[]
importedCount: number
alreadyKnownCount: number
failedCount: number
}
export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
@@ -1071,6 +1135,7 @@ export type GitHubPullRequestStateUpdate = {
export type LinearIssueUpdate = {
stateId?: string
title?: string
description?: string
assigneeId?: string | null
estimate?: number | null
priority?: number
@@ -1218,6 +1283,7 @@ export type LinearTeam = {
workspaceName?: string
name: string
key: string
url?: string
}
// ─── Hooks (orca.yaml) ──────────────────────────────────────────────
@@ -1318,6 +1384,14 @@ export type CreateWorktreeResult = {
setup?: WorktreeSetupLaunch
warning?: string
initialBaseStatus?: WorktreeBaseStatusEvent
localBaseRefRefresh?: LocalBaseRefRefreshResult
}
export type LocalBaseRefRefreshResult = {
status: 'updated' | 'skipped_dirty_worktree' | 'skipped_not_fast_forward' | 'skipped_error'
baseRef: string
localBranch: string
ownerWorktreePath?: string
}
export type WorktreeBaseStatusKind = 'checking' | 'current' | 'drift' | 'base_changed' | 'unknown'
@@ -1467,6 +1541,7 @@ export type TuiAgent =
| 'autohand' // Autohand Code CLI
| 'opencode' // OpenCode
| 'pi' // Pi (pi.dev)
| 'omp' // OMP (omp.sh)
| 'gemini' // Gemini CLI
| 'antigravity' // Google Antigravity CLI
| 'aider' // Aider
@@ -1478,6 +1553,7 @@ export type TuiAgent =
| 'aug' // Augment/Auggie
| 'cline' // Cline
| 'codebuff' // Codebuff
| 'command-code' // Command Code
| 'continue' // Continue
| 'cursor' // Cursor
| 'droid' // Factory Droid
@@ -1565,6 +1641,10 @@ export type GlobalSettings = {
nestWorkspaces: boolean
workspaceDirHistory?: OrcaWorkspaceLayout[]
refreshLocalBaseRefOnWorktreeCreate: boolean
/** When enabled, Orca renames a workspace's auto-generated creature branch to
* a short name derived from the first prompt once work begins. Opt-in;
* uses the same agent configured for AI commit messages. */
autoRenameBranchFromWork: boolean
branchPrefix: 'git-username' | 'custom' | 'none'
branchPrefixCustom: string
enableGitHubAttribution: boolean
@@ -1660,6 +1740,7 @@ export type GlobalSettings = {
openLinksInApp: boolean
/** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */
openInApplications?: OpenInApplication[]
/** Deprecated: migration/backward-compat only. Use PersistedUIState.rightSidebarOpen. */
rightSidebarOpenByDefault: boolean
showGitIgnoredFiles?: boolean
/** Preferred Source Control changes layout. Per-user, not per-workspace. */
@@ -1772,6 +1853,9 @@ export type GlobalSettings = {
geminiCliOAuthEnabled: boolean
/** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */
agentCmdOverrides: Partial<Record<TuiAgent, string>>
/** Why: disabling must persist so startup does not reinstall global agent
* hook entries right after the user removes them from Settings or CLI. */
agentStatusHooksEnabled: boolean
/** When true, Orca requests local awake assertions while hook-reported agents are working. */
keepComputerAwakeWhileAgentsRun: boolean
/** Why: macOS terminals must choose between letting Option compose layout
@@ -1794,6 +1878,9 @@ export type GlobalSettings = {
* detection, so no visible behavior change. Then we flip this flag to true
* and never migrate again. */
terminalMacOptionAsAltMigrated: boolean
/** Controls whether macOS terminal input translates the physical JIS Yen (¥)
* key to a backslash, matching the common terminal expectation for that key. */
terminalJISYenToBackslash: boolean
experimentalMobile: boolean
/** Auto-restore window for a phone-fit PTY after the last mobile
* subscriber leaves. `null` (default) holds the PTY at phone size
@@ -1817,6 +1904,10 @@ export type GlobalSettings = {
/** One-shot migration guard for defaulting the Agents view off for all
* users. Once set, later explicit opt-ins persist normally. */
experimentalActivityDefaultedOffForAllUsers?: boolean
/** Experimental: persistent terminal pane attention ring for terminal bell
* and agent-completion events. Opt-in while the signal/noise balance is
* being tested. */
experimentalTerminalAttention: boolean
/** Experimental: when creating a worktree, automatically symlink a
* user-configured set of files/folders from the primary checkout (e.g.
* `.env`, `node_modules`) into the new worktree. Opt-in while the
@@ -1835,6 +1926,8 @@ export type GlobalSettings = {
* user-customizable prompt suffix. Optional so existing profiles do not
* require a migration step before this feature lands. */
commitMessageAi?: CommitMessageAiSettings
/** Source-control AI generation settings for commit messages and hosted-review drafts. */
sourceControlAi?: SourceControlAiSettings
/** GitLab project preferences — pinned + recent project paths.
* Optional for backward compatibility with profiles saved before
* GitLab support; the persistence merge fills the empty default. */
@@ -2063,10 +2156,14 @@ export type TaskResumeState = {
linearQuery?: string
}
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks' | 'ports'
export type PersistedUIState = {
lastActiveRepoId: string | null
lastActiveWorktreeId: string | null
sidebarWidth: number
rightSidebarOpen: boolean
rightSidebarTab: RightSidebarTab
rightSidebarWidth: number
groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status'
sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual'
@@ -2300,6 +2397,7 @@ export type LegacyPaneKeyAliasEntry = {
export type PersistedState = {
schemaVersion: number
repos: Repo[]
projectGroups: ProjectGroup[]
/** Sparse-checkout presets keyed by repoId. Empty record on first launch;
* presets are managed from the new-workspace composer and repo settings. */
sparsePresetsByRepo: Record<string, SparsePreset[]>
+2
View File
@@ -1,2 +1,4 @@
export const ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT = 'orca:updater-quit-and-install-started'
export const ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT = 'orca:updater-quit-and-install-aborted'
export const ORCA_APP_RESTART_STARTED_EVENT = 'orca:app-restart-started'
export const ORCA_APP_RESTART_ABORTED_EVENT = 'orca:app-restart-aborted'
+24 -8
View File
@@ -347,8 +347,8 @@ describe('resolveWindowShortcutAction', () => {
expect(
resolveWindowShortcutAction(
{
code: 'KeyT',
key: 't',
code: 'KeyA',
key: 'a',
meta: true,
control: false,
alt: true,
@@ -361,8 +361,8 @@ describe('resolveWindowShortcutAction', () => {
expect(
resolveWindowShortcutAction(
{
code: 'KeyT',
key: 't',
code: 'KeyA',
key: 'a',
meta: false,
control: true,
alt: true,
@@ -373,12 +373,28 @@ describe('resolveWindowShortcutAction', () => {
).toEqual({ type: 'toggleFloatingTerminal' })
})
it('resolves the floating terminal chord when macOS Option composes the letter', () => {
expect(
resolveWindowShortcutAction(
{
code: 'KeyA',
key: 'å',
meta: true,
control: false,
alt: true,
shift: false
},
'darwin'
)
).toEqual({ type: 'toggleFloatingTerminal' })
})
it('rejects floating terminal chord variants with Shift or opposite primary modifier', () => {
expect(
resolveWindowShortcutAction(
{
code: 'KeyT',
key: 't',
code: 'KeyA',
key: 'a',
meta: true,
control: false,
alt: true,
@@ -391,8 +407,8 @@ describe('resolveWindowShortcutAction', () => {
expect(
resolveWindowShortcutAction(
{
code: 'KeyT',
key: 't',
code: 'KeyA',
key: 'a',
meta: true,
control: true,
alt: true,
+3 -5
View File
@@ -19,22 +19,20 @@ export const WORKBENCH_STEPS: readonly WorkbenchStep[] = [
id: 'terminal',
name: 'Terminal',
subtitle: 'Terminal',
description:
'Bring your terminal setup into Orca, then split panes to keep servers, tests, logs, and agents running side by side.'
description: 'Keep your agents, tests, and dev logs visible at once.'
},
{
id: 'editor',
name: 'Editor',
subtitle: 'Editor',
description:
'A rich Notion-style markdown editor with slash commands, inline blocks, and autosave.'
description: 'Use our Notion-style markdown editor to write notes without leaving Orca.'
},
{
id: 'browser',
name: 'Browser',
subtitle: 'Browser',
description:
"Run your app in Orca's browser, send selected UI elements to agents, and let agents navigate, click, and verify pages."
"Run your app in Orca's browser, send selected UI elements to agents, and let your agents interact with your webpage."
}
] as const
+13
View File
@@ -12,6 +12,19 @@ describe('resolveWorktreeAddBaseRef', () => {
expect(refExists).not.toHaveBeenCalled()
})
it('leaves provider review refs unchanged', async () => {
const refExists = vi.fn()
await expect(resolveWorktreeAddBaseRef('refs/pull/123/head', refExists)).resolves.toBe(
'refs/pull/123/head'
)
await expect(
resolveWorktreeAddBaseRef('refs/merge-requests/456/head', refExists)
).resolves.toBe('refs/merge-requests/456/head')
expect(refExists).not.toHaveBeenCalled()
})
it('qualifies a bare local branch name', async () => {
const refExists = vi.fn(async (ref: string) => ref === 'refs/heads/main')
+2 -2
View File
@@ -178,9 +178,9 @@ function hasStrongOrcaMetadata(meta: WorktreeMeta | undefined): boolean {
)
}
function matchesStrongOrcaCreatePath(
export function matchesStrongOrcaCreatePath(
worktreePath: string,
knownOrcaLayouts: OrcaWorkspaceLayout[],
knownOrcaLayouts: readonly OrcaWorkspaceLayout[],
repo: Pick<Repo, 'path'>
): boolean {
const repoName = getRuntimePathBasename(repo.path).replace(/\.git$/i, '')