diff --git a/packages/rangelink-vscode-extension/CHANGELOG.md b/packages/rangelink-vscode-extension/CHANGELOG.md index d134da8b..91d3f753 100644 --- a/packages/rangelink-vscode-extension/CHANGELOG.md +++ b/packages/rangelink-vscode-extension/CHANGELOG.md @@ -116,6 +116,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Forgiving filename navigation** - Links with bare filenames (no directory path) now navigate when exactly one matching file exists in the workspace (#342) - Covers links generated by AI tools that omit directory prefixes (e.g., `RangeLinkNavigationHandler.ts#L10`) - When the filename is ambiguous (multiple matches) or not found, the existing "Cannot find file" warning is shown +- **Claude Code cold-start re-focus loop** - Adds configurable `coldStartDelayMs` and `coldRefocusIntervalMs` settings under `rangelink.destinations.claudeCode.*` to ensure the chat panel is ready before the first paste dispatch after binding. + - **List Bookmarks (R-B-L)** - `Cmd+R Cmd+B Cmd+L` (Mac) / `Ctrl+R Ctrl+B Ctrl+L` (Win/Linux) - Select a bookmark to paste its link to bound destination (or clipboard if unbound) - Manage bookmarks via the gear icon action in the bookmark list + --> ### Changed diff --git a/packages/rangelink-vscode-extension/README.md b/packages/rangelink-vscode-extension/README.md index c3978de7..fe0aae09 100644 --- a/packages/rangelink-vscode-extension/README.md +++ b/packages/rangelink-vscode-extension/README.md @@ -491,6 +491,13 @@ Set the navigation settings to `false` for a quieter workflow. Navigation itself When you have more terminals than this threshold, the destination picker shows a "More terminals..." option instead of listing all terminals individually. +### Claude Code Settings Unreleased + +| Setting | Default | Description | +| --------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------ | +| `rangelink.destinations.claudeCode.coldStartDelayMs` | `1500` | Total duration (ms) of the cold-start re-focus window for Claude Code | +| `rangelink.destinations.claudeCode.coldRefocusIntervalMs` | `300` | Interval (ms) between successive focus-command re-sends during the cold-start window | + ### Delimiter Settings | Setting | Default | Description | diff --git a/packages/rangelink-vscode-extension/package.json b/packages/rangelink-vscode-extension/package.json index 71866cba..85e5dbf7 100644 --- a/packages/rangelink-vscode-extension/package.json +++ b/packages/rangelink-vscode-extension/package.json @@ -532,6 +532,22 @@ "description": "Delimiter used between start and end positions (e.g., - in #L10-L20)", "pattern": "^[^0-9]+$" }, + "rangelink.destinations.claudeCode.coldStartDelayMs": { + "type": "number", + "default": 1500, + "minimum": 500, + "maximum": 15000, + "description": "Total duration (ms) of the cold-start re-focus window for Claude Code. During this window, focus commands are re-sent at the coldRefocusIntervalMs cadence to ensure the chat panel is ready before paste dispatch.", + "title": "Claude Code Cold Start Delay" + }, + "rangelink.destinations.claudeCode.coldRefocusIntervalMs": { + "type": "number", + "default": 300, + "minimum": 100, + "maximum": 5000, + "description": "Interval (ms) between successive focus-command re-sends during the Claude Code cold-start window.", + "title": "Claude Code Cold Re-focus Interval" + }, "rangelink.features.bookmarks.enabled": { "type": "boolean", "default": 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 48b4da5c..2a8b71ba 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 @@ -3141,6 +3141,42 @@ test_cases: command_to_run: 'pnpm test:release:with-extensions --grep "claude-code-005"' automated: assisted + - id: claude-code-006 + labels: + - requires-extensions + - cold-start + feature: 'Built-in AI Assistants' + scenario: 'Cold-start default settings produce a valid ColdRefocusConfig where totalMs > intervalMs' + preconditions: + - 'Claude Code extension (anthropic.claude-code) is installed and active' + - 'VS Code is running with the standard test:release:with-extensions configuration' + steps: + - 'Read the rangelink.destinations.claudeCode.coldStartDelayMs and coldRefocusIntervalMs defaults from VS Code configuration' + - 'Confirm coldStartDelayMs > coldRefocusIntervalMs' + expected_result: 'Default settings produce a valid ColdRefocusConfig. coldStartDelayMs is greater than coldRefocusIntervalMs.' + command_to_run: 'pnpm test:release:with-extensions --grep "claude-code-006"' + automated: true + + - id: claude-code-007 + labels: + - requires-extensions + - cold-start + - validation + feature: 'Built-in AI Assistants' + scenario: 'Cold-start validation rejects invalid config (totalMs <= intervalMs) and falls back to defaults with a warning' + preconditions: + - 'Claude Code extension (anthropic.claude-code) is installed and active' + - 'VS Code is running with the standard test:release:with-extensions configuration' + steps: + - 'Set rangelink.destinations.claudeCode.coldStartDelayMs to 100 and coldRefocusIntervalMs to 400 (invalid: delay <= interval)' + - 'Bind to Claude Code Chat to trigger getColdRefocus validation' + - 'Confirm a warning log is emitted about invalid cold-start config' + - 'Confirm the fallback defaults from coldStartDelayMs / coldRefocusIntervalMs are used instead of the invalid values' + - 'Restore original configuration values' + expected_result: 'Invalid config is rejected. Warning is logged. Defaults from coldStartDelayMs / coldRefocusIntervalMs are used as fallback.' + command_to_run: 'pnpm test:release:with-extensions --grep "claude-code-007"' + automated: true + - id: cursor-ai-003 labels: - clipboard diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/editorHelpers.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/editorHelpers.ts index 60578a0a..5550d424 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/editorHelpers.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/editorHelpers.ts @@ -35,6 +35,22 @@ export const clearEditorSelection = async (): Promise => { } }; +export const openUntitledDoc = async (options?: { + content?: string; + language?: string; + viewColumn?: vscode.ViewColumn; +}): Promise => { + const { + content = '', + language = 'plaintext', + viewColumn = vscode.ViewColumn.One, + } = options ?? {}; + const doc = await vscode.workspace.openTextDocument({ content, language }); + await vscode.window.showTextDocument(doc, viewColumn); + await settle(); + return doc; +}; + export const selectAll = (editor: vscode.TextEditor): void => { const lastLine = editor.document.lineCount - 1; const lastChar = editor.document.lineAt(lastLine).text.length; diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/index.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/index.ts index 333d89b7..a3250034 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/index.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/index.ts @@ -12,7 +12,12 @@ export { CLIPBOARD_SENTINEL, writeClipboardSentinel, } from './clipboardHelpers'; -export { clearEditorSelection, selectAll, waitForActiveEditor } from './editorHelpers'; +export { + clearEditorSelection, + openUntitledDoc, + selectAll, + waitForActiveEditor, +} from './editorHelpers'; export { cleanupFiles, closeAllEditors, diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts index 0c880b51..d861dc81 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts @@ -1,4 +1,11 @@ +import assert from 'node:assert'; + +import * as vscode from 'vscode'; + +import { CMD_UNBIND_DESTINATION } from '../../constants/commandIds'; + import { closeAllEditors } from './fileHelpers'; +import { getLogCapture } from './getLogCapture'; import { createLogger } from './logHelpers'; import { resetRangelinkSettings } from './settingsHelpers'; import { activateExtension, settle } from './testEnv'; @@ -12,11 +19,19 @@ export const standardSuite = (name: string, fn: (log: (msg: string) => void) => }); setup(async () => { + assert.ok( + getLogCapture().isCapturing, + 'RANGELINK_CAPTURE_LOGS must be true for toast assertions', + ); await resetRangelinkSettings(log); + await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); + await closeAllEditors(); await settle(); }); suiteTeardown(async () => { + await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); + await settle(); await closeAllEditors(); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/bindToDestination.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/bindToDestination.test.ts index 45a02384..87748407 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/bindToDestination.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/bindToDestination.test.ts @@ -29,12 +29,10 @@ standardSuite('R-D Bind to Destination', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand('rangelink.unbindDestination'); for (const t of terminals) { t.dispose(); } terminals.length = 0; - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); 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 16a4da17..4a0ed80e 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 @@ -7,15 +7,12 @@ import { CMD_BIND_TO_CLAUDE_CODE, CMD_BIND_TO_DESTINATION, CMD_COPY_LINK_RELATIVE, - CMD_UNBIND_DESTINATION, + CMD_JUMP_TO_DESTINATION, } from '../../constants/commandIds'; import { CLAUDE_CODE_EXTENSION_ID } from '../../utils/aiAssistants/isClaudeCodeAvailable'; import { - activateExtension, assertStatusBarMsgLogged, cleanupFiles, - closeAllEditors, - createLogger, createWorkspaceFile, extractQuickPickItemsLogged, getLogCapture, @@ -30,17 +27,10 @@ const AI_ASSISTANTS_GROUP_LABEL = 'AI Assistants'; const CLAUDE_CODE_DISPLAY_NAME = 'Claude Code Chat'; const CLAUDE_CODE_BIND_STATUS_BAR_MESSAGE = '✓ RangeLink bound to Claude Code Chat'; -suite('Built-in AI Assistants', () => { - const log = createLogger('builtInAiAssistants'); +standardSuite('Built-in AI Assistants', (log) => { const tmpFileUris: vscode.Uri[] = []; - suiteSetup(async () => { - await activateExtension(); - }); - teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); @@ -194,7 +184,10 @@ suite('Built-in AI Assistants', () => { 'Human reported the cold-send RangeLink did not appear in Claude Code chat', ); - // Select lines 3-4 for warm send + // Select lines 3-4 for warm send. Re-focus the editor first — the cold + // verdict dialog stole focus and the editor must be active for the send + // command to see the selection. + await vscode.window.showTextDocument(doc); editor.selection = new vscode.Selection(2, 0, 3, 6); await settle(); @@ -309,4 +302,65 @@ standardSuite('Built-in AI Assistants — Destination Picker', (log) => { log('✓ github-copilot-chat-001 — log confirms "GitHub Copilot Chat" appears in R-D picker'); }); + + test('claude-code-006: Cold-start default settings produce correct ColdRefocusConfig', async function (this: MochaContext) { + const config = vscode.workspace.getConfiguration('rangelink.destinations.claudeCode'); + const totalMs = config.get('coldStartDelayMs', 1500); + const intervalMs = config.get('coldRefocusIntervalMs', 300); + + assert.strictEqual(totalMs, 1500, 'Expected default coldStartDelayMs to be 1500'); + assert.strictEqual(intervalMs, 300, 'Expected default coldRefocusIntervalMs to be 300'); + assert.ok( + totalMs > intervalMs, + `Expected coldStartDelayMs (${totalMs}) > coldRefocusIntervalMs (${intervalMs})`, + ); + + log('✓ Default cold-start config produces valid ColdRefocusConfig'); + }); + + test('claude-code-007: Cold-start validation rejects invalid config and falls back to defaults', async function (this: MochaContext) { + if (!vscode.extensions.getExtension(CLAUDE_CODE_EXTENSION_ID)) { + log('Skipping claude-code-007 — Claude Code extension not installed in this test config'); + this.skip(); + } + + const config = vscode.workspace.getConfiguration('rangelink.destinations.claudeCode'); + + // Set delay <= interval so the validation rejects it (by default, delay > + // interval so the config is valid; flipping that relationship makes it invalid). + const INVALID_DELAY_MS = 100; + const INVALID_INTERVAL_MS = 400; + await config.update('coldStartDelayMs', INVALID_DELAY_MS, vscode.ConfigurationTarget.Workspace); + await config.update( + 'coldRefocusIntervalMs', + INVALID_INTERVAL_MS, + vscode.ConfigurationTarget.Workspace, + ); + await settle(); + + const logCapture = getLogCapture(); + logCapture.mark('before-cc-007'); + + // Validation lives inside getColdRefocus, which is a thunk only invoked + // during focus() — not at bind() time. CMD_JUMP_TO_DESTINATION triggers + // focusBoundDestination() → focus() → getColdRefocus() → validation warning. + await vscode.commands.executeCommand(CMD_BIND_TO_CLAUDE_CODE); + await settle(); + + await vscode.commands.executeCommand(CMD_JUMP_TO_DESTINATION); + await settle(); + + const lines = logCapture.getLinesSince('before-cc-007'); + const warningLog = lines.find( + (line) => + line.includes('coldStartDelayMs must be greater than coldRefocusIntervalMs') && + line.includes('using defaults'), + ); + assert.ok( + warningLog, + 'Expected validation warning log with "using defaults" when coldStartDelayMs <= coldRefocusIntervalMs', + ); + + log('✓ claude-code-007 — invalid config triggers fallback to defaults'); + }); }); 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 549ad16c..8516abe9 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 @@ -4,10 +4,10 @@ import * as vscode from 'vscode'; import { CMD_BIND_TO_TEXT_EDITOR_HERE, + CMD_BIND_TO_TERMINAL_HERE, CMD_COPY_LINK_RELATIVE, CMD_PASTE_CURRENT_FILE_PATH_RELATIVE, CMD_TERMINAL_PASTE_SELECTED_TEXT, - CMD_UNBIND_DESTINATION, } from '../../constants/commandIds'; import { VSCODE_CMD_TERMINAL_SELECT_ALL } from '../../constants/vscodeCommandIds'; import { @@ -16,7 +16,6 @@ import { assertTerminalBufferContains, type CapturingTerminal, cleanupFiles, - closeAllEditors, CLIPBOARD_SENTINEL, createAndBindCapturingTerminal, createTerminal, @@ -52,17 +51,14 @@ standardSuite('Clipboard Preservation', (_log) => { }); setup(async () => { + capturing.terminal.show(true); + await vscode.commands.executeCommand(CMD_BIND_TO_TERMINAL_HERE); + await settle(); editor = await vscode.window.showTextDocument(editor.document); await writeClipboardSentinel(); editor.selection = new vscode.Selection(new vscode.Position(0, 0), new vscode.Position(0, 7)); }); - teardown(async () => { - await vscode.workspace - .getConfiguration('rangelink') - .update('clipboard.preserve', undefined, vscode.ConfigurationTarget.Global); - }); - test('clipboard-preservation-003: R-F with preserve=always restores clipboard to sentinel after send', async () => { await vscode.workspace .getConfiguration('rangelink') @@ -123,10 +119,8 @@ standardSuite('Clipboard Preservation — Assisted', (log) => { const tmpTerminals: vscode.Terminal[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); await vscode.commands.executeCommand('dummyAi.clearAll'); for (const t of tmpTerminals.splice(0)) t.dispose(); - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.splice(0); await settle(); @@ -301,8 +295,6 @@ standardSuite('Clipboard Preservation — Assisted', (log) => { }); test('clipboard-preservation-009: always mode — dismissed picker leaves clipboard unchanged', async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`); const fileUri = createWorkspaceFile('cbp-009', lines.join('\n') + '\n'); tmpFileUris.push(fileUri); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorContent.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorContent.test.ts index 216b54c3..3f4898fb 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorContent.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorContent.test.ts @@ -3,7 +3,6 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; -import { CMD_UNBIND_DESTINATION } from '../../constants/commandIds'; import { assertClipboardWriteLogged, assertFilePathLogged, @@ -12,7 +11,6 @@ import { assertStatusBarMsgLogged, assertTerminalBufferContains, cleanupFiles, - closeAllEditors, createAndBindCapturingTerminal, createAndOpenFile, getLogCapture, @@ -57,12 +55,10 @@ standardSuite('Context Menus — Editor Content', (log) => { }); teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); for (const t of terminals) { t.dispose(); } terminals.length = 0; - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorTab.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorTab.test.ts index cfa6d042..dc470c06 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorTab.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorTab.test.ts @@ -2,7 +2,7 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; -import { CMD_BIND_TO_TERMINAL_HERE, CMD_UNBIND_DESTINATION } from '../../constants/commandIds'; +import { CMD_BIND_TO_TERMINAL_HERE } from '../../constants/commandIds'; import { assertClipboardWriteLogged, assertFilePathLogged, @@ -11,7 +11,6 @@ import { assertStatusBarMsgLogged, assertTerminalBufferEquals, cleanupFiles, - closeAllEditors, createAndOpenFile, createCapturingTerminal, getLogCapture, @@ -28,12 +27,10 @@ standardSuite('Context Menus — Editor Tab', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); for (const t of terminals) { t.dispose(); } terminals.length = 0; - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuExplorer.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuExplorer.test.ts index c653d94b..8fc38177 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuExplorer.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuExplorer.test.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; -import { CMD_BIND_TO_TERMINAL_HERE, CMD_UNBIND_DESTINATION } from '../../constants/commandIds'; +import { CMD_BIND_TO_TERMINAL_HERE } from '../../constants/commandIds'; import { assertClipboardWriteLogged, assertFilePathLogged, @@ -13,7 +13,6 @@ import { assertStatusBarMsgLogged, assertTerminalBufferEquals, cleanupFiles, - closeAllEditors, createAndOpenFile, createCapturingTerminal, getLogCapture, @@ -31,12 +30,10 @@ standardSuite('Context Menus — Explorer', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); for (const t of terminals) { t.dispose(); } terminals.length = 0; - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); @@ -186,9 +183,6 @@ standardSuite('Context Menus — Explorer', (log) => { const uri = await createAndOpenFile('ctxmenu-exp-005', FILE_CONTENT, undefined, tmpFileUris); const fn = path.basename(uri.fsPath); - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await settle(); - const logCapture = getLogCapture(); logCapture.mark('before-ctxmenu-exp-005'); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuTerminal.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuTerminal.test.ts index a3234148..f2f1fe63 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuTerminal.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuTerminal.test.ts @@ -6,7 +6,6 @@ import * as vscode from 'vscode'; import { CMD_BIND_TO_TERMINAL_HERE, CMD_CONTEXT_EDITOR_CONTENT_BIND, - CMD_UNBIND_DESTINATION, } from '../../constants/commandIds'; import { TERMINAL_READY_MS, @@ -14,7 +13,6 @@ import { assertStatusBarMsgLogged, assertTerminalBufferContains, cleanupFiles, - closeAllEditors, createAndBindCapturingTerminal, createAndOpenFile, createCapturingTerminal, @@ -34,12 +32,10 @@ standardSuite('Context Menus — Terminal', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); for (const t of terminals) { t.dispose(); } terminals.length = 0; - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); @@ -425,9 +421,6 @@ standardSuite('Context Menus — Terminal', (log) => { // --------------------------------------------------------------------------- test('[assisted] send-terminal-selection-011: "Send Selection" (unbound) opens destination picker and delivers to picked terminal', async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await settle(); - const sourceName = 'rl-sts-011-SOURCE'; const destName = 'rl-sts-011-DEST'; diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/coreSendCommands.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/coreSendCommands.test.ts index 92e43f21..2495eee6 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/coreSendCommands.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/coreSendCommands.test.ts @@ -10,7 +10,6 @@ import { CMD_COPY_PORTABLE_LINK_RELATIVE, CMD_PASTE_TO_DESTINATION, CMD_TERMINAL_PASTE_SELECTED_TEXT, - CMD_UNBIND_DESTINATION, } from '../../constants/commandIds'; import { assertClipboardChanged, @@ -21,7 +20,6 @@ import { assertToastLogged, type CapturingTerminal, cleanupFiles, - closeAllEditors, createAndBindCapturingTerminal, createWorkspaceFile, extractQuickPickItemsLogged, @@ -43,10 +41,8 @@ standardSuite('Core Send Commands', (log) => { const tmpTerminals: vscode.Terminal[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); await vscode.commands.executeCommand('dummyAi.clearAll'); for (const t of tmpTerminals.splice(0)) t.dispose(); - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.splice(0); await settle(); @@ -309,9 +305,6 @@ standardSuite('Core Send Commands', (log) => { test('[assisted] send-terminal-selection-004: R-V with no bound destination opens destination picker', async () => { const MARKER = 'rl-sts-004-marker'; - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await settle(); - const srcTerminal = vscode.window.createTerminal({ name: 'csc-sts-004-src' }); srcTerminal.show(true); tmpTerminals.push(srcTerminal); 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 328c897c..4973f7af 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 @@ -8,7 +8,6 @@ import { assertClipboardRestored, assertToastLogged, cleanupFiles, - closeAllEditors, createAndOpenFile, extractQuickPickItemsLogged, getLogCapture, @@ -267,9 +266,7 @@ standardSuite('Custom AI Assistants — Cold Start', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand('rangelink.unbindDestination'); await vscode.commands.executeCommand('dummyAi.clearAll'); - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); @@ -329,9 +326,7 @@ standardSuite('Custom AI Assistants — Paste Flow', (log) => { }); teardown(async () => { - await vscode.commands.executeCommand('rangelink.unbindDestination'); await vscode.commands.executeCommand('dummyAi.clearAll'); - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); 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 85a1222d..f4a9ef5c 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 @@ -2,11 +2,7 @@ import assert from 'node:assert'; import * as vscode from 'vscode'; -import { - CMD_COPY_LINK_ONLY_RELATIVE, - CMD_COPY_LINK_RELATIVE, - CMD_UNBIND_DESTINATION, -} from '../../constants/commandIds'; +import { CMD_COPY_LINK_ONLY_RELATIVE, CMD_COPY_LINK_RELATIVE } from '../../constants/commandIds'; import { assertClipboardRestored, assertNoToastLogged, @@ -38,13 +34,6 @@ standardSuite('Dirty Buffer Warning', (_log) => { cleanupFiles([testFileUri]); }); - teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await vscode.workspace - .getConfiguration('rangelink') - .update('warnOnDirtyBuffer', undefined, vscode.ConfigurationTarget.Workspace); - }); - test('dirty-buffer-warning-004: warnOnDirtyBuffer=false — R-C generates link without showing warning dialog', async () => { const editor = await openEditor(testFileUri); @@ -400,15 +389,6 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (log) => { cleanupFiles([testFileUri]); }); - teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await vscode.workspace - .getConfiguration('rangelink') - .update('warnOnDirtyBuffer', undefined, vscode.ConfigurationTarget.Workspace); - await closeAllEditors(); - await settle(); - }); - test('[assisted] dirty-buffer-warning-001: warnOnDirtyBuffer=true — R-L on dirty file shows warning dialog', async () => { const capturing = await createAndBindCapturingTerminal('dirty-buffer-test'); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/editorBindingValidation.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/editorBindingValidation.test.ts index e67c1151..1c16610a 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/editorBindingValidation.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/editorBindingValidation.test.ts @@ -4,10 +4,9 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; -import { CMD_BIND_TO_DESTINATION, CMD_UNBIND_DESTINATION } from '../../constants/commandIds'; +import { CMD_BIND_TO_DESTINATION } from '../../constants/commandIds'; import { cleanupFiles, - closeAllEditors, createWorkspaceFile, extractQuickPickItemsLogged, findTestItemsByPrefix, @@ -23,8 +22,6 @@ standardSuite('Editor Binding Validation', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await closeAllEditors(); cleanupFiles(tmpFileUris); await settle(); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/filePicker.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/filePicker.test.ts index a0d43c9e..eafe730f 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/filePicker.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/filePicker.test.ts @@ -7,7 +7,6 @@ import * as vscode from 'vscode'; import { CMD_BIND_TO_DESTINATION, CMD_OPEN_STATUS_BAR_MENU } from '../../constants/commandIds'; import { cleanupFiles, - closeAllEditors, createAndOpenFile, extractQuickPickItemsLogged, findTestItemsByPrefix, @@ -27,8 +26,6 @@ standardSuite('File Picker', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand('rangelink.unbindDestination'); - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/goToRangeLink.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/goToRangeLink.test.ts index 95f53192..896abd25 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/goToRangeLink.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/goToRangeLink.test.ts @@ -8,7 +8,6 @@ import { assertInputBoxLogged, assertToastLogged, cleanupFiles, - closeAllEditors, createAndOpenFile, extractQuickPickItemsLogged, getLogCapture, @@ -42,7 +41,6 @@ standardSuite('R-G Go to Link', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/linkGeneration.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/linkGeneration.test.ts index 12dc2630..21d227ce 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/linkGeneration.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/linkGeneration.test.ts @@ -6,7 +6,6 @@ import * as vscode from 'vscode'; import { cleanupFiles, - closeAllEditors, createAndOpenFile, createWorkspaceFile, openEditor, @@ -161,7 +160,6 @@ standardSuite('Link Generation — Clickable Links (Assisted)', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/navigationToastSettings.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/navigationToastSettings.test.ts index ae2c3d26..7d6ce15e 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/navigationToastSettings.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/navigationToastSettings.test.ts @@ -21,11 +21,6 @@ standardSuite('Navigation Toast Settings', (_log) => { let testFileUri: vscode.Uri; suiteSetup(async () => { - assert.ok( - getLogCapture().isCapturing, - 'RANGELINK_CAPTURE_LOGS must be true for toast assertions', - ); - const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1} content here`); testFileUri = createWorkspaceFile('toast settings', lines.join('\n') + '\n'); testFilename = path.basename(testFileUri.fsPath); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts index 2346df0f..cec9c8c3 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts @@ -5,7 +5,6 @@ import * as vscode from 'vscode'; import { CMD_BIND_TO_TEXT_EDITOR_HERE, CMD_PASTE_CURRENT_FILE_PATH_RELATIVE, - CMD_UNBIND_DESTINATION, } from '../../constants/commandIds'; import { assertFilePathLogged, @@ -14,7 +13,6 @@ import { assertTerminalBufferEquals, assertToastLogged, cleanupFiles, - closeAllEditors, createAndBindCapturingTerminal, createCapturingTerminal, createFileAt, @@ -33,13 +31,8 @@ standardSuite('Send File Path', (log) => { const tmpTerminals: vscode.Terminal[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); for (const t of tmpTerminals.splice(0)) t.dispose(); - await closeAllEditors(); cleanupFiles(tmpFileUris.splice(0)); - await vscode.workspace - .getConfiguration('rangelink') - .update('clipboard.preserve', undefined, vscode.ConfigurationTarget.Global); await settle(); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/smartPadding.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/smartPadding.test.ts index 3855ff80..156f6971 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/smartPadding.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/smartPadding.test.ts @@ -6,8 +6,8 @@ import * as vscode from 'vscode'; import { cleanupFiles, - closeAllEditors, getWorkspaceRoot, + openUntitledDoc, settle, standardSuite, waitForActiveEditor, @@ -22,9 +22,6 @@ standardSuite('Smart Padding — Editor-to-Editor R-V', (log) => { }); setup(async () => { - log('setup: unbinding any stale destination'); - await vscode.commands.executeCommand('rangelink.unbindDestination'); - const ts = Date.now(); const sourcePath = path.join(getWorkspaceRoot(), `__rl-test-sp-source-${ts}.txt`); const destPath = path.join(getWorkspaceRoot(), `__rl-test-sp-dest-${ts}.txt`); @@ -38,9 +35,6 @@ standardSuite('Smart Padding — Editor-to-Editor R-V', (log) => { }); teardown(async () => { - log('teardown: unbinding + closing editors'); - await vscode.commands.executeCommand('rangelink.unbindDestination'); - await closeAllEditors(); cleanupFiles([sourceFileUri, destFileUri]); log('teardown: complete'); }); @@ -84,8 +78,7 @@ standardSuite('Smart Padding — Editor-to-Editor R-V', (log) => { await vscode.window.showTextDocument(sourceDoc, vscode.ViewColumn.One); log('setupUntitledEditorPair: creating untitled dest in ViewColumn.Two'); - const destDoc = await vscode.workspace.openTextDocument({ content: '', language: 'plaintext' }); - await vscode.window.showTextDocument(destDoc, vscode.ViewColumn.Two); + const destDoc = await openUntitledDoc({ viewColumn: vscode.ViewColumn.Two }); log( `setupUntitledEditorPair: dest scheme=${destDoc.uri.scheme}, uri=${destDoc.uri.toString()}`, ); @@ -317,8 +310,7 @@ standardSuite('Smart Padding — Editor-to-Editor R-V', (log) => { fs.writeFileSync(sourceFileUri.fsPath, sourceContent, 'utf8'); - const destDoc = await vscode.workspace.openTextDocument({ content: '', language: 'plaintext' }); - await vscode.window.showTextDocument(destDoc, vscode.ViewColumn.Two); + const destDoc = await openUntitledDoc({ viewColumn: vscode.ViewColumn.Two }); const originalUri = destDoc.uri.toString(); const originalLanguage = destDoc.languageId; log(`langswitch: dest uri=${originalUri}, language=${originalLanguage}`); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/statusBarMenu.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/statusBarMenu.test.ts index 115eb461..ece0650f 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/statusBarMenu.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/statusBarMenu.test.ts @@ -2,11 +2,7 @@ import assert from 'node:assert'; import * as vscode from 'vscode'; -import { - CMD_BIND_TO_TERMINAL_HERE, - CMD_OPEN_STATUS_BAR_MENU, - CMD_UNBIND_DESTINATION, -} from '../../constants/commandIds'; +import { CMD_BIND_TO_TERMINAL_HERE, CMD_OPEN_STATUS_BAR_MENU } from '../../constants/commandIds'; import { assertQuickPickItemsLogged, cleanupFiles, @@ -133,7 +129,6 @@ standardSuite('R-M Status Bar Menu', (log) => { log('✓ Bound-state menu items validated via log capture'); } finally { - await vscode.commands.executeCommand('rangelink.unbindDestination'); terminal.dispose(); } }); @@ -154,9 +149,6 @@ standardSuite('R-M Status Bar Menu', (log) => { }); test('status-bar-menu-006: R-M menu shows destination picker items when no destination is bound', async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await settle(); - const logCapture = getLogCapture(); logCapture.mark('before-006'); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/terminalPicker.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/terminalPicker.test.ts index 7323dbc5..f73c4d95 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/terminalPicker.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/terminalPicker.test.ts @@ -28,12 +28,10 @@ standardSuite('Terminal Picker', (log) => { const terminals: vscode.Terminal[] = []; teardown(async () => { - await vscode.commands.executeCommand('rangelink.unbindDestination'); for (const t of terminals) { t.dispose(); } terminals.length = 0; - await closeAllEditors(); await settle(); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts index cfbf63fc..8178e182 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts @@ -2,16 +2,11 @@ import assert from 'node:assert'; import * as vscode from 'vscode'; -import { - CMD_BIND_TO_TEXT_EDITOR_HERE, - CMD_COPY_LINK_RELATIVE, - CMD_UNBIND_DESTINATION, -} from '../../constants/commandIds'; +import { CMD_BIND_TO_TEXT_EDITOR_HERE, CMD_COPY_LINK_RELATIVE } from '../../constants/commandIds'; import { assertNoToastLogged, assertToastLogged, cleanupFiles, - closeAllEditors, createWorkspaceFile, getLogCapture, settle, @@ -23,8 +18,6 @@ standardSuite('Text Editor Destination', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await closeAllEditors(); cleanupFiles(tmpFileUris); await settle(); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/untitledNavigation.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/untitledNavigation.test.ts index 2de33bb8..b9d179dd 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/untitledNavigation.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/untitledNavigation.test.ts @@ -10,6 +10,7 @@ import { assertToastLogged, clearEditorSelection, getLogCapture, + openUntitledDoc, settle, standardSuite, } from '../helpers'; @@ -70,27 +71,20 @@ const navigateToUntitledLink = ( }); }; +const UNTITLED_CONTENT = Array.from( + { length: 15 }, + (_, i) => `untitled line ${i + 1} content here`, +).join('\n'); + standardSuite('Untitled File Navigation', (_log) => { let untitledDoc: vscode.TextDocument; let untitledDisplayName: string; - suiteSetup(async () => { - assert.ok( - getLogCapture().isCapturing, - 'RANGELINK_CAPTURE_LOGS must be true for toast assertions', - ); - - untitledDoc = await vscode.workspace.openTextDocument({ - content: Array.from({ length: 15 }, (_, i) => `untitled line ${i + 1} content here`).join( - '\n', - ), - language: 'plaintext', - }); - await vscode.window.showTextDocument(untitledDoc, vscode.ViewColumn.One); - + setup(async () => { + untitledDoc = await openUntitledDoc({ content: UNTITLED_CONTENT }); assert.strictEqual(untitledDoc.uri.scheme, 'untitled', 'Expected untitled document'); - untitledDisplayName = getUntitledDisplayName(untitledDoc.uri); + await settle(); }); // untitled-navigation-001: Navigate to single line in untitled file 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 6ab2e8be..67b2b9e3 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/constants/packageJsonContracts.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/constants/packageJsonContracts.test.ts @@ -726,8 +726,36 @@ describe('package.json contributions', () => { }); }); + describe('destination settings', () => { + it('rangelink.destinations.claudeCode.coldStartDelayMs', () => { + expect(properties['rangelink.destinations.claudeCode.coldStartDelayMs']).toStrictEqual({ + type: 'number', + default: 1500, + minimum: 500, + maximum: 15000, + description: + 'Total duration (ms) of the cold-start re-focus window for Claude Code. During this window, focus commands are re-sent at the coldRefocusIntervalMs cadence to ensure the chat panel is ready before paste dispatch.', + title: 'Claude Code Cold Start Delay', + }); + }); + + it('rangelink.destinations.claudeCode.coldRefocusIntervalMs', () => { + expect(properties['rangelink.destinations.claudeCode.coldRefocusIntervalMs']).toStrictEqual( + { + type: 'number', + default: 300, + minimum: 100, + maximum: 5000, + description: + 'Interval (ms) between successive focus-command re-sends during the Claude Code cold-start window.', + title: 'Claude Code Cold Re-focus Interval', + }, + ); + }); + }); + it('has the expected number of configuration properties', () => { - expect(Object.keys(properties)).toHaveLength(15); + expect(Object.keys(properties)).toHaveLength(17); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/constants/settingDefaults.test.ts b/packages/rangelink-vscode-extension/src/__tests__/constants/settingDefaults.test.ts index ab661906..826085a9 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/constants/settingDefaults.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/constants/settingDefaults.test.ts @@ -3,6 +3,8 @@ import { DEFAULT_DELIMITER_LINE, DEFAULT_DELIMITER_POSITION, DEFAULT_DELIMITER_RANGE, + DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS, + DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS, DEFAULT_SMART_PADDING_PASTE_BOOKMARK, DEFAULT_SMART_PADDING_PASTE_CONTENT, DEFAULT_SMART_PADDING_PASTE_LINK, @@ -46,4 +48,14 @@ describe('settingDefaults', () => { expect(DEFAULT_SMART_PADDING_PASTE_LINK).toBe('both'); }); }); + + describe('destination defaults', () => { + it('DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS is 1500', () => { + expect(DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS).toBe(1500); + }); + + it('DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS is 300', () => { + expect(DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS).toBe(300); + }); + }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts index 423f2598..41710fae 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts @@ -139,6 +139,7 @@ describe('ComposablePasteDestination Integration Tests', () => { const focusCapability = new AIAssistantFocusCapability( mockAdapter, ['ai.assistant.focus'], + undefined, insertFactory, mockLogger, ); @@ -188,6 +189,7 @@ describe('ComposablePasteDestination Integration Tests', () => { const focusCapability = new AIAssistantFocusCapability( mockAdapter, ['command.first', 'command.second', 'command.third'], + undefined, insertFactory, mockLogger, ); @@ -236,6 +238,7 @@ describe('ComposablePasteDestination Integration Tests', () => { const focusCapability = new AIAssistantFocusCapability( mockAdapter, ['command.first', 'command.second'], + undefined, insertFactory, mockLogger, ); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/DestinationRegistry.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/DestinationRegistry.test.ts index 66e15e29..487a21b9 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/DestinationRegistry.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/DestinationRegistry.test.ts @@ -8,6 +8,7 @@ import { import type { BindOptions } from '../../types'; import { createBaseMockPasteDestination, + createMockConfigReader, createMockEligibilityCheckerFactory, createMockFocusCapabilityFactory, createMockUri, @@ -17,6 +18,7 @@ import { describe('DestinationRegistry', () => { const mockLogger = createMockLogger(); const mockAdapter = createMockVscodeAdapter(); + const mockConfigReader = createMockConfigReader(); const createMockFactories = () => ({ focusCapability: createMockFocusCapabilityFactory(), @@ -28,6 +30,7 @@ describe('DestinationRegistry', () => { factories.focusCapability, factories.eligibilityChecker, mockAdapter, + mockConfigReader, mockLogger, ); @@ -96,6 +99,7 @@ describe('DestinationRegistry', () => { eligibilityChecker: factories.eligibilityChecker, }, ideAdapter: mockAdapter, + configReader: mockConfigReader, logger: mockLogger, }); }); @@ -228,16 +232,20 @@ describe('DestinationRegistry', () => { let capturedCapability: unknown; const builder: DestinationBuilder = (_options, context) => { - capturedCapability = context.factories.focusCapability.createAIAssistantCapability([ - 'focus', - ]); + capturedCapability = context.factories.focusCapability.createAIAssistantCapability( + ['focus'], + undefined, + ); return createBaseMockPasteDestination({ id: 'terminal' }); }; registry.register('terminal', builder); registry.create({ kind: 'terminal', terminal: {} as never }); expect(capturedCapability).toBe(mockCapability); - expect(factories.focusCapability.createAIAssistantCapability).toHaveBeenCalledWith(['focus']); + expect(factories.focusCapability.createAIAssistantCapability).toHaveBeenCalledWith( + ['focus'], + undefined, + ); }); it('should allow mocking EligibilityCheckerFactory methods in builder', () => { @@ -315,9 +323,10 @@ describe('DestinationRegistry', () => { const registry = createRegistry(factories); const builder: DestinationBuilder = (_options, context) => { - const capability = context.factories.focusCapability.createAIAssistantCapability([ - 'focus.cmd', - ]); + const capability = context.factories.focusCapability.createAIAssistantCapability( + ['focus.cmd'], + undefined, + ); const checker = context.factories.eligibilityChecker.createContentEligibilityChecker(); expect(capability).toBe(mockCapability); @@ -330,9 +339,10 @@ describe('DestinationRegistry', () => { const destination = registry.create({ kind: 'cursor-ai' }); expect(destination).toBeDefined(); - expect(factories.focusCapability.createAIAssistantCapability).toHaveBeenCalledWith([ - 'focus.cmd', - ]); + expect(factories.focusCapability.createAIAssistantCapability).toHaveBeenCalledWith( + ['focus.cmd'], + undefined, + ); expect(factories.eligibilityChecker.createContentEligibilityChecker).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/AIAssistantFocusCapability.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/AIAssistantFocusCapability.test.ts new file mode 100644 index 00000000..af57b2fe --- /dev/null +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/AIAssistantFocusCapability.test.ts @@ -0,0 +1,226 @@ +import type { LoggingContext } from 'barebone-logger'; +import { createMockLogger } from 'barebone-logger-testing'; + +import { AIAssistantFocusCapability } from '../../../destinations/capabilities/AIAssistantFocusCapability'; +import type { ColdRefocusConfig } from '../../../destinations/capabilities/ColdRefocusConfig'; +import type { FocusResult } from '../../../destinations/capabilities/FocusCapability'; +import type { InsertFactory } from '../../../destinations/capabilities/insertFactories'; +import { createMockVscodeAdapter } from '../../helpers'; + +const FOCUS_COMMANDS = ['ai.focus']; +const CTX: LoggingContext = { fn: 'test' }; + +const createMockInsertFactory = (): jest.Mocked> => ({ + forTarget: jest.fn().mockReturnValue(undefined), +}); + +describe('AIAssistantFocusCapability', () => { + let mockAdapter: ReturnType; + let mockLogger: ReturnType; + let mockInsertFactory: jest.Mocked>; + + beforeEach(() => { + jest.useFakeTimers(); + mockAdapter = createMockVscodeAdapter(); + mockLogger = createMockLogger(); + mockInsertFactory = createMockInsertFactory(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const createCapability = ( + commands: string[] = FOCUS_COMMANDS, + getColdRefocus?: () => ColdRefocusConfig, + ): AIAssistantFocusCapability => + new AIAssistantFocusCapability( + mockAdapter, + commands, + getColdRefocus, + mockInsertFactory, + mockLogger, + ); + + it('succeeds on first focus command and returns inserter', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const capability = createCapability(); + const focusPromise = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + const result = await focusPromise; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + expect(mockAdapter.executeCommand).toHaveBeenCalledWith('ai.focus'); + expect(mockLogger.debug).toHaveBeenCalledWith( + { fn: 'test', command: 'ai.focus' }, + 'Focus command succeeded', + ); + }); + + it('falls back through command list until one succeeds', async () => { + jest + .spyOn(mockAdapter, 'executeCommand') + .mockRejectedValueOnce(new Error('first failed')) + .mockResolvedValueOnce(undefined); + const capability = createCapability(['cmd.a', 'cmd.b', 'cmd.c']); + const focusPromise = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + const result = await focusPromise; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + expect(mockAdapter.executeCommand).toHaveBeenCalledTimes(2); + expect(mockAdapter.executeCommand).toHaveBeenNthCalledWith(1, 'cmd.a'); + expect(mockAdapter.executeCommand).toHaveBeenNthCalledWith(2, 'cmd.b'); + }); + + it('returns error when all focus commands fail', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockRejectedValue(new Error('all failed')); + const capability = createCapability(['cmd.a', 'cmd.b']); + const result = await capability.focus(CTX); + + expect(result).toBeErrWith((error: FocusResult['error']) => { + expect(error.reason).toBe('COMMAND_FOCUS_FAILED'); + }); + expect(mockLogger.warn).toHaveBeenCalledWith( + { fn: 'test', allCommandsFailed: true }, + 'All focus commands failed', + ); + }); + + it('waits FOCUS_TO_PASTE_DELAY_MS when no coldRefocus configured (warm delay)', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const capability = createCapability(); + + const focusPromise = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + const result = await focusPromise; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + }); + + it('waits FOCUS_TO_PASTE_DELAY_MS on second focus after cold-start (warm)', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const coldRefocus = (): ColdRefocusConfig => ({ totalMs: 900, intervalMs: 300 }); + const capability = createCapability(FOCUS_COMMANDS, coldRefocus); + + const firstFocus = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(900); + await firstFocus; + + const secondFocus = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + const result = await secondFocus; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + }); + + it('re-fires focus commands at each interval during cold-start', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const coldRefocus = (): ColdRefocusConfig => ({ totalMs: 900, intervalMs: 300 }); + const capability = createCapability(FOCUS_COMMANDS, coldRefocus); + + const focusPromise = capability.focus(CTX); + + await jest.advanceTimersByTimeAsync(900); + const result = await focusPromise; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + expect(mockAdapter.executeCommand).toHaveBeenCalledTimes(3); + }); + + it('does not refocus on warm path even with coldRefocus configured', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const coldRefocus = (): ColdRefocusConfig => ({ totalMs: 900, intervalMs: 300 }); + const capability = createCapability(FOCUS_COMMANDS, coldRefocus); + + const firstFocus = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(900); + await firstFocus; + + (mockAdapter.executeCommand as jest.Mock).mockClear(); + + const secondFocus = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + await secondFocus; + + expect(mockAdapter.executeCommand).toHaveBeenCalledTimes(1); + }); + + it('logs elapsed time after cold refocus loop', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const coldRefocus = (): ColdRefocusConfig => ({ totalMs: 900, intervalMs: 300 }); + const capability = createCapability(FOCUS_COMMANDS, coldRefocus); + + const focusPromise = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(900); + await focusPromise; + + expect(mockLogger.debug).toHaveBeenCalledWith( + { fn: 'test', totalMs: expect.any(Number) as number, intervalMs: 300 }, + 'Cold refocus loop completed', + ); + }); + + it('falls back to warm delay when intervalMs is 0', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const coldRefocus = (): ColdRefocusConfig => ({ totalMs: 2500, intervalMs: 0 }); + const capability = createCapability(FOCUS_COMMANDS, coldRefocus); + + const focusPromise = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + const result = await focusPromise; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + expect(mockLogger.warn).toHaveBeenCalledWith( + { fn: 'test', totalMs: 2500, intervalMs: 0 }, + 'Invalid cold refocus config, falling back to warm delay', + ); + expect(mockAdapter.executeCommand).toHaveBeenCalledTimes(1); + }); + + it('falls back to warm delay when totalMs is 0', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const coldRefocus = (): ColdRefocusConfig => ({ totalMs: 0, intervalMs: 300 }); + const capability = createCapability(FOCUS_COMMANDS, coldRefocus); + + const focusPromise = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + const result = await focusPromise; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + expect(mockAdapter.executeCommand).toHaveBeenCalledTimes(1); + }); + + it('falls back to warm delay when totalMs <= intervalMs (positive-but-invalid)', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const coldRefocus = (): ColdRefocusConfig => ({ totalMs: 300, intervalMs: 300 }); + const capability = createCapability(FOCUS_COMMANDS, coldRefocus); + + const focusPromise = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + const result = await focusPromise; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + expect(mockLogger.warn).toHaveBeenCalledWith( + { fn: 'test', totalMs: 300, intervalMs: 300 }, + 'Invalid cold refocus config, falling back to warm delay', + ); + expect(mockAdapter.executeCommand).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/FocusCapabilityFactory.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/FocusCapabilityFactory.test.ts index 2cf2d6f6..e2ca82d4 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/FocusCapabilityFactory.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/FocusCapabilityFactory.test.ts @@ -32,7 +32,10 @@ describe('FocusCapabilityFactory', () => { }); it('creates AIAssistantFocusCapability', () => { - const capability = factory.createAIAssistantCapability(['workbench.action.chat.open']); + const capability = factory.createAIAssistantCapability( + ['workbench.action.chat.open'], + undefined, + ); expect(capability).toBeInstanceOf(AIAssistantFocusCapability); }); 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 af338761..de560eaf 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts @@ -9,9 +9,9 @@ import { type DestinationBuilder, registerAllDestinationBuilders, } from '../../destinations'; -import { AutoPasteResult } from '../../types'; -import type { DestinationKind } from '../../types'; +import { AutoPasteResult, type DestinationKind } from '../../types'; import { + createMockConfigReader, createMockDocument, createMockEditor, createMockEligibilityCheckerFactory, @@ -35,6 +35,7 @@ describe('destinationBuilders', () => { eligibilityChecker: createMockEligibilityCheckerFactory(), }, ideAdapter: createMockVscodeAdapter(adapterOverrides), + configReader: createMockConfigReader(), logger: mockLogger, }); @@ -730,4 +731,89 @@ describe('destinationBuilders', () => { expect(builders.has('custom-ai:anthropic.claude-code')).toBe(false); }); }); + + describe('getColdRefocus', () => { + it('provides a getColdRefocus function for Claude Code', () => { + const builder = getBuiltinBuilder('claude-code'); + const context = createMockContext(); + builder({ kind: 'claude-code' }, context); + + const createCapabilityMock = context.factories.focusCapability + .createAIAssistantCapability as jest.Mock; + expect(createCapabilityMock).toHaveBeenCalledTimes(1); + const [capabilities, getColdRefocusArg] = createCapabilityMock.mock.calls[0]; + expect(capabilities).toStrictEqual([ + 'claude-vscode.focus', + 'claude-vscode.sidebar.open', + 'claude-vscode.editor.open', + ]); + expect(typeof getColdRefocusArg).toBe('function'); + + const result = getColdRefocusArg(); + expect(result).toStrictEqual({ + totalMs: 1500, + intervalMs: 300, + }); + }); + + it('does not provide getColdRefocus for non-Claude-Code assistants', () => { + const builder = getBuiltinBuilder('cursor-ai'); + const context = createMockContext(); + builder({ kind: 'cursor-ai' }, context); + + const createCapabilityMock = context.factories.focusCapability + .createAIAssistantCapability as jest.Mock; + expect(createCapabilityMock).toHaveBeenCalledTimes(1); + const [, getColdRefocusArg] = createCapabilityMock.mock.calls[0]; + expect(getColdRefocusArg).toBeUndefined(); + }); + + it('uses config values from settings when valid', () => { + const builder = getBuiltinBuilder('claude-code'); + const context = createMockContext(); + context.configReader.getWithDefault = jest.fn().mockImplementation((_key: string) => { + if (_key === 'destinations.claudeCode.coldStartDelayMs') return 5000; + if (_key === 'destinations.claudeCode.coldRefocusIntervalMs') return 500; + return undefined; + }); + + builder({ kind: 'claude-code' }, context); + const createCapabilityMock = context.factories.focusCapability + .createAIAssistantCapability as jest.Mock; + expect(createCapabilityMock).toHaveBeenCalledTimes(1); + const [, getColdRefocusFn] = createCapabilityMock.mock.calls[0]; + + const result = getColdRefocusFn(); + expect(result).toStrictEqual({ + totalMs: 5000, + intervalMs: 500, + }); + }); + + it('falls back to defaults when config values are invalid (totalMs <= intervalMs)', () => { + const builder = getBuiltinBuilder('claude-code'); + const context = createMockContext(); + context.configReader.getWithDefault = jest.fn().mockImplementation((_key: string) => { + if (_key === 'destinations.claudeCode.coldStartDelayMs') return 100; + if (_key === 'destinations.claudeCode.coldRefocusIntervalMs') return 500; + return undefined; + }); + + builder({ kind: 'claude-code' }, context); + const createCapabilityMock = context.factories.focusCapability + .createAIAssistantCapability as jest.Mock; + expect(createCapabilityMock).toHaveBeenCalledTimes(1); + const [, getColdRefocusFn] = createCapabilityMock.mock.calls[0]; + + const result = getColdRefocusFn(); + expect(result).toStrictEqual({ + totalMs: 1500, + intervalMs: 300, + }); + expect(context.logger.warn).toHaveBeenCalledWith( + { fn: 'claudeCode.getColdRefocus', totalMs: 100, intervalMs: 500 }, + 'coldStartDelayMs must be greater than coldRefocusIntervalMs, using defaults', + ); + }); + }); }); diff --git a/packages/rangelink-vscode-extension/src/constants/settingDefaults.ts b/packages/rangelink-vscode-extension/src/constants/settingDefaults.ts index 04594cc3..e660334a 100644 --- a/packages/rangelink-vscode-extension/src/constants/settingDefaults.ts +++ b/packages/rangelink-vscode-extension/src/constants/settingDefaults.ts @@ -31,6 +31,13 @@ export const DEFAULT_DELIMITER_LINE = DEFAULT_DELIMITERS.line; export const DEFAULT_DELIMITER_POSITION = DEFAULT_DELIMITERS.position; export const DEFAULT_DELIMITER_RANGE = DEFAULT_DELIMITERS.range; +// ============================================================================= +// Destination Defaults +// ============================================================================= + +export const DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS = 300; +export const DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS = 1500; + // ============================================================================= // Navigation Defaults // ============================================================================= diff --git a/packages/rangelink-vscode-extension/src/constants/settingKeys.ts b/packages/rangelink-vscode-extension/src/constants/settingKeys.ts index df775cb1..9908faf3 100644 --- a/packages/rangelink-vscode-extension/src/constants/settingKeys.ts +++ b/packages/rangelink-vscode-extension/src/constants/settingKeys.ts @@ -17,6 +17,17 @@ export const SETTING_NAMESPACE = 'rangelink'; export const SETTING_CLIPBOARD_PRESERVE = 'clipboard.preserve'; +// ============================================================================= +// Destination Settings +// ============================================================================= + +export const SETTINGS_DESTINATIONS_PREFIX = 'destinations.'; + +export const SETTING_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS = + 'destinations.claudeCode.coldRefocusIntervalMs'; +export const SETTING_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS = + 'destinations.claudeCode.coldStartDelayMs'; + // ============================================================================= // Feature Flags — TODO: #366 remove when bookmarks graduates from beta // ============================================================================= diff --git a/packages/rangelink-vscode-extension/src/createWiringServices.ts b/packages/rangelink-vscode-extension/src/createWiringServices.ts index 8d689018..971a8937 100644 --- a/packages/rangelink-vscode-extension/src/createWiringServices.ts +++ b/packages/rangelink-vscode-extension/src/createWiringServices.ts @@ -98,6 +98,7 @@ export const createWiringServices = ( focusCapabilityFactory, eligibilityCheckerFactory, ideAdapter, + configReader, logger, ); const customAssistants = parseCustomAiAssistants(configReader, logger); diff --git a/packages/rangelink-vscode-extension/src/destinations/DestinationRegistry.ts b/packages/rangelink-vscode-extension/src/destinations/DestinationRegistry.ts index fde5073e..033dfb41 100644 --- a/packages/rangelink-vscode-extension/src/destinations/DestinationRegistry.ts +++ b/packages/rangelink-vscode-extension/src/destinations/DestinationRegistry.ts @@ -1,5 +1,6 @@ import type { Logger } from 'barebone-logger'; +import type { ConfigReader } from '../config/ConfigReader'; import { RangeLinkExtensionError, RangeLinkExtensionErrorCodes } from '../errors'; import type { VscodeAdapter } from '../ide/vscode/VscodeAdapter'; import { MessageCode } from '../types'; @@ -44,6 +45,7 @@ export interface DestinationBuilderFactories { export interface DestinationBuilderContext { readonly factories: DestinationBuilderFactories; readonly ideAdapter: VscodeAdapter; + readonly configReader: ConfigReader; readonly logger: Logger; } @@ -84,6 +86,7 @@ export class DestinationRegistry { focusCapabilityFactory: FocusCapabilityFactory, eligibilityCheckerFactory: EligibilityCheckerFactory, ideAdapter: VscodeAdapter, + configReader: ConfigReader, logger: Logger, ) { this.context = { @@ -92,6 +95,7 @@ export class DestinationRegistry { eligibilityChecker: eligibilityCheckerFactory, }, ideAdapter, + configReader, logger, }; } diff --git a/packages/rangelink-vscode-extension/src/destinations/capabilities/AIAssistantFocusCapability.ts b/packages/rangelink-vscode-extension/src/destinations/capabilities/AIAssistantFocusCapability.ts index 106fdc0c..b57eec82 100644 --- a/packages/rangelink-vscode-extension/src/destinations/capabilities/AIAssistantFocusCapability.ts +++ b/packages/rangelink-vscode-extension/src/destinations/capabilities/AIAssistantFocusCapability.ts @@ -1,8 +1,10 @@ import type { Logger, LoggingContext } from 'barebone-logger'; import { Result } from 'rangelink-core-ts'; +import { FOCUS_TO_PASTE_DELAY_MS } from '../../constants/aiAssistantPasteConstants'; import type { VscodeAdapter } from '../../ide/vscode/VscodeAdapter'; +import type { ColdRefocusConfig } from './ColdRefocusConfig'; import { FocusErrorReason, type FocusCapability, type FocusResult } from './FocusCapability'; import type { InsertFactory } from './insertFactories'; @@ -10,14 +12,19 @@ import type { InsertFactory } from './insertFactories'; * FocusCapability for AI assistant destinations. * * Executes focus commands to open the AI assistant panel. + * On cold start (first focus after bind), re-fires focus commands + * at intervals to keep the panel open while it initializes. * Uses InsertFactory injection for decoupled clipboard-based paste. * * Used by: Claude Code, Cursor AI, GitHub Copilot Chat */ export class AIAssistantFocusCapability implements FocusCapability { + private panelIsWarm = false; + constructor( private readonly ideAdapter: VscodeAdapter, private readonly focusCommands: string[], + private readonly getColdRefocus: (() => ColdRefocusConfig) | undefined, private readonly insertFactory: InsertFactory, private readonly logger: Logger, ) {} @@ -28,6 +35,16 @@ export class AIAssistantFocusCapability implements FocusCapability { await this.ideAdapter.executeCommand(command); this.logger.debug({ ...context, command }, 'Focus command succeeded'); + const coldRefocus = this.getColdRefocus?.(); + + if (!this.panelIsWarm && coldRefocus) { + await this.refocusDuring(context, coldRefocus); + } else { + await new Promise((resolve) => setTimeout(resolve, FOCUS_TO_PASTE_DELAY_MS)); + } + + this.panelIsWarm = true; + return Result.ok({ inserter: this.insertFactory.forTarget(), }); @@ -41,4 +58,42 @@ export class AIAssistantFocusCapability implements FocusCapability { reason: FocusErrorReason.COMMAND_FOCUS_FAILED, }); } + + private async refocusDuring(context: LoggingContext, refocus: ColdRefocusConfig): Promise { + if (refocus.totalMs <= 0 || refocus.intervalMs <= 0 || refocus.totalMs <= refocus.intervalMs) { + this.logger.warn( + { ...context, totalMs: refocus.totalMs, intervalMs: refocus.intervalMs }, + 'Invalid cold refocus config, falling back to warm delay', + ); + await new Promise((resolve) => setTimeout(resolve, FOCUS_TO_PASTE_DELAY_MS)); + return; + } + + const start = Date.now(); + let elapsed = 0; + + while (elapsed < refocus.totalMs) { + const waitMs = Math.min(refocus.intervalMs, refocus.totalMs - elapsed); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + elapsed += waitMs; + + if (elapsed >= refocus.totalMs) { + break; + } + + for (const command of this.focusCommands) { + try { + await this.ideAdapter.executeCommand(command); + break; + } catch { + // try next command + } + } + } + + this.logger.debug( + { ...context, totalMs: Date.now() - start, intervalMs: refocus.intervalMs }, + 'Cold refocus loop completed', + ); + } } diff --git a/packages/rangelink-vscode-extension/src/destinations/capabilities/ColdRefocusConfig.ts b/packages/rangelink-vscode-extension/src/destinations/capabilities/ColdRefocusConfig.ts new file mode 100644 index 00000000..79359b1b --- /dev/null +++ b/packages/rangelink-vscode-extension/src/destinations/capabilities/ColdRefocusConfig.ts @@ -0,0 +1,14 @@ +/** + * Configuration for the cold-start re-focus loop. + * + * When an AI assistant's chat panel is first opened (cold start), the Webview's + * IPC clipboard reader may not be ready. The re-focus loop re-sends focus commands + * at `intervalMs` ticks for up to `totalMs`, giving the panel time to initialize + * before paste dispatch. + */ +export interface ColdRefocusConfig { + /** Total duration of the re-focus window in milliseconds. */ + readonly totalMs: number; + /** Interval between successive focus-command sends within the window. */ + readonly intervalMs: number; +} diff --git a/packages/rangelink-vscode-extension/src/destinations/capabilities/FocusCapabilityFactory.ts b/packages/rangelink-vscode-extension/src/destinations/capabilities/FocusCapabilityFactory.ts index dd5d258d..39c11ba2 100644 --- a/packages/rangelink-vscode-extension/src/destinations/capabilities/FocusCapabilityFactory.ts +++ b/packages/rangelink-vscode-extension/src/destinations/capabilities/FocusCapabilityFactory.ts @@ -6,6 +6,7 @@ import type { VscodeAdapter } from '../../ide/vscode/VscodeAdapter'; import type { FocusTier } from '../types'; import { AIAssistantFocusCapability } from './AIAssistantFocusCapability'; +import type { ColdRefocusConfig } from './ColdRefocusConfig'; import { EditorFocusCapability } from './EditorFocusCapability'; import type { FocusCapability } from './FocusCapability'; import { @@ -49,10 +50,14 @@ export class FocusCapabilityFactory { ); } - createAIAssistantCapability(focusCommands: string[]): FocusCapability { + createAIAssistantCapability( + focusCommands: string[], + getColdRefocus: (() => ColdRefocusConfig) | undefined, + ): FocusCapability { return new AIAssistantFocusCapability( this.ideAdapter, focusCommands, + getColdRefocus, new AIAssistantInsertFactory(this.ideAdapter, this.logger), this.logger, ); diff --git a/packages/rangelink-vscode-extension/src/destinations/capabilities/index.ts b/packages/rangelink-vscode-extension/src/destinations/capabilities/index.ts index a39fe997..9f052d79 100644 --- a/packages/rangelink-vscode-extension/src/destinations/capabilities/index.ts +++ b/packages/rangelink-vscode-extension/src/destinations/capabilities/index.ts @@ -13,6 +13,8 @@ export { AIAssistantFocusCapability } from './AIAssistantFocusCapability'; export { EditorFocusCapability } from './EditorFocusCapability'; export { TerminalFocusCapability } from './TerminalFocusCapability'; export { FocusCapabilityFactory } from './FocusCapabilityFactory'; +export { LazyResolvedFocusCapability } from './LazyResolvedFocusCapability'; +export { ColdRefocusConfig } from './ColdRefocusConfig'; export type { InsertFactory } from './insertFactories'; export { diff --git a/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts b/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts index cb87b0af..e83ac468 100644 --- a/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts +++ b/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts @@ -8,6 +8,14 @@ import type * as vscode from 'vscode'; import type { CustomAiAssistantConfig } from '../config/parseCustomAiAssistants'; +import { + DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS, + DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS, +} from '../constants/settingDefaults'; +import { + SETTING_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS, + SETTING_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS, +} from '../constants/settingKeys'; import { RangeLinkExtensionError, RangeLinkExtensionErrorCodes } from '../errors'; import { AutoPasteResult, @@ -29,6 +37,7 @@ import { CURSOR_AI_FOCUS_COMMANDS, GITHUB_COPILOT_CHAT_FOCUS_COMMANDS, } from './aiAssistantFocusCommands'; +import type { ColdRefocusConfig } from './capabilities/ColdRefocusConfig'; import { ComposablePasteDestination } from './ComposablePasteDestination'; import type { DestinationBuilder, DestinationBuilderContext } from './DestinationRegistry'; import { compareEditorsByUri } from './equality/compareEditorsByUri'; @@ -46,6 +55,7 @@ interface BuiltinAiAssistantDef { readonly jumpMessageCode: MessageCode; readonly userInstructionMessageCode: MessageCode; readonly isAvailable: (context: DestinationBuilderContext) => boolean | Promise; + readonly getColdRefocus?: (context: DestinationBuilderContext) => ColdRefocusConfig; } const BUILTIN_AI_ASSISTANTS: Record = { @@ -64,6 +74,29 @@ const BUILTIN_AI_ASSISTANTS: Record = { jumpMessageCode: MessageCode.STATUS_BAR_JUMP_SUCCESS_CLAUDE_CODE, userInstructionMessageCode: MessageCode.INFO_CLAUDE_CODE_USER_INSTRUCTIONS, isAvailable: (ctx) => isClaudeCodeAvailable(ctx.ideAdapter, ctx.logger), + getColdRefocus: (context) => { + const totalMs = context.configReader.getWithDefault( + SETTING_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS, + DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS, + ) as number; + const intervalMs = context.configReader.getWithDefault( + SETTING_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS, + DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS, + ) as number; + + if (totalMs <= intervalMs) { + context.logger.warn( + { fn: 'claudeCode.getColdRefocus', totalMs, intervalMs }, + 'coldStartDelayMs must be greater than coldRefocusIntervalMs, using defaults', + ); + return { + totalMs: DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS, + intervalMs: DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS, + }; + } + + return { totalMs, intervalMs }; + }, }, 'github.copilot-chat': { kind: 'github-copilot-chat', @@ -180,9 +213,10 @@ const buildBuiltinAiAssistantDestination = ( ComposablePasteDestination.createAiAssistant({ id: def.kind, displayName: def.displayName, - focusCapability: context.factories.focusCapability.createAIAssistantCapability([ - ...def.focusCommands, - ]), + focusCapability: context.factories.focusCapability.createAIAssistantCapability( + [...def.focusCommands], + def.getColdRefocus !== undefined ? () => def.getColdRefocus!(context) : undefined, + ), isAvailable: async () => def.isAvailable(context), jumpSuccessMessage: formatMessage(def.jumpMessageCode), loggingDetails: {},