mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
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:
@@ -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])
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -38,7 +38,9 @@ export type AgentHookSource =
|
||||
| 'opencode'
|
||||
| 'cursor'
|
||||
| 'pi'
|
||||
| 'omp'
|
||||
| 'droid'
|
||||
| 'command-code'
|
||||
| 'grok'
|
||||
| 'copilot'
|
||||
| 'hermes'
|
||||
|
||||
@@ -10,6 +10,7 @@ export const AGENT_HOOK_TARGETS = [
|
||||
'antigravity',
|
||||
'cursor',
|
||||
'droid',
|
||||
'command-code',
|
||||
'grok',
|
||||
'copilot',
|
||||
'hermes'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,9 @@ export type WellKnownAgentType =
|
||||
| 'copilot'
|
||||
| 'aider'
|
||||
| 'pi'
|
||||
| 'omp'
|
||||
| 'droid'
|
||||
| 'command-code'
|
||||
| 'grok'
|
||||
| 'hermes'
|
||||
| 'unknown'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: ''
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, '\\$&')
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export type CreateHostedReviewInput = {
|
||||
body?: string
|
||||
draft?: boolean
|
||||
worktreePath?: string
|
||||
useTemplate?: boolean
|
||||
}
|
||||
|
||||
export type CreateHostedReviewArgs = CreateHostedReviewInput & {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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, '\\$&')
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('; ')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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, '')
|
||||
|
||||
Reference in New Issue
Block a user