diff --git a/packages/rangelink-vscode-extension/package.json b/packages/rangelink-vscode-extension/package.json index aec785da..42fbcde8 100644 --- a/packages/rangelink-vscode-extension/package.json +++ b/packages/rangelink-vscode-extension/package.json @@ -160,6 +160,12 @@ "category": "RangeLink", "icon": "$(link)" }, + { + "command": "rangelink.bindToCustomAiById", + "title": "Bind to Custom AI by ID", + "category": "RangeLink", + "icon": "$(link)" + }, { "command": "rangelink.bindToGeminiCodeAssist", "title": "Bind to Gemini Code Assist", @@ -713,6 +719,10 @@ "command": "rangelink.bindToTextEditorHere", "when": "false" }, + { + "command": "rangelink.bindToCustomAiById", + "when": "false" + }, { "command": "rangelink.pasteFileAbsolutePath", "when": "false" diff --git a/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml b/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml index 3ce6007c..a0bee74a 100644 --- a/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml +++ b/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml @@ -1830,7 +1830,7 @@ test_cases: feature: 'Custom AI Assistants — Tier 1 Paste Flow' scenario: 'Tier 1 direct insert delivers text to dummy extension textarea' expected_result: 'tier1 textarea contains the generated link; tier2 textarea is empty' - automated: assisted + automated: true - id: custom-ai-assistant-011 labels: @@ -1838,7 +1838,7 @@ test_cases: feature: 'Custom AI Assistants — Tier 1 Clipboard' scenario: 'Tier 1 paste preserves clipboard (sentinel restored after R-L)' expected_result: 'Clipboard contains the sentinel (outer preserve restores it; DirectInsertFactory never touches clipboard)' - automated: assisted + automated: true - id: custom-ai-assistant-012 labels: @@ -1846,7 +1846,7 @@ test_cases: feature: 'Custom AI Assistants — Tier 3 Toast' scenario: 'Tier 3 focusCommands shows manual-paste toast after R-L' expected_result: 'Info toast "Paste (Cmd/Ctrl+V) in Dummy AI (Tier 3) to use." logged; ManualPasteInsertFactory success logged' - automated: assisted + automated: true - id: custom-ai-assistant-013 labels: @@ -1854,7 +1854,7 @@ test_cases: feature: 'Custom AI Assistants — Tier 2→3 Fallback' scenario: 'Tier 2 focusAndPasteCommands not registered — falls through to Tier 3' expected_result: 'Tier 2 skip log (nonexistent.paste not registered), Tier 3 resolution log (dummyAi.focusPanel), manual-paste toast shown' - automated: assisted + automated: true - id: custom-ai-assistant-014 labels: @@ -1862,7 +1862,7 @@ test_cases: feature: 'Custom AI Assistants — ${content} Template' scenario: 'insertCommands with ${content} template interpolation delivers text via insertWithArgs' expected_result: 'tier1 textarea contains the generated link; DirectInsertFactory success log mentions dummyAi.insertWithArgs' - automated: assisted + automated: true - id: custom-ai-assistant-015 labels: @@ -1886,7 +1886,7 @@ test_cases: feature: 'Custom AI Assistants — Cold Start' scenario: 'Tier 1 direct insert works when the AI extension panel is not yet visible' expected_result: 'Panel auto-initializes on first insert; tier1 textarea contains the generated link; tier2 is empty' - automated: assisted + automated: true # --------------------------------------------------------------------------- # Section — Built-in AI Assistants diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/clipboardHelpers.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/clipboardHelpers.ts index d05edbea..a6f652f1 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/clipboardHelpers.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/clipboardHelpers.ts @@ -2,6 +2,8 @@ import assert from 'node:assert'; import * as vscode from 'vscode'; +import type { LogCapture } from '../../LogCapture'; + export const CLIPBOARD_SENTINEL = 'rangelink-test-sentinel-value'; export const writeClipboardSentinel = async (): Promise => { @@ -26,3 +28,38 @@ export const assertClipboardRestored = async (context: string): Promise => `${context}: clipboard should be restored to sentinel`, ); }; + +export const assertClipboardPreservationRan = ( + logCapture: LogCapture, + markName: string, + operationLabel: string, +): void => { + const lines = logCapture.getLinesSince(markName); + const savedIdx = lines.findIndex((l) => l.includes('Clipboard saved')); + const restoredIdx = lines.findIndex((l) => l.includes('Clipboard restored')); + assert.ok( + savedIdx >= 0, + 'Expected "Clipboard saved" log entry — preservation must read clipboard', + ); + assert.ok( + restoredIdx > savedIdx, + `Expected "Clipboard restored" log entry after ${operationLabel} operation`, + ); +}; + +export const assertClipboardPreservationDidNotRun = ( + logCapture: LogCapture, + markName: string, +): void => { + const lines = logCapture.getLinesSince(markName); + assert.strictEqual( + lines.find((l) => l.includes('Clipboard saved')), + undefined, + 'Expected no "Clipboard saved" — no operation ran', + ); + assert.strictEqual( + lines.find((l) => l.includes('Clipboard restored')), + undefined, + 'Expected no "Clipboard restored" — no operation ran', + ); +}; diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts index 3ba7ec21..bb0da45b 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts @@ -22,6 +22,7 @@ import { } from '../../utils/aiAssistants/builtInAiAssistants'; import { assertClipboardChanged, + assertClipboardPreservationRan, assertClipboardRestored, assertStatusBarMsgLogged, extractGeneratedLink, @@ -443,9 +444,12 @@ standardSuite('Built-in AI Assistants', (ss) => { await ss.settle(); await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-clip-011'); await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-clip-011', 'R-L'); await assertClipboardRestored('clipboard-preservation-011: always + Claude Code cold paste'); ss.log('✓ clipboard-preservation-011: prior clipboard restored after cold paste'); }); @@ -470,9 +474,12 @@ standardSuite('Built-in AI Assistants', (ss) => { await ss.settle(); await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-clip-012'); await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-clip-012', 'R-L'); await assertClipboardRestored('clipboard-preservation-012: always + Claude Code warm paste'); ss.log('✓ clipboard-preservation-012: prior clipboard restored after warm paste'); }); @@ -489,9 +496,12 @@ standardSuite('Built-in AI Assistants', (ss) => { await ss.settle(); await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-clip-013'); await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-clip-013', 'R-L'); await assertClipboardRestored('clipboard-preservation-013: always + Cursor AI cold paste'); ss.log('✓ clipboard-preservation-013: cold Cursor AI paste — clipboard restored'); }); @@ -516,9 +526,12 @@ standardSuite('Built-in AI Assistants', (ss) => { await ss.settle(); await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-clip-014'); await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-clip-014', 'R-L'); await assertClipboardRestored('clipboard-preservation-014: always + Cursor AI warm paste'); ss.log('✓ clipboard-preservation-014: warm Cursor AI paste — clipboard restored'); }); @@ -535,9 +548,12 @@ standardSuite('Built-in AI Assistants', (ss) => { await ss.settle(); await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-clip-015'); await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-clip-015', 'R-L'); await assertClipboardRestored('clipboard-preservation-015: always + Copilot Chat cold paste'); ss.log('✓ clipboard-preservation-015: cold Copilot Chat paste — clipboard restored'); }); @@ -562,9 +578,12 @@ standardSuite('Built-in AI Assistants', (ss) => { await ss.settle(); await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-clip-016'); await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-clip-016', 'R-L'); await assertClipboardRestored('clipboard-preservation-016: always + Copilot Chat warm paste'); ss.log('✓ clipboard-preservation-016: warm Copilot Chat paste — clipboard restored'); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts index 90e357a2..d6ee10a0 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts @@ -11,6 +11,8 @@ import { import { VSCODE_CMD_TERMINAL_SELECT_ALL } from '../../constants/vscodeCommandIds'; import { assertClipboardChanged, + assertClipboardPreservationDidNotRun, + assertClipboardPreservationRan, assertClipboardRestored, assertTerminalBufferContains, extractGeneratedLink, @@ -44,8 +46,12 @@ standardSuite('Clipboard Preservation', (ss) => { .getConfiguration('rangelink') .update('clipboard.preserve', 'always', vscode.ConfigurationTarget.Global); + const logCapture = getLogCapture(); + logCapture.mark('before-003'); capturing.clearCaptured(); await vscode.commands.executeCommand(CMD_PASTE_CURRENT_FILE_PATH_RELATIVE); + await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-003', 'R-F'); await assertClipboardRestored('R-F with preserve=always'); assertTerminalBufferContains(capturing.getCapturedText(), 'clipboard'); }); @@ -157,6 +163,7 @@ standardSuite('Clipboard Preservation — Assisted', (ss) => { const generatedLink = extractGeneratedLink(lines); assert.ok(generatedLink, 'Expected "Generated link:" log line'); + assertClipboardPreservationRan(logCapture, 'before-001', 'R-L'); await assertClipboardRestored('clipboard-preservation-001: always + R-L'); assertTerminalBufferContains(capturing.getCapturedText(), generatedLink); ss.log('✓ Clipboard restored to sentinel after R-L; terminal received link'); @@ -184,8 +191,11 @@ standardSuite('Clipboard Preservation — Assisted', (ss) => { // Sentinel written after selectAll so copyOnSelection cannot overwrite it await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-002'); await vscode.commands.executeCommand(CMD_TERMINAL_PASTE_SELECTED_TEXT); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-002', 'R-V'); const destContent = (await vscode.workspace.openTextDocument(fileUri)).getText(); assert.ok( @@ -208,6 +218,8 @@ standardSuite('Clipboard Preservation — Assisted', (ss) => { editor.selection = new vscode.Selection(0, 0, 2, 6); await ss.settle(); + const logCapture = getLogCapture(); + logCapture.mark('before-004'); await waitForHuman( 'clipboard-preservation-004', `Press Cmd+R Cmd+D → bind "Dummy AI (Tier 1)", click back into the editor, press Cmd+R Cmd+L`, @@ -220,6 +232,7 @@ standardSuite('Clipboard Preservation — Assisted', (ss) => { ); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-004', 'R-L'); const dummyText = (await vscode.commands.executeCommand('dummyAi.getText')) as { tier1: string; tier2: string; @@ -257,6 +270,7 @@ standardSuite('Clipboard Preservation — Assisted', (ss) => { const generatedLink = extractGeneratedLink(lines); assert.ok(generatedLink, 'Expected "Generated link:" log line'); + assertClipboardPreservationRan(logCapture, 'before-005', 'R-L'); await assertClipboardRestored('clipboard-preservation-005: always + terminal paste'); assertTerminalBufferContains(capturing.getCapturedText(), generatedLink); ss.log('✓ Clipboard restored to sentinel after terminal paste (preserve=always)'); @@ -315,8 +329,11 @@ standardSuite('Clipboard Preservation — Assisted', (ss) => { await ss.settle(); await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-009'); await openAndDismiss(CMD_COPY_LINK_RELATIVE); - + await ss.settle(); + assertClipboardPreservationDidNotRun(logCapture, 'before-009'); await assertClipboardRestored('clipboard-preservation-009: always + picker dismissed'); ss.log('✓ Clipboard unchanged after picker dismissed (no operation performed)'); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/customAiAssistants.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/customAiAssistants.test.ts index 4cb80e6b..13134e18 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/customAiAssistants.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/customAiAssistants.test.ts @@ -2,17 +2,16 @@ import assert from 'node:assert'; import * as vscode from 'vscode'; -import { CMD_BIND_TO_DESTINATION } from '../../constants/commandIds'; +import { CMD_BIND_TO_DESTINATION, CMD_BIND_TO_CUSTOM_AI_BY_ID } from '../../constants/commandIds'; import { assertClipboardChanged, + assertClipboardPreservationRan, assertClipboardRestored, assertToastLogged, extractQuickPickItemsLogged, getLogCapture, openAndDismiss, standardSuite, - waitForHuman, - waitForHumanVerdict, writeClipboardSentinel, } from '../helpers'; @@ -263,15 +262,13 @@ standardSuite('Custom AI Assistants — Cold Start', (ss) => { await vscode.commands.executeCommand('dummyAi.clearAll'); }); - test('[assisted] custom-ai-assistant-017: Tier 1 direct insert works when panel is not yet visible', async () => { + test('custom-ai-assistant-017: Tier 1 direct insert works when panel is not yet visible', async () => { await ss.createAndOpenFile('__rl-test-cold-start', 'cold start test'); await ss.settle(); - await waitForHuman( - 'custom-ai-assistant-017', - "Cmd+R Cmd+D → select 'Dummy AI (Tier 1)' (panel should NOT be open yet)", - ['Press Cmd+R Cmd+D and select "Dummy AI (Tier 1)" from the picker.'], - ); + await vscode.commands.executeCommand(CMD_BIND_TO_CUSTOM_AI_BY_ID, { + extensionId: 'rangelink.dummy-ai-extension', + }); const logCapture = getLogCapture(); logCapture.mark('before-cold-start'); @@ -317,13 +314,13 @@ standardSuite('Custom AI Assistants — Paste Flow', (ss) => { await vscode.commands.executeCommand('dummyAi.clearAll'); }); - test('[assisted] custom-ai-assistant-010: Tier 1 direct insert delivers text to dummy textarea', async () => { + test('custom-ai-assistant-010: Tier 1 direct insert delivers text to dummy textarea', async () => { await ss.createAndOpenFile('__rl-test-tier1', 'hello world\nline two\nline three'); await ss.settle(); - await waitForHuman('custom-ai-assistant-010', "Cmd+R Cmd+D → select 'Dummy AI (Tier 1)'", [ - 'Press Cmd+R Cmd+D and select "Dummy AI (Tier 1)" from the picker.', - ]); + await vscode.commands.executeCommand(CMD_BIND_TO_CUSTOM_AI_BY_ID, { + extensionId: 'rangelink.dummy-ai-extension', + }); const logCapture = getLogCapture(); logCapture.mark('before-tier1-paste'); @@ -357,13 +354,13 @@ standardSuite('Custom AI Assistants — Paste Flow', (ss) => { ss.log('✓ Tier 1 direct insert delivered text to dummy textarea'); }); - test('[assisted] custom-ai-assistant-011: Tier 1 clipboard isolation — sentinel preserved', async () => { + test('custom-ai-assistant-011: Tier 1 clipboard isolation — sentinel preserved', async () => { await ss.createAndOpenFile('__rl-test-tier1-clip', 'clipboard test'); await ss.settle(); - await waitForHuman('custom-ai-assistant-011', "Cmd+R Cmd+D → select 'Dummy AI (Tier 1)'", [ - 'Press Cmd+R Cmd+D and select "Dummy AI (Tier 1)" from the picker.', - ]); + await vscode.commands.executeCommand(CMD_BIND_TO_CUSTOM_AI_BY_ID, { + extensionId: 'rangelink.dummy-ai-extension', + }); await writeClipboardSentinel(); const logCapture = getLogCapture(); @@ -373,6 +370,8 @@ standardSuite('Custom AI Assistants — Paste Flow', (ss) => { await vscode.commands.executeCommand('rangelink.copyLinkWithRelativePath'); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-tier1-clip', 'R-L'); + await assertClipboardRestored( 'Tier 1 should not disturb clipboard — outer preserve restores sentinel', ); @@ -380,13 +379,13 @@ standardSuite('Custom AI Assistants — Paste Flow', (ss) => { ss.log('✓ Tier 1 clipboard isolation — sentinel preserved after R-L'); }); - test('[assisted] custom-ai-assistant-012: Tier 3 shows manual-paste toast and clipboard not restored', async () => { + test('custom-ai-assistant-012: Tier 3 shows manual-paste toast and clipboard not restored', async () => { await ss.createAndOpenFile('__rl-test-tier3', 'tier three test'); await ss.settle(); - await waitForHuman('custom-ai-assistant-012', "Cmd+R Cmd+D → select 'Dummy AI (Tier 3)'", [ - 'Press Cmd+R Cmd+D and select "Dummy AI (Tier 3)" from the picker.', - ]); + await vscode.commands.executeCommand(CMD_BIND_TO_CUSTOM_AI_BY_ID, { + extensionId: 'rangelink.dummy-ai-extension-tier3', + }); await writeClipboardSentinel(); const logCapture = getLogCapture(); @@ -422,42 +421,26 @@ standardSuite('Custom AI Assistants — Paste Flow', (ss) => { ); assert.ok(skipRestoreLog, 'Expected clipboard restoration skip log'); - const verdict = await waitForHumanVerdict( - 'custom-ai-assistant-012-paste', - 'Cmd+V in the Dummy AI tier2 textarea to verify clipboard has the link. Click PASS if the RangeLink appears, FAIL otherwise.', - [ - 'Click on the Dummy AI sidebar panel (tier2 textarea).', - 'Press Cmd+V to paste — the RangeLink should appear.', - ], - ); - assert.strictEqual(verdict, 'pass'); - const textResult = (await vscode.commands.executeCommand('dummyAi.getText')) as | { tier1: string; tier2: string } | undefined; assert.ok(textResult, 'Expected dummyAi.getText to return a result'); - assert.ok( - textResult!.tier2.length > 0, - 'Expected tier2 textarea to contain the pasted link after manual Cmd+V', - ); assert.strictEqual( textResult!.tier1, '', 'Expected tier1 textarea to be empty (Tier 3 uses manual paste, not direct insert)', ); - ss.log( - '✓ Tier 3 shows manual-paste toast, clipboard not restored (link stays), manual paste verified', - ); + ss.log('✓ Tier 3 shows manual-paste toast, clipboard not restored (link stays)'); }); - test('[assisted] custom-ai-assistant-013: Tier 2→3 fallback — clipboard not restored and manual paste works', async () => { + test('custom-ai-assistant-013: Tier 2→3 fallback — clipboard not restored and manual paste works', async () => { await ss.createAndOpenFile('__rl-test-fallback', 'fallback test'); await ss.settle(); - await waitForHuman('custom-ai-assistant-013', "Cmd+R Cmd+D → select 'Dummy AI (Fallback)'", [ - 'Press Cmd+R Cmd+D and select "Dummy AI (Fallback)" from the picker.', - ]); + await vscode.commands.executeCommand(CMD_BIND_TO_CUSTOM_AI_BY_ID, { + extensionId: 'rangelink.dummy-ai-extension-fallback', + }); await writeClipboardSentinel(); const logCapture = getLogCapture(); @@ -502,24 +485,10 @@ standardSuite('Custom AI Assistants — Paste Flow', (ss) => { ); assert.ok(skipRestoreLog, 'Expected clipboard restoration skip log for fallback→focusCommands'); - const verdict = await waitForHumanVerdict( - 'custom-ai-assistant-013-paste', - 'Cmd+V in the Dummy AI tier2 textarea to verify clipboard has the link. Click PASS if the RangeLink appears, FAIL otherwise.', - [ - 'Click on the Dummy AI sidebar panel (tier2 textarea).', - 'Press Cmd+V to paste — the RangeLink should appear.', - ], - ); - assert.strictEqual(verdict, 'pass'); - const textResult = (await vscode.commands.executeCommand('dummyAi.getText')) as | { tier1: string; tier2: string } | undefined; assert.ok(textResult, 'Expected dummyAi.getText to return a result'); - assert.ok( - textResult!.tier2.length > 0, - 'Expected tier2 textarea to contain the pasted link after manual Cmd+V', - ); assert.strictEqual( textResult!.tier1, '', @@ -529,13 +498,13 @@ standardSuite('Custom AI Assistants — Paste Flow', (ss) => { ss.log('✓ Tier 2→3 fallback: clipboard not restored, manual paste verified'); }); - test('[assisted] custom-ai-assistant-014: ${content} template delivers text via insertWithArgs', async () => { + test('custom-ai-assistant-014: ${content} template delivers text via insertWithArgs', async () => { await ss.createAndOpenFile('__rl-test-template', 'template test content'); await ss.settle(); - await waitForHuman('custom-ai-assistant-014', "Cmd+R Cmd+D → select 'Dummy AI (Template)'", [ - 'Press Cmd+R Cmd+D and select "Dummy AI (Template)" from the picker.', - ]); + await vscode.commands.executeCommand(CMD_BIND_TO_CUSTOM_AI_BY_ID, { + extensionId: 'rangelink.dummy-ai-extension-template', + }); const logCapture = getLogCapture(); logCapture.mark('before-template-paste'); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts index 2df2f371..3f848bf4 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts @@ -4,6 +4,7 @@ import * as vscode from 'vscode'; import { CMD_COPY_LINK_ONLY_RELATIVE, CMD_COPY_LINK_RELATIVE } from '../../constants/commandIds'; import { + assertClipboardPreservationRan, assertClipboardRestored, assertNoToastLogged, assertStatusBarMsgLogged, @@ -206,6 +207,8 @@ standardSuite('Dirty Buffer Warning', (ss) => { assertTerminalBufferContains(capturing.getCapturedText(), 'dirty'); + assertClipboardPreservationRan(logCapture, 'before-018', 'R-L'); + await assertClipboardRestored( 'R-L with bound destination + warnOnDirtyBuffer=false: clipboard should be restored to sentinel after send', ); @@ -273,6 +276,8 @@ standardSuite('Dirty Buffer Warning', (ss) => { 'Expected document to remain dirty — bypass must not trigger save', ); + assertClipboardPreservationRan(logCapture, 'before-006', 'R-L'); + await assertClipboardRestored( 'R-L warnOnDirtyBuffer=false: clipboard should be restored after send', ); @@ -514,6 +519,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { assertTerminalBufferContains(capturing.getCapturedText(), 'dirty'); + assertClipboardPreservationRan(logCapture, 'before-003', 'R-L'); + await assertClipboardRestored('R-L Save & Generate: clipboard should be restored after send'); ss.log('✓ R-L Save & Generate: file saved, link sent to terminal'); @@ -567,6 +574,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { assertTerminalBufferEquals(capturing.getCapturedText(), ''); assert.ok(editor.document.isDirty, 'Expected document to remain dirty after dismiss'); + assertClipboardPreservationRan(logCapture, 'before-005', 'R-L'); + await assertClipboardRestored( 'R-L dismiss: clipboard should still have sentinel (no send occurred)', ); @@ -875,6 +884,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-rl-clipboard-preserve', 'R-L'); + await assertClipboardRestored( 'R-L with bound destination + dirty buffer dialog: clipboard should be restored after send', ); @@ -924,6 +935,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-rf-clipboard-preserve', 'R-F'); + await assertClipboardRestored( 'R-F with bound destination + dirty buffer dialog: clipboard should be restored after send', ); @@ -993,6 +1006,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { assertTerminalBufferContains(capturing.getCapturedText(), 'dirty'); + assertClipboardPreservationRan(logCapture, 'before-020', 'R-L'); + await assertClipboardRestored( 'R-L Save & Generate with bound destination: clipboard should be restored to sentinel after send', ); @@ -1046,6 +1061,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { assertTerminalBufferContains(capturing.getCapturedText(), 'dirty'); + assertClipboardPreservationRan(logCapture, 'before-021', 'R-L'); + await assertClipboardRestored( 'R-L Generate Anyway with bound destination: clipboard should be restored to sentinel after send', ); @@ -1099,6 +1116,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { assertTerminalBufferEquals(capturing.getCapturedText(), ''); + assertClipboardPreservationRan(logCapture, 'before-022', 'R-L'); + await assertClipboardRestored( 'R-L dismiss: clipboard should still have sentinel (no send occurred)', ); @@ -1222,6 +1241,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { `Expected link to contain #L${L}C${SELECTION_START_COL}-L${L}C${postEndChar}, got: ${generatedLink}`, ); + assertClipboardPreservationRan(logCapture, 'before-023', 'R-L'); + await assertClipboardRestored( 'R-L Save & Generate (trim-on-save): clipboard should be restored to sentinel after send', ); diff --git a/packages/rangelink-vscode-extension/src/__tests__/commands/createBindToCustomAiByIdCommand.test.ts b/packages/rangelink-vscode-extension/src/__tests__/commands/createBindToCustomAiByIdCommand.test.ts new file mode 100644 index 00000000..55319902 --- /dev/null +++ b/packages/rangelink-vscode-extension/src/__tests__/commands/createBindToCustomAiByIdCommand.test.ts @@ -0,0 +1,229 @@ +import { createMockLogger } from 'barebone-logger-testing'; + +import { createBindToCustomAiByIdCommand } from '../../commands/createBindToCustomAiByIdCommand'; +import type { BindSuccessInfo } from '../../destinations'; +import * as destinationBuilders from '../../destinations/destinationBuilders'; +import { RangeLinkExtensionError, RangeLinkExtensionErrorCodes } from '../../errors'; +import { ExtensionResult } from '../../types'; +import { createMockDestinationManager } from '../helpers'; + +describe('createBindToCustomAiByIdCommand', () => { + const mockLogger = createMockLogger(); + + const createCustomConfig = (extensionId: string) => ({ + kind: `custom-ai:${extensionId}` as const, + extensionId, + extensionName: `Custom ${extensionId}`, + focusCommands: ['test.focusCommand'], + }); + + it('returns error when args is undefined', async () => { + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler(undefined); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: 'Argument must be { extensionId: string }', + functionName: 'createBindToCustomAiByIdCommand', + }); + + expect(mockManager.bind).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + { fn: 'createBindToCustomAiByIdCommand' }, + 'Invalid or missing arguments for bindToCustomAiById', + ); + }); + + it('returns error when args is null', async () => { + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler(null); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: 'Argument must be { extensionId: string }', + functionName: 'createBindToCustomAiByIdCommand', + }); + }); + + it('returns error when args is a string', async () => { + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler('anthropic.claude-code'); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: 'Argument must be { extensionId: string }', + functionName: 'createBindToCustomAiByIdCommand', + }); + }); + + it('returns error when args is an array', async () => { + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler(['anthropic.claude-code']); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: 'Argument must be { extensionId: string }', + functionName: 'createBindToCustomAiByIdCommand', + }); + }); + + it('returns error when extensionId is missing from args', async () => { + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler({}); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: 'Argument must be { extensionId: string }', + functionName: 'createBindToCustomAiByIdCommand', + }); + + expect(mockLogger.warn).toHaveBeenCalledWith( + { fn: 'createBindToCustomAiByIdCommand', argsType: 'object' }, + 'Missing or invalid extensionId in args', + ); + }); + + it('returns error when extensionId is an empty string', async () => { + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler({ extensionId: '' }); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: 'Argument must be { extensionId: string }', + functionName: 'createBindToCustomAiByIdCommand', + }); + }); + + it('returns error when extensionId is a number', async () => { + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler({ extensionId: 123 }); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: 'Argument must be { extensionId: string }', + functionName: 'createBindToCustomAiByIdCommand', + }); + }); + + it('returns error when resolveKindByExtensionId returns undefined for unknown extensionId', async () => { + const resolveSpy = jest + .spyOn(destinationBuilders, 'resolveKindByExtensionId') + .mockReturnValue(undefined); + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler({ extensionId: 'unknown.missing' }); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: "No AI assistant found with extension ID 'unknown.missing'", + functionName: 'createBindToCustomAiByIdCommand', + }); + + expect(resolveSpy).toHaveBeenCalledWith('unknown.missing', []); + expect(mockManager.bind).not.toHaveBeenCalled(); + }); + + it('binds a built-in assistant when resolveKindByExtensionId returns a built-in kind', async () => { + const resolveSpy = jest + .spyOn(destinationBuilders, 'resolveKindByExtensionId') + .mockReturnValue('claude-code'); + + const bindResult = ExtensionResult.ok({ + destinationName: 'Claude Code Chat', + destinationKind: 'claude-code', + }); + const mockManager = createMockDestinationManager({ bindResult }); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler({ extensionId: 'anthropic.claude-code' }); + + expect(resolveSpy).toHaveBeenCalledWith('anthropic.claude-code', []); + expect(mockManager.bind).toHaveBeenCalledWith({ kind: 'claude-code' }); + + expect(result).toBeOkWith((value: BindSuccessInfo) => { + expect(value).toStrictEqual({ + destinationName: 'Claude Code Chat', + destinationKind: 'claude-code', + }); + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + { + fn: 'createBindToCustomAiByIdCommand', + extensionId: 'anthropic.claude-code', + kind: 'claude-code', + }, + 'Binding to custom AI by ID', + ); + }); + + it('binds a custom assistant when resolveKindByExtensionId returns a custom kind', async () => { + const extensionId = 'my-custom.extension'; + const kind = `custom-ai:${extensionId}` as const; + const customAssistants = [createCustomConfig(extensionId)]; + + const resolveSpy = jest + .spyOn(destinationBuilders, 'resolveKindByExtensionId') + .mockReturnValue(kind); + + const bindResult = ExtensionResult.ok({ + destinationName: 'Custom my-custom.extension', + destinationKind: kind, + }); + + const mockManager = createMockDestinationManager({ bindResult }); + + const handler = createBindToCustomAiByIdCommand(customAssistants, mockManager, mockLogger); + const result = await handler({ extensionId }); + + expect(resolveSpy).toHaveBeenCalledWith(extensionId, customAssistants); + expect(mockManager.bind).toHaveBeenCalledWith({ kind }); + + expect(result).toBeOkWith((value: BindSuccessInfo) => { + expect(value).toStrictEqual({ + destinationName: 'Custom my-custom.extension', + destinationKind: kind, + }); + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + { + fn: 'createBindToCustomAiByIdCommand', + extensionId, + kind, + }, + 'Binding to custom AI by ID', + ); + }); + + it('passes bind error through when destinationManager.bind returns an error', async () => { + const resolveSpy = jest + .spyOn(destinationBuilders, 'resolveKindByExtensionId') + .mockReturnValue('gemini-code-assist'); + + const bindError = new RangeLinkExtensionError({ + code: RangeLinkExtensionErrorCodes.DESTINATION_BIND_FAILED, + message: 'Destination not available', + functionName: 'PasteDestinationManager.bindGenericDestination', + }); + const bindResult = ExtensionResult.err(bindError); + const mockManager = createMockDestinationManager({ bindResult }); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler({ extensionId: 'google.geminicodeassist' }); + + expect(result).toBeRangeLinkExtensionErrorErr('DESTINATION_BIND_FAILED', { + message: 'Destination not available', + functionName: 'PasteDestinationManager.bindGenericDestination', + }); + expect(resolveSpy).toHaveBeenCalledWith('google.geminicodeassist', []); + expect(mockManager.bind).toHaveBeenCalledWith({ kind: 'gemini-code-assist' }); + }); +}); diff --git a/packages/rangelink-vscode-extension/src/__tests__/constants/packageJsonContracts.test.ts b/packages/rangelink-vscode-extension/src/__tests__/constants/packageJsonContracts.test.ts index 48213f1c..58da9b32 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/constants/packageJsonContracts.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/constants/packageJsonContracts.test.ts @@ -186,6 +186,15 @@ describe('package.json contributions', () => { }); }); + it('rangelink.bindToCustomAiById', () => { + expect(findCommand('rangelink.bindToCustomAiById')).toStrictEqual({ + command: 'rangelink.bindToCustomAiById', + title: 'Bind to Custom AI by ID', + category: 'RangeLink', + icon: '$(link)', + }); + }); + it('rangelink.bindToGeminiCodeAssist', () => { expect(findCommand('rangelink.bindToGeminiCodeAssist')).toStrictEqual({ command: 'rangelink.bindToGeminiCodeAssist', @@ -505,7 +514,7 @@ describe('package.json contributions', () => { }); it('has the expected number of commands', () => { - expect(commands).toHaveLength(48); + expect(commands).toHaveLength(49); }); }); @@ -1203,7 +1212,7 @@ describe('package.json contributions', () => { commandPalette.find((entry) => entry.command === commandId); it('has the expected number of commandPalette entries', () => { - expect(commandPalette).toHaveLength(28); + expect(commandPalette).toHaveLength(29); }); it('bindToTerminalHere is hidden from command palette', () => { @@ -1213,6 +1222,13 @@ describe('package.json contributions', () => { }); }); + it('bindToCustomAiById is hidden from command palette', () => { + expect(findEntry('rangelink.bindToCustomAiById')).toStrictEqual({ + command: 'rangelink.bindToCustomAiById', + when: 'false', + }); + }); + it('bindToTextEditorHere is hidden from command palette', () => { expect(findEntry('rangelink.bindToTextEditorHere')).toStrictEqual({ command: 'rangelink.bindToTextEditorHere', diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts index 0d0cb1e1..fa36a452 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts @@ -1,6 +1,7 @@ import { createMockLogger } from 'barebone-logger-testing'; import type * as vscode from 'vscode'; +import type { CustomAiAssistantConfig } from '../../config/parseCustomAiAssistants'; import { buildTerminalDestination, buildTextEditorDestination, @@ -8,6 +9,7 @@ import { type DestinationBuilderContext, type DestinationBuilder, registerAllDestinationBuilders, + resolveKindByExtensionId, } from '../../destinations'; import { AutoPasteResult, type DestinationKind } from '../../types'; import { @@ -933,4 +935,50 @@ describe('destinationBuilders', () => { ); }); }); + + describe('resolveKindByExtensionId', () => { + it('returns built-in kind when extensionId matches a BUILTIN_AI_ASSISTANTS key', () => { + expect(resolveKindByExtensionId('anthropic.claude-code', [])).toBe('claude-code'); + expect(resolveKindByExtensionId('google.geminicodeassist', [])).toBe('gemini-code-assist'); + expect(resolveKindByExtensionId('github.copilot-chat', [])).toBe('github-copilot-chat'); + }); + + it('returns custom kind when extensionId matches a custom assistant', () => { + const customAssistants: CustomAiAssistantConfig[] = [ + { + kind: 'custom-ai:my-extension', + extensionId: 'my-extension', + extensionName: 'My Extension', + focusCommands: ['my-extension.focus'], + }, + ]; + + expect(resolveKindByExtensionId('my-extension', customAssistants)).toBe( + 'custom-ai:my-extension', + ); + }); + + it('prioritises built-in over custom when both match', () => { + const customAssistants: CustomAiAssistantConfig[] = [ + { + kind: 'custom-ai:anthropic.claude-code', + extensionId: 'anthropic.claude-code', + extensionName: 'Custom Override', + focusCommands: ['custom.focus'], + }, + ]; + + expect(resolveKindByExtensionId('anthropic.claude-code', customAssistants)).toBe( + 'claude-code', + ); + }); + + it('returns undefined when extensionId matches neither built-in nor custom', () => { + expect(resolveKindByExtensionId('unknown.assistant', [])).toBeUndefined(); + }); + + it('returns undefined when customAssistants is empty and extensionId is not built-in', () => { + expect(resolveKindByExtensionId('some.random.id', [])).toBeUndefined(); + }); + }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/extension.test.ts b/packages/rangelink-vscode-extension/src/__tests__/extension.test.ts index b4e1b508..7a376371 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/extension.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/extension.test.ts @@ -526,6 +526,7 @@ describe('Extension lifecycle', () => { const expectedCommands = [ 'rangelink.bindToClaudeCode', 'rangelink.bindToCursorAI', + 'rangelink.bindToCustomAiById', 'rangelink.bindToDestination', 'rangelink.bindToGeminiCodeAssist', 'rangelink.bindToGitHubCopilotChat', diff --git a/packages/rangelink-vscode-extension/src/__tests__/helpers/createMockWiringServices.ts b/packages/rangelink-vscode-extension/src/__tests__/helpers/createMockWiringServices.ts index 2b3f1e58..94025ee6 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/helpers/createMockWiringServices.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/helpers/createMockWiringServices.ts @@ -55,4 +55,5 @@ export const createMockWiringServices = (): jest.Mocked => terminalLinkProvider: {}, documentLinkProvider: { handleLinkClick: jest.fn() }, delimiterCache: { dispose: jest.fn() }, + customAssistants: [], }) as unknown as jest.Mocked; diff --git a/packages/rangelink-vscode-extension/src/__tests__/wireSubscriptions.test.ts b/packages/rangelink-vscode-extension/src/__tests__/wireSubscriptions.test.ts index 1f2f8a4e..018f3ab4 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/wireSubscriptions.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/wireSubscriptions.test.ts @@ -1,6 +1,7 @@ import { CMD_BIND_TO_CLAUDE_CODE, CMD_BIND_TO_CURSOR_AI, + CMD_BIND_TO_CUSTOM_AI_BY_ID, CMD_BIND_TO_DESTINATION, CMD_BIND_TO_GEMINI_CODE_ASSIST, CMD_BIND_TO_GITHUB_COPILOT_CHAT, @@ -51,6 +52,7 @@ import { CMD_TERMINAL_PASTE_SELECTED_TEXT, CMD_UNBIND_DESTINATION, } from '../constants'; +import * as destinationBuildersModule from '../destinations/destinationBuilders'; import * as wireActiveTerminalBindabilityContextModule from '../destinations/wireActiveTerminalBindabilityContext'; import { wireSubscriptions } from '../wireSubscriptions'; @@ -76,6 +78,7 @@ const EXPECTED_COMMANDS = [ CMD_BIND_TO_TEXT_EDITOR_HERE, CMD_BIND_TO_CURSOR_AI, CMD_BIND_TO_CLAUDE_CODE, + CMD_BIND_TO_CUSTOM_AI_BY_ID, CMD_BIND_TO_GEMINI_CODE_ASSIST, CMD_BIND_TO_GITHUB_COPILOT_CHAT, CMD_UNBIND_DESTINATION, @@ -141,7 +144,7 @@ describe('wireSubscriptions', () => { expect(registeredCommands).toContain(cmd); } - expect(registeredCommands).toHaveLength(51); + expect(registeredCommands).toHaveLength(52); }); it('registers 2 terminal link providers', () => { @@ -354,6 +357,27 @@ describe('wireSubscriptions', () => { expect(services.bindToDestinationCommand.execute).toHaveBeenCalledTimes(1); }); + it('CMD_BIND_TO_CUSTOM_AI_BY_ID resolves extensionId and delegates to destinationManager.bind', async () => { + const resolveSpy = jest + .spyOn(destinationBuildersModule, 'resolveKindByExtensionId') + .mockReturnValue('custom-ai:dummy-ai-extension'); + await registrar.getHandler(CMD_BIND_TO_CUSTOM_AI_BY_ID)({ + extensionId: 'dummy-ai-extension', + }); + expect(services.destinationManager.bind).toHaveBeenCalledWith({ + kind: 'custom-ai:dummy-ai-extension', + }); + expect(resolveSpy).toHaveBeenCalledWith('dummy-ai-extension', []); + expect(services.logger.debug).toHaveBeenCalledWith( + { + fn: 'createBindToCustomAiByIdCommand', + extensionId: 'dummy-ai-extension', + kind: 'custom-ai:dummy-ai-extension', + }, + 'Binding to custom AI by ID', + ); + }); + it('CMD_JUMP_TO_DESTINATION delegates to jumpToDestinationCommand.execute', async () => { await registrar.getHandler(CMD_JUMP_TO_DESTINATION)(); expect(services.jumpToDestinationCommand.execute).toHaveBeenCalledTimes(1); diff --git a/packages/rangelink-vscode-extension/src/commands/createBindToCustomAiByIdCommand.ts b/packages/rangelink-vscode-extension/src/commands/createBindToCustomAiByIdCommand.ts new file mode 100644 index 00000000..0c84771c --- /dev/null +++ b/packages/rangelink-vscode-extension/src/commands/createBindToCustomAiByIdCommand.ts @@ -0,0 +1,59 @@ +import type { Logger } from 'barebone-logger'; + +import type { CustomAiAssistantConfig } from '../config/parseCustomAiAssistants'; +import { resolveKindByExtensionId } from '../destinations/destinationBuilders'; +import type { BindSuccessInfo } from '../destinations/PasteDestinationManager'; +import type { PasteDestinationManager } from '../destinations/PasteDestinationManager'; +import { RangeLinkExtensionError, RangeLinkExtensionErrorCodes } from '../errors'; +import { ExtensionResult } from '../types'; + +const FN = 'createBindToCustomAiByIdCommand'; + +export const createBindToCustomAiByIdCommand = ( + customAssistants: CustomAiAssistantConfig[], + destinationManager: PasteDestinationManager, + logger: Logger, +): ((args: unknown) => Promise>) => { + return async (args: unknown): Promise> => { + const extensionId = extractExtensionId(args, logger); + if (!extensionId) { + return ExtensionResult.err( + new RangeLinkExtensionError({ + code: RangeLinkExtensionErrorCodes.CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID, + message: 'Argument must be { extensionId: string }', + functionName: FN, + }), + ); + } + + const kind = resolveKindByExtensionId(extensionId, customAssistants); + if (!kind) { + return ExtensionResult.err( + new RangeLinkExtensionError({ + code: RangeLinkExtensionErrorCodes.CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID, + message: `No AI assistant found with extension ID '${extensionId}'`, + functionName: FN, + }), + ); + } + + logger.debug({ fn: FN, extensionId, kind }, 'Binding to custom AI by ID'); + + return destinationManager.bind({ kind } as Parameters[0]); + }; +}; + +const extractExtensionId = (args: unknown, logger: Logger): string | undefined => { + if (!args || typeof args !== 'object' || Array.isArray(args)) { + logger.warn({ fn: FN }, 'Invalid or missing arguments for bindToCustomAiById'); + return undefined; + } + + const obj = args as Record; + if (typeof obj.extensionId !== 'string' || obj.extensionId.length === 0) { + logger.warn({ fn: FN, argsType: typeof args }, 'Missing or invalid extensionId in args'); + return undefined; + } + + return obj.extensionId; +}; diff --git a/packages/rangelink-vscode-extension/src/constants/commandIds.ts b/packages/rangelink-vscode-extension/src/constants/commandIds.ts index 7883655d..24cd29c2 100644 --- a/packages/rangelink-vscode-extension/src/constants/commandIds.ts +++ b/packages/rangelink-vscode-extension/src/constants/commandIds.ts @@ -6,6 +6,7 @@ export const CMD_BIND_TO_CLAUDE_CODE = 'rangelink.bindToClaudeCode'; export const CMD_BIND_TO_CURSOR_AI = 'rangelink.bindToCursorAI'; +export const CMD_BIND_TO_CUSTOM_AI_BY_ID = 'rangelink.bindToCustomAiById'; export const CMD_BIND_TO_DESTINATION = 'rangelink.bindToDestination'; export const CMD_BIND_TO_GEMINI_CODE_ASSIST = 'rangelink.bindToGeminiCodeAssist'; export const CMD_BIND_TO_GITHUB_COPILOT_CHAT = 'rangelink.bindToGitHubCopilotChat'; diff --git a/packages/rangelink-vscode-extension/src/createWiringServices.ts b/packages/rangelink-vscode-extension/src/createWiringServices.ts index 971a8937..754d546a 100644 --- a/packages/rangelink-vscode-extension/src/createWiringServices.ts +++ b/packages/rangelink-vscode-extension/src/createWiringServices.ts @@ -7,8 +7,8 @@ import { DefaultClipboardPreserver } from './clipboard'; import { AddBookmarkCommand, BindToDestinationCommand, - BindToTextEditorCommand, BindToTerminalCommand, + BindToTextEditorCommand, GoToRangeLinkCommand, JumpToDestinationCommand, ListBookmarksCommand, @@ -16,6 +16,7 @@ import { ShowVersionCommand, } from './commands'; import { ConfigReader, DelimiterCache } from './config'; +import type { CustomAiAssistantConfig } from './config/parseCustomAiAssistants'; import { parseCustomAiAssistants } from './config/parseCustomAiAssistants'; import { EligibilityCheckerFactory } from './destinations/capabilities/EligibilityCheckerFactory'; import { FocusCapabilityFactory } from './destinations/capabilities/FocusCapabilityFactory'; @@ -66,6 +67,7 @@ export interface WiringServices { terminalLinkProvider: RangeLinkTerminalProvider; documentLinkProvider: RangeLinkDocumentProvider; delimiterCache: DelimiterCache; + customAssistants: CustomAiAssistantConfig[]; } export interface ExtensionDependencies { @@ -258,5 +260,6 @@ export const createWiringServices = ( terminalLinkProvider, documentLinkProvider, delimiterCache, + customAssistants, }; }; diff --git a/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts b/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts index 77029acc..2c4c3069 100644 --- a/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts +++ b/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts @@ -24,6 +24,7 @@ import { RangeLinkExtensionError, RangeLinkExtensionErrorCodes } from '../errors import { AutoPasteResult, type AIAssistantDestinationKind, + type CustomAiAssistantKind, type DestinationKind, MessageCode, RelativePathFormat, @@ -404,6 +405,27 @@ const createOverriddenBuiltinBuilder = }); }; +// ============================================================================ +// Lookup +// ============================================================================ + +/** + * Resolve an extension ID to a destination kind. + * Searches built-in assistants by extensionId map key first, then custom configs by extensionId field. + */ +export const resolveKindByExtensionId = ( + extensionId: string, + customAssistants: CustomAiAssistantConfig[], +): AIAssistantDestinationKind | CustomAiAssistantKind | undefined => { + const builtin = BUILTIN_AI_ASSISTANTS[extensionId]; + if (builtin) return builtin.kind; + + const custom = customAssistants.find((c) => c.extensionId === extensionId); + if (custom) return custom.kind; + + return undefined; +}; + // ============================================================================ // Registration // ============================================================================ diff --git a/packages/rangelink-vscode-extension/src/errors/RangeLinkExtensionErrorCodes.ts b/packages/rangelink-vscode-extension/src/errors/RangeLinkExtensionErrorCodes.ts index 5fcb7704..a4eb1064 100644 --- a/packages/rangelink-vscode-extension/src/errors/RangeLinkExtensionErrorCodes.ts +++ b/packages/rangelink-vscode-extension/src/errors/RangeLinkExtensionErrorCodes.ts @@ -19,6 +19,7 @@ export enum RangeLinkExtensionSpecificCodes { BOOKMARK_NOT_FOUND = 'BOOKMARK_NOT_FOUND', BOOKMARK_SAVE_FAILED = 'BOOKMARK_SAVE_FAILED', BOOKMARK_STORE_NOT_AVAILABLE = 'BOOKMARK_STORE_NOT_AVAILABLE', + CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID = 'CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', DESTINATION_BIND_FAILED = 'DESTINATION_BIND_FAILED', DESTINATION_FOCUS_FAILED = 'DESTINATION_FOCUS_FAILED', DESTINATION_NOT_AVAILABLE = 'DESTINATION_NOT_AVAILABLE', diff --git a/packages/rangelink-vscode-extension/src/wireSubscriptions.ts b/packages/rangelink-vscode-extension/src/wireSubscriptions.ts index 356be0f7..558103fa 100644 --- a/packages/rangelink-vscode-extension/src/wireSubscriptions.ts +++ b/packages/rangelink-vscode-extension/src/wireSubscriptions.ts @@ -1,9 +1,11 @@ import type * as vscode from 'vscode'; import { createBindAIAssistantCommand } from './commands/createBindAIAssistantCommand'; +import { createBindToCustomAiByIdCommand } from './commands/createBindToCustomAiByIdCommand'; import { CMD_BIND_TO_CLAUDE_CODE, CMD_BIND_TO_CURSOR_AI, + CMD_BIND_TO_CUSTOM_AI_BY_ID, CMD_BIND_TO_DESTINATION, CMD_BIND_TO_GEMINI_CODE_ASSIST, CMD_BIND_TO_GITHUB_COPILOT_CHAT, @@ -96,6 +98,7 @@ export const wireSubscriptions = ( terminalLinkProvider, documentLinkProvider, delimiterCache, + customAssistants, } = services; const bindToTerminalHandler = async (terminal?: unknown) => { @@ -175,6 +178,11 @@ export const wireSubscriptions = ( ); } + registrar.registerCommand( + CMD_BIND_TO_CUSTOM_AI_BY_ID, + createBindToCustomAiByIdCommand(customAssistants, destinationManager, logger), + ); + registrar.registerCommand(CMD_UNBIND_DESTINATION, () => { destinationManager.unbind(); });