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: {},