diff --git a/plugins/vscode/media/chat-panel.js b/plugins/vscode/media/chat-panel.js
index f205c46c6..4ef284f0a 100644
--- a/plugins/vscode/media/chat-panel.js
+++ b/plugins/vscode/media/chat-panel.js
@@ -499,6 +499,8 @@
function toggleHistoryView() {
isHistoryView = !isHistoryView;
if (isHistoryView) {
+ isSettingsView = false;
+ document.getElementById('settings-view').classList.add('hidden');
document.getElementById('chat-view').classList.add('hidden');
document.getElementById('history-view').classList.remove('hidden');
// Fetch sessions from extension host and render immediately
@@ -511,8 +513,10 @@
function showChatView() {
isHistoryView = false;
- document.getElementById('chat-view').classList.remove('hidden');
+ isSettingsView = false;
document.getElementById('history-view').classList.add('hidden');
+ document.getElementById('settings-view').classList.add('hidden');
+ document.getElementById('chat-view').classList.remove('hidden');
}
const sendStopBtn = document.getElementById('send-stop-btn');
@@ -529,6 +533,7 @@
let isProcessing = false;
let currentAggregator = null;
let currentThoughtBox = null;
+ let currentTurnFooter = null;
let visualLoader = null;
const toolKinds = new Map();
let toastTimeout = null;
@@ -593,7 +598,7 @@
function createMessageFooter(getText, role, sentAt) {
const footer = document.createElement('div');
- footer.className = 'flex h-5 items-center gap-1.5 mt-1 text-xs text-vscode-fg opacity-60 ' +
+ footer.className = 'message-footer flex h-5 items-center gap-1.5 mt-1 text-xs text-vscode-fg opacity-60 ' +
(role === 'user' ? 'self-end' : 'self-start');
const btn = document.createElement('button');
@@ -965,13 +970,6 @@
const imagesToSubmit = pendingImages.length > 0 ? [...pendingImages] : undefined;
- // Send message to extension host
- vscode.postMessage({
- type: 'submitMessage',
- text: text,
- images: imagesToSubmit
- });
-
// Clear input. Close the mention first — its token offsets point into
// text that is about to disappear.
closeMention();
@@ -984,8 +982,23 @@
attachedPaths = [];
renderChips();
+ dispatchPrompt(text, imagesToSubmit);
+ }
+
+ // Send `text` to the agent as a turn of its own. Split out of
+ // submitMessage so an editor-driven prompt can bypass the composer: going
+ // through it would overwrite a draft the user is typing and sweep up chips
+ // and images they staged for a different question.
+ function dispatchPrompt(text, images) {
+ // Send message to extension host
+ vscode.postMessage({
+ type: 'submitMessage',
+ text: text,
+ images: images
+ });
+
// Optimistically append user message
- appendMessage(text, 'user', imagesToSubmit);
+ appendMessage(text, 'user', images);
pendingUserMessageText = text;
if (!isProcessing) {
@@ -1194,7 +1207,10 @@
// A user message opens a new turn, so the agent segments that follow get
// a fresh id. The raw-text accumulator is handed over lazily, once the
// new response produces text.
- if (role === 'user') agentTurnId++;
+ if (role === 'user') {
+ agentTurnId++;
+ currentTurnFooter = null;
+ }
const wrapper = document.createElement('div');
wrapper.className = 'group flex flex-col min-w-0 shrink-0 ' +
@@ -1382,8 +1398,15 @@
msgEl.appendChild(textContainer);
wrapper.appendChild(msgEl);
- wrapper.appendChild(createMessageFooter(() => wrapper.dataset.rawText || '', 'agent', new Date()));
- wrapper.dataset.rawText = currentTurnText;
+ if (currentTurnFooter) {
+ currentTurnFooter.remove();
+ } else {
+ // captures footer, not currentTurnFooter - avoids copying the next turn's text
+ const footer = createMessageFooter(() => footer.dataset.rawText || '', 'agent', new Date());
+ currentTurnFooter = footer;
+ }
+ currentTurnFooter.dataset.rawText = lastAgentRawText;
+ wrapper.appendChild(currentTurnFooter);
messagesContainer.appendChild(wrapper);
currentTurnEl = msgEl;
@@ -1393,8 +1416,8 @@
// Append to existing turn
currentTurnText += textChunk;
syncLastAgentRawText();
- if (currentTurnEl.parentElement) {
- currentTurnEl.parentElement.dataset.rawText = currentTurnText;
+ if (currentTurnFooter) {
+ currentTurnFooter.dataset.rawText = lastAgentRawText;
}
if (typeof marked !== 'undefined') {
@@ -1574,7 +1597,8 @@
case 'clear':
// Session reset (new chat or resume) should return to the active
// chat view, not leave the panel stuck on the history list.
- showChatView();
+ if (isHistoryView) showChatView();
+ if (isSettingsView) hideSettingsView();
if (renderTimeout) { clearTimeout(renderTimeout); renderTimeout = null; }
if (message.isLoading) {
messagesContainer.innerHTML = `
${ICONS.pending}
Loading session...
`;
@@ -1584,6 +1608,7 @@
currentTurnEl = null;
currentTextEl = null;
currentTurnText = '';
+ currentTurnFooter = null;
toolKinds.clear();
agentTurnId = 0;
lastAgentRawTurnId = -1;
@@ -1616,7 +1641,17 @@
case 'permissionsCancelled':
handlePermissionsCancelled(message.toolCallIds);
break;
-
+ case 'toggleSettings':
+ toggleSettingsView();
+ break;
+ case 'settingsData':
+ renderSettingsData(message.settings);
+ break;
+ case 'settingsUpdated':
+ if (!message.success) {
+ console.error('Failed to update setting:', message.error);
+ }
+ break;
case 'syncState':
handleSyncState(message);
break;
@@ -1627,6 +1662,11 @@
case 'updateTimeline':
timelineStrip.setEntries(message.entries || []);
break;
+ case 'runPrompt':
+ if (isHistoryView) showChatView();
+ dispatchPrompt(message.text);
+ chatInput.focus();
+ break;
case 'copyLastCodeBlock':
copyLastCodeBlock();
break;
@@ -1816,6 +1856,20 @@
syncLastAgentRawText();
}
+ function aggregatorHasPendingTools(aggregator) {
+ for (const item of aggregator.toolItems.values()) {
+ if (item.dataset.pending === 'true') return true;
+ }
+ return false;
+ }
+
+ function closeAggregatorIfIdle() {
+ if (currentAggregator && !aggregatorHasPendingTools(currentAggregator)) {
+ currentAggregator.close();
+ currentAggregator = null;
+ }
+ }
+
function handleAcpUpdate(payload) {
if (!payload) return;
const update = payload.update ? payload.update : payload;
@@ -1839,6 +1893,7 @@
if (currentThoughtBox) {
currentThoughtBox.pause();
}
+ closeAggregatorIfIdle();
if (update.content && update.content.text) {
stopVisualLoader();
appendChunk(update.content.text);
@@ -1847,6 +1902,7 @@
if (!currentThoughtBox) {
endCurrentTextBlock();
currentThoughtBox = new ThoughtAggregator();
+ closeAggregatorIfIdle();
}
if (update.content && update.content.text) {
currentThoughtBox.append(update.content.text);
@@ -1871,6 +1927,208 @@
keepVisualLoaderAtBottom();
}
+ // ─── Settings Panel Logic ───────────────────────────────────────
+
+ let isSettingsView = false;
+
+ function showSettingsView() {
+ isSettingsView = true;
+ isHistoryView = false;
+ document.getElementById('chat-view').classList.add('hidden');
+ document.getElementById('history-view').classList.add('hidden');
+ document.getElementById('settings-view').classList.remove('hidden');
+ // Request fresh settings data from extension host
+ vscode.postMessage({ type: 'requestSettings' });
+ }
+
+ function hideSettingsView() {
+ isSettingsView = false;
+ document.getElementById('settings-view').classList.add('hidden');
+ showChatView();
+ }
+
+ function toggleSettingsView() {
+ if (isSettingsView) {
+ hideSettingsView();
+ } else {
+ showSettingsView();
+ }
+ }
+
+ // Settings tab switching
+ document.querySelectorAll('.settings-tab').forEach(tab => {
+ tab.addEventListener('click', () => {
+ document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
+ tab.classList.add('active');
+ const tabId = tab.dataset.tab;
+ document.querySelectorAll('.settings-tab-content').forEach(c => c.classList.add('hidden'));
+ const content = document.getElementById('settings-tab-' + tabId);
+ if (content) content.classList.remove('hidden');
+ });
+ });
+
+ // Settings action buttons (edit config, restart, etc.)
+ document.querySelectorAll('.settings-action-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const action = btn.dataset.action;
+ if (action === 'edit-providers' || action === 'edit-mcp' || action === 'edit-tools' || action === 'open-agents-config') {
+ vscode.postMessage({ type: 'openConfigFile', file: 'agents.config.json' });
+ } else if (action === 'open-preferences') {
+ vscode.postMessage({ type: 'openConfigFile', file: 'nanocoder-preferences.json' });
+ } else if (action === 'restart-acp') {
+ vscode.postMessage({ type: 'restartAcp' });
+ }
+ });
+ });
+
+ // Behavior tab — interactive controls change handlers
+ function initSettingsControls() {
+ // Default mode
+ const modeSelect = document.getElementById('setting-defaultMode');
+ if (modeSelect) {
+ modeSelect.addEventListener('change', () => {
+ vscode.postMessage({ type: 'updateSetting', key: 'defaultMode', value: modeSelect.value || null });
+ });
+ }
+
+ // Auto-compact enabled
+ const acEnabled = document.getElementById('setting-autoCompact-enabled');
+ if (acEnabled) {
+ acEnabled.addEventListener('change', () => {
+ vscode.postMessage({ type: 'updateSetting', key: 'autoCompact.enabled', value: acEnabled.checked });
+ });
+ }
+
+ // Auto-compact threshold
+ const acThreshold = document.getElementById('setting-autoCompact-threshold');
+ if (acThreshold) {
+ acThreshold.addEventListener('change', () => {
+ const val = parseInt(acThreshold.value, 10);
+ if (!isNaN(val) && val >= 50 && val <= 95) {
+ vscode.postMessage({ type: 'updateSetting', key: 'autoCompact.threshold', value: val });
+ }
+ });
+ }
+
+ // Auto-compact mode
+ const acMode = document.getElementById('setting-autoCompact-mode');
+ if (acMode) {
+ acMode.addEventListener('change', () => {
+ vscode.postMessage({ type: 'updateSetting', key: 'autoCompact.mode', value: acMode.value });
+ });
+ }
+
+ // Reasoning traces
+ const rtToggle = document.getElementById('setting-reasoningTraces');
+ if (rtToggle) {
+ rtToggle.addEventListener('change', () => {
+ vscode.postMessage({ type: 'updateSetting', key: 'reasoningTraces', value: rtToggle.checked });
+ });
+ }
+
+ // Sessions auto-save
+ const saToggle = document.getElementById('setting-sessions-autoSave');
+ if (saToggle) {
+ saToggle.addEventListener('change', () => {
+ vscode.postMessage({ type: 'updateSetting', key: 'sessions.autoSave', value: saToggle.checked });
+ });
+ }
+ }
+ initSettingsControls();
+
+ /**
+ * Populate the settings UI with data received from the extension host.
+ */
+ function renderSettingsData(settings) {
+ // ── Providers list ──
+ const providersList = document.getElementById('settings-providers-list');
+ if (providersList) {
+ if (settings.providers.length === 0) {
+ providersList.innerHTML = '
No providers configured
';
+ } else {
+ providersList.innerHTML = settings.providers.map(p => {
+ const detail = p.baseUrl || 'default endpoint';
+ const models = p.models.length > 0
+ ? p.models[0] + (p.models.length > 1 ? ` +${p.models.length - 1}` : '')
+ : 'no models';
+ const keyBadge = p.apiKeySet
+ ? '
Key ✓'
+ : '
No key';
+ return `
+ ${escapeHtml(p.name)}
+ ${escapeHtml(detail)} · ${escapeHtml(models)}
+ ${keyBadge}
+
`;
+ }).join('');
+ }
+ }
+
+ // ── MCP Servers list ──
+ const mcpList = document.getElementById('settings-mcp-list');
+ if (mcpList) {
+ if (settings.mcpServers.length === 0) {
+ mcpList.innerHTML = '
No MCP servers configured
';
+ } else {
+ mcpList.innerHTML = settings.mcpServers.map(s => {
+ const detail = s.command || s.url || '(no endpoint)';
+ return `
+ ${escapeHtml(s.name)}
+ ${escapeHtml(s.transport)} · ${escapeHtml(detail)}
+
`;
+ }).join('');
+ }
+ }
+
+ // ── Tool auto-approval list ──
+ const toolsList = document.getElementById('settings-tools-list');
+ if (toolsList) {
+ if (settings.alwaysAllow.length === 0) {
+ toolsList.innerHTML = '
No tools auto-approved
';
+ } else {
+ toolsList.innerHTML = settings.alwaysAllow.map(t =>
+ `
+ ${escapeHtml(t)}
+
`
+ ).join('');
+ }
+ }
+
+ // ── Web search status ──
+ const wsStatus = document.getElementById('settings-websearch-status');
+ if (wsStatus) {
+ wsStatus.innerHTML = settings.webSearch.configured
+ ? '
API key configured ✓
'
+ : '
Not configured
';
+ }
+
+ // ── Behavior controls ──
+ const modeSelect = document.getElementById('setting-defaultMode');
+ if (modeSelect) modeSelect.value = settings.defaultMode || 'normal';
+
+ const acEnabled = document.getElementById('setting-autoCompact-enabled');
+ if (acEnabled) acEnabled.checked = settings.autoCompact.enabled;
+
+ const acThreshold = document.getElementById('setting-autoCompact-threshold');
+ if (acThreshold) acThreshold.value = settings.autoCompact.threshold;
+
+ const acMode = document.getElementById('setting-autoCompact-mode');
+ if (acMode) acMode.value = settings.autoCompact.mode;
+
+ const rtToggle = document.getElementById('setting-reasoningTraces');
+ if (rtToggle) rtToggle.checked = settings.reasoningTraces;
+
+ const saToggle = document.getElementById('setting-sessions-autoSave');
+ if (saToggle) saToggle.checked = settings.sessions.autoSave;
+ }
+
+ function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+ }
+
+ // ─── End Settings Panel Logic ───────────────────────────────────
+
class ThoughtAggregator {
constructor() {
this.el = document.createElement('div');
@@ -2020,8 +2278,8 @@
messagesContainer.appendChild(this.el);
}
- toggle() {
- this.isOpen = !this.isOpen;
+ toggle(force) {
+ this.isOpen = force !== undefined ? force : !this.isOpen;
this.body.style.display = this.isOpen ? '' : 'none';
const svg = this.chevron.querySelector('svg');
@@ -2078,20 +2336,27 @@
statusEl.dataset.status = update.status || 'pending';
if (update.status === 'success' || update.status === 'completed') {
statusEl.innerHTML = ICONS.success;
+ item.dataset.pending = 'false';
} else if (
update.status === 'cancelled' ||
update.status === 'denied' ||
// ACP has no 'cancelled' status, so a cancel arrives as failed with
// 'Cancelled by user'. Case-insensitive, or the capital C misses.
- (update.status === 'failed' && update.rawOutput && typeof update.rawOutput === 'string' && /aborterror|cancelled/i.test(update.rawOutput))
+ (update.status === 'failed' && update.rawOutput && typeof update.rawOutput === 'string' && /aborterror|cancelled|denied/i.test(update.rawOutput))
) {
statusEl.innerHTML = ICONS.cancelled;
+ item.dataset.pending = 'false';
} else if (update.status === 'error' || update.status === 'failed') {
statusEl.innerHTML = ICONS.error;
+ item.dataset.pending = 'false';
} else if (update.status === 'pending') {
+ // Queued, not yet running - still unfinished, so the
+ // aggregator must stay open for it.
statusEl.innerHTML = ICONS.circle;
+ item.dataset.pending = 'true';
} else {
statusEl.innerHTML = ICONS.pending;
+ item.dataset.pending = 'true';
}
}
@@ -2113,6 +2378,7 @@
if (!card) {
endCurrentTextBlock();
+ closeAggregatorIfIdle();
card = document.createElement('div');
card.id = `plan-card-${agentTurnId}`;
card.className = 'my-3 border border-vscode-widget-border rounded bg-vscode-widget-bg overflow-hidden shrink-0';
@@ -2186,6 +2452,7 @@
if (toolKinds.get(toolCallId) === 'edit') {
let card = document.getElementById(`tool-card-${toolCallId}`);
if (!card) {
+ closeAggregatorIfIdle();
card = createEditCard(toolCallId, update);
messagesContainer.appendChild(card);
scrollToBottom();
diff --git a/plugins/vscode/package.json b/plugins/vscode/package.json
index a86abdd57..75e1dd583 100644
--- a/plugins/vscode/package.json
+++ b/plugins/vscode/package.json
@@ -82,6 +82,12 @@
"category": "Nanocoder",
"icon": "$(add)"
},
+ {
+ "command": "nanocoder.toggleSettings",
+ "title": "Settings",
+ "category": "Nanocoder",
+ "icon": "$(gear)"
+ },
{
"command": "nanocoder.cancel",
"title": "Cancel Current Response",
@@ -91,6 +97,16 @@
"command": "nanocoder.copyLastCodeBlock",
"title": "Copy Last Code Block",
"category": "Nanocoder"
+ },
+ {
+ "command": "nanocoder.explainCode",
+ "title": "Explain Code",
+ "category": "Nanocoder"
+ },
+ {
+ "command": "nanocoder.generateTests",
+ "title": "Generate Tests",
+ "category": "Nanocoder"
}
],
"keybindings": [
@@ -112,6 +128,21 @@
"command": "nanocoder.toggleHistory",
"when": "view == nanocoder.chatView",
"group": "navigation@2"
+ },
+ {
+ "command": "nanocoder.toggleSettings",
+ "when": "view == nanocoder.chatView",
+ "group": "navigation@3"
+ }
+ ],
+ "commandPalette": [
+ {
+ "command": "nanocoder.explainCode",
+ "when": "false"
+ },
+ {
+ "command": "nanocoder.generateTests",
+ "when": "false"
}
]
},
@@ -158,6 +189,12 @@
"type": "string",
"scope": "resource",
"description": "Working directory for the Nanocoder CLI. Defaults to the current VS Code workspace root."
+ },
+ "nanocoder.codeLens": {
+ "type": "boolean",
+ "default": true,
+ "scope": "resource",
+ "description": "Show Explain Code / Generate Tests actions above functions and classes in the editor."
}
}
}
diff --git a/plugins/vscode/src/acp-client.spec.ts b/plugins/vscode/src/acp-client.spec.ts
index c632be87c..78f111232 100644
--- a/plugins/vscode/src/acp-client.spec.ts
+++ b/plugins/vscode/src/acp-client.spec.ts
@@ -50,6 +50,7 @@ test('NanocoderAcpClient - cancel resolves and clears pending permissions', asyn
const requestPromise = client.handlePermissionRequest({
toolCall: { toolCallId: 'call_123', name: 'write_file', arguments: {} },
});
+
t.true(client.hasPendingPermissions());
await client.cancel();
@@ -152,3 +153,18 @@ test('NanocoderAcpClient - revertTimeline calls timeline/revert', async (t) => {
t.deepEqual(called.params, {sessionId: 'session-1', checkpointId: 'cp-1'});
});
+test('NanocoderAcpClient - reconnecting clears permissions left by the dead process', async (t) => {
+ const outputChannel = {appendLine: () => {}} as any;
+ const stateManager = new AcpStateManager();
+ const client = new NanocoderAcpClient(outputChannel, stateManager);
+
+ const requestPromise = client.handlePermissionRequest({
+ toolCall: {toolCallId: 'call_456', name: 'test_tool', arguments: {}},
+ });
+
+ client.setConnection({} as any);
+
+ const result = await requestPromise;
+ t.is((result as any).outcome.outcome, 'cancelled');
+ t.false(client.hasPendingPermissions());
+});
diff --git a/plugins/vscode/src/acp-client.ts b/plugins/vscode/src/acp-client.ts
index 0b6ab558a..e5fc78aba 100644
--- a/plugins/vscode/src/acp-client.ts
+++ b/plugins/vscode/src/acp-client.ts
@@ -101,6 +101,7 @@ export class NanocoderAcpClient {
setConnection(connection: ClientSideConnection): void {
this.connection = connection;
this._sessionId = undefined; // Clear any stale session to force re-creation
+ this._clearPendingPermissions();
}
async handlePermissionRequest(params: any): Promise
{
@@ -346,10 +347,10 @@ export class NanocoderAcpClient {
}
async cancel(): Promise {
- if (!this.connection || !this._sessionId) return;
this.cancelRequested = true;
// Before the notification, so the map is emptied even if cancel() throws.
this._clearPendingPermissions();
+ if (!this.connection || !this._sessionId) return;
try {
await this.connection.cancel({
sessionId: this._sessionId
diff --git a/plugins/vscode/src/chat-webview-provider.ts b/plugins/vscode/src/chat-webview-provider.ts
index 9fdd3097c..ff1683c64 100644
--- a/plugins/vscode/src/chat-webview-provider.ts
+++ b/plugins/vscode/src/chat-webview-provider.ts
@@ -3,8 +3,11 @@ import * as path from 'path';
import * as vscode from 'vscode';
import { WebviewToExtensionMessage, ExtensionToWebviewMessage, MentionItem } from './webview-protocol';
+
+
import { NanocoderAcpClient } from './acp-client';
import { DiffManager } from './diff-manager';
+import { SettingsManager } from './settings-manager';
import { searchMentions, MentionSearchDeps } from './mention-search';
import { readCappedFile, readCappedDirectory } from './context-attachment';
@@ -22,19 +25,34 @@ const MENTION_ALWAYS_EXCLUDE = [
'**/coverage/**',
];
-export class ChatWebviewProvider implements vscode.WebviewViewProvider {
+/**
+ * How long an editor-driven prompt waits for the webview shell and the ACP
+ * session before it is dropped. Without a bound, a prompt queued while the CLI
+ * is down would fire whenever the connection eventually came up - long after
+ * the user moved on from the code they clicked.
+ */
+const PENDING_PROMPT_TIMEOUT_MS = 30_000;
+
+export class ChatWebviewProvider
+ implements vscode.WebviewViewProvider, vscode.Disposable {
public static readonly viewType = 'nanocoder.chatView';
private _view?: vscode.WebviewView;
private _isWebviewReady = false;
private _timelineRefreshTimer?: ReturnType;
+ /** Code lens prompt waiting on the webview shell and the ACP session. */
+ private _pendingPrompt: string | null = null;
+ private _pendingPromptTimer: ReturnType | null = null;
+
+ private readonly _settingsManager: SettingsManager;
constructor(
private readonly _extensionUri: vscode.Uri,
private readonly _outputChannel: vscode.OutputChannel,
private readonly _acpClient: NanocoderAcpClient,
private readonly _diffManager: DiffManager
- ) {
+ ) {
+ this._settingsManager = new SettingsManager(this._outputChannel);
// Listen for session updates from ACP
this._acpClient.onSessionUpdate = (update: any) => {
this.handleDiffs(update);
@@ -96,6 +114,58 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
}
}
+ /**
+ * Reveal the chat view and run `text` as a prompt. An editor code lens can
+ * fire long before the sidebar has ever been opened, so the prompt is held
+ * until the shell reports ready and a session exists.
+ */
+ public async sendPrompt(text: string) {
+ this._queuePendingPrompt(text);
+ await vscode.commands.executeCommand(`${ChatWebviewProvider.viewType}.focus`);
+ await this._initializeSessionIfReady();
+ }
+
+ private _queuePendingPrompt(text: string) {
+ this._clearPendingPrompt();
+ this._pendingPrompt = text;
+ this._pendingPromptTimer = setTimeout(() => {
+ this._pendingPromptTimer = null;
+ this._pendingPrompt = null;
+ vscode.window.showWarningMessage(
+ 'Nanocoder: the agent did not start in time, so your editor request was not sent. Try again once the chat view is connected.',
+ );
+ }, PENDING_PROMPT_TIMEOUT_MS);
+ }
+
+ private _clearPendingPrompt() {
+ if (this._pendingPromptTimer) {
+ clearTimeout(this._pendingPromptTimer);
+ this._pendingPromptTimer = null;
+ }
+ this._pendingPrompt = null;
+ }
+
+ /**
+ * Hand a queued prompt to the webview. Cleared before posting so a failed
+ * delivery can't be retried into a half-loaded shell.
+ */
+ private _flushPendingPrompt() {
+ const text = this._pendingPrompt;
+ if (text === null) {
+ return;
+ }
+ this._clearPendingPrompt();
+ this.postMessage({type: 'runPrompt', text});
+ }
+
+ /**
+ * Registered with the extension's subscriptions so a deactivate cannot
+ * leave the pending-prompt timer running against a disposed view.
+ */
+ public dispose() {
+ this._clearPendingPrompt();
+ }
+
public requestCopyLastCodeBlock() {
if (!this._view) {
vscode.window.showInformationMessage('Nanocoder: open the Nanocoder chat view first.');
@@ -110,12 +180,35 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
}
}
+ public toggleSettings() {
+ if (this._view) {
+ this._view.webview.postMessage({ type: 'toggleSettings' });
+ }
+ }
+
public resolveWebviewView(
webviewView: vscode.WebviewView,
context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken,
) {
this._view = webviewView;
+ // A re-resolve means a brand new shell that has not run its script yet.
+ // Leaving the flag set from the previous one would let a queued prompt
+ // post into a webview with no message listener attached, dropping it.
+ this._isWebviewReady = false;
+ webviewView.onDidDispose(() => {
+ // A disposal can land after a newer view has already been resolved
+ // (VS Code tears the old one down late). Without this guard that
+ // stale event would null out the live view and drop its state.
+ if (this._view !== webviewView) {
+ return;
+ }
+ this._view = undefined;
+ this._isWebviewReady = false;
+ // A queued prompt is deliberately kept: a disposal is usually a
+ // re-reveal in progress, and the next resolve is what delivers it.
+ // The timeout is what bounds the wait if no view comes back.
+ });
webviewView.webview.options = {
enableScripts: true,
@@ -203,6 +296,22 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
this._broadcastSessions();
});
break;
+ case 'requestSettings':
+ this._outputChannel.appendLine('[Webview] Settings data requested.');
+ this._handleRequestSettings();
+ break;
+ case 'updateSetting':
+ this._outputChannel.appendLine(`[Webview] Update setting: ${message.key}`);
+ this._handleUpdateSetting(message.key, message.value);
+ break;
+ case 'openConfigFile':
+ this._outputChannel.appendLine(`[Webview] Open config file: ${message.file}`);
+ this._handleOpenConfigFile(message.file);
+ break;
+ case 'restartAcp':
+ this._outputChannel.appendLine('[Webview] Restart ACP requested.');
+ vscode.commands.executeCommand('nanocoder.restartAcp');
+ break;
case 'requestPathInfo': {
try {
const stat = fs.statSync(message.path);
@@ -286,6 +395,7 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
// Broadcast session list to populate History tab
await this._broadcastSessions();
await this._broadcastTimeline();
+ this._flushPendingPrompt();
}
} catch (error) {
this._outputChannel.appendLine(`Failed to initialize session on ready: ${error}`);
@@ -336,6 +446,50 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
}
}
+ private _handleRequestSettings() {
+ const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd();
+ const settings = this._settingsManager.readSettings(cwd);
+ this.postMessage({type: 'settingsData', settings});
+ }
+
+ private _handleUpdateSetting(key: string, value: unknown) {
+ const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd();
+ const result = this._settingsManager.updateSetting(cwd, key, value);
+ this.postMessage({
+ type: 'settingsUpdated',
+ key,
+ success: result.success,
+ error: result.error,
+ });
+
+ // If successful, send refreshed settings so the UI stays in sync
+ if (result.success) {
+ const settings = this._settingsManager.readSettings(cwd);
+ this.postMessage({type: 'settingsData', settings});
+ } else {
+ vscode.window.showErrorMessage(`Failed to save setting '${key}': ${result.error}`);
+ }
+ }
+
+ private async _handleOpenConfigFile(file: string) {
+ const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd();
+ const paths = this._settingsManager.getConfigPaths(cwd);
+ const filePath = file === 'agents.config.json' ? paths.agentsConfig : paths.preferences;
+
+ try {
+ if (!fs.existsSync(filePath)) {
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
+ fs.writeFileSync(filePath, '{}\n', 'utf-8');
+ }
+ const doc = await vscode.workspace.openTextDocument(filePath);
+ await vscode.window.showTextDocument(doc);
+ } catch {
+ vscode.window.showErrorMessage(
+ `Could not open ${file} at ${filePath}. Ensure the file exists.`,
+ );
+ }
+ }
+
/**
* Exclude glob for `@` search.
*
@@ -463,6 +617,10 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider {
try {
if (this._acpClient.hasPendingPermissions()) {
vscode.window.showWarningMessage('Nanocoder: Please approve or deny the pending tool before sending a new message.');
+ // The webview has already drawn the user bubble and flipped to
+ // the loading state, and no turn is going to start - so end the
+ // turn here or the composer spins until the user hits Escape.
+ this.postMessage({type: 'acpUpdate', update: {sessionUpdate: 'prompt_response'}});
return;
}
diff --git a/plugins/vscode/src/code-lens-provider.spec.ts b/plugins/vscode/src/code-lens-provider.spec.ts
new file mode 100644
index 000000000..1283f0b01
--- /dev/null
+++ b/plugins/vscode/src/code-lens-provider.spec.ts
@@ -0,0 +1,232 @@
+import test from 'ava';
+import * as vscode from 'vscode';
+import {
+ buildCodeLensPrompt,
+ MAX_LENS_SOURCE_LINES,
+ NanocoderCodeLensProvider,
+ sendCodeLensPrompt,
+ truncateLensSource,
+} from './code-lens-provider';
+
+const {__test} = vscode as unknown as {__test: any};
+
+const NEVER_CANCELLED = {
+ isCancellationRequested: false,
+ onCancellationRequested: () => ({dispose: () => {}}),
+} as unknown as vscode.CancellationToken;
+
+const DOCUMENT = {
+ uri: vscode.Uri.file('/repo/src/thing.ts'),
+} as unknown as vscode.TextDocument;
+
+function symbol(
+ name: string,
+ kind: vscode.SymbolKind,
+ children: any[] = [],
+): any {
+ return {
+ name,
+ kind,
+ range: new vscode.Range(0, 0, 10, 0),
+ selectionRange: new vscode.Range(0, 6, 0, 6 + name.length),
+ children,
+ };
+}
+
+test.beforeEach(() => {
+ __test.reset();
+});
+
+test('provideCodeLenses - keeps lensable kinds and walks nested children', async t => {
+ __test.executeCommand = async () => [
+ symbol('Widget', vscode.SymbolKind.Class, [
+ symbol('constructor', vscode.SymbolKind.Constructor),
+ symbol('render', vscode.SymbolKind.Method),
+ // Fields and properties are deliberately skipped - a lens on every
+ // one of them would bury the editor.
+ symbol('count', vscode.SymbolKind.Property),
+ symbol('label', vscode.SymbolKind.Field),
+ ]),
+ symbol('helper', vscode.SymbolKind.Function, [
+ // Nested one level deeper than any top-level symbol, so it only
+ // shows up if the walk actually recurses.
+ symbol('inner', vscode.SymbolKind.Function),
+ ]),
+ symbol('total', vscode.SymbolKind.Variable),
+ ];
+
+ const lenses = await new NanocoderCodeLensProvider().provideCodeLenses(
+ DOCUMENT,
+ NEVER_CANCELLED,
+ );
+
+ // Class, Constructor, Method, Function, nested Function - two lenses each.
+ t.is(lenses.length, 10);
+ t.deepEqual(
+ [...new Set(lenses.map(lens => lens.command?.title))],
+ ['Explain Code', 'Generate Tests'],
+ );
+ t.deepEqual(
+ [...new Set(lenses.map(lens => lens.command?.command))],
+ ['nanocoder.explainCode', 'nanocoder.generateTests'],
+ );
+ // The lens is anchored on the name, but the command carries the whole body.
+ t.is((lenses[0].command?.arguments?.[1] as vscode.Range).end.line, 10);
+});
+
+test('provideCodeLenses - skips legacy symbols with no selectionRange', async t => {
+ const legacy = symbol('legacy', vscode.SymbolKind.Function);
+ legacy.selectionRange = undefined;
+ __test.executeCommand = async () => [legacy];
+
+ const lenses = await new NanocoderCodeLensProvider().provideCodeLenses(
+ DOCUMENT,
+ NEVER_CANCELLED,
+ );
+
+ t.deepEqual(lenses, []);
+});
+
+test('provideCodeLenses - short-circuits when nanocoder.codeLens is false', async t => {
+ let symbolProviderCalls = 0;
+ __test.configuration = (_section: string, key: string, fallback: unknown) =>
+ key === 'codeLens' ? false : fallback;
+ __test.executeCommand = async () => {
+ symbolProviderCalls++;
+ return [symbol('helper', vscode.SymbolKind.Function)];
+ };
+
+ const lenses = await new NanocoderCodeLensProvider().provideCodeLenses(
+ DOCUMENT,
+ NEVER_CANCELLED,
+ );
+
+ t.deepEqual(lenses, []);
+ // Bailing before the symbol request is the point: the language server is
+ // not asked to do work whose result is thrown away.
+ t.is(symbolProviderCalls, 0);
+});
+
+test('buildCodeLensPrompt - instruction, locator, then fenced source', t => {
+ const prompt = buildCodeLensPrompt({
+ instruction: 'Explain what this code does.',
+ relativePath: 'src/thing.ts',
+ startLine: 12,
+ endLine: 14,
+ languageId: 'typescript',
+ source: 'function add(a, b) {\n\treturn a + b;\n}',
+ });
+
+ t.is(
+ prompt,
+ [
+ 'Explain what this code does.',
+ '',
+ 'src/thing.ts:12-14',
+ '```typescript',
+ 'function add(a, b) {',
+ '\treturn a + b;',
+ '}',
+ '```',
+ ].join('\n'),
+ );
+});
+
+test('buildCodeLensPrompt - caps a long symbol and points at the file', t => {
+ const source = Array.from(
+ {length: MAX_LENS_SOURCE_LINES + 40},
+ (_unused, i) => `\tline ${i};`,
+ ).join('\n');
+
+ const prompt = buildCodeLensPrompt({
+ instruction: 'Write unit tests for this code.',
+ relativePath: 'src/huge.ts',
+ startLine: 1,
+ endLine: MAX_LENS_SOURCE_LINES + 40,
+ languageId: 'typescript',
+ source,
+ });
+
+ t.true(prompt.includes('\tline 0;'));
+ t.false(prompt.includes(`\tline ${MAX_LENS_SOURCE_LINES};`));
+ t.true(prompt.endsWith('(truncated - 40 more lines; read src/huge.ts for the rest)'));
+ // The locator survives truncation, so the agent can still find the rest.
+ t.true(prompt.includes(`src/huge.ts:1-${MAX_LENS_SOURCE_LINES + 40}`));
+});
+
+test('truncateLensSource - leaves a short symbol untouched', t => {
+ const source = 'const a = 1;\nconst b = 2;';
+ t.deepEqual(truncateLensSource(source), {
+ text: source,
+ omittedLines: 0,
+ truncated: false,
+ });
+});
+
+test('truncateLensSource - cuts a single line that busts the char cap', t => {
+ const result = truncateLensSource('x'.repeat(500), 200, 100);
+ t.is(result.text.length, 100);
+ t.is(result.omittedLines, 0);
+ t.true(result.truncated);
+});
+
+test('truncateLensSource - char cap can bind before the line cap', t => {
+ const result = truncateLensSource('abcd\nabcd\nabcd\nabcd', 200, 12);
+ t.is(result.text, 'abcd\nabcd');
+ t.is(result.omittedLines, 2);
+ t.true(result.truncated);
+});
+
+test('sendCodeLensPrompt - hands the built prompt to the chat view', async t => {
+ __test.asRelativePath = () => 'src/thing.ts';
+ __test.openTextDocument = async () => ({
+ languageId: 'typescript',
+ getText: () => 'const answer = 42;',
+ });
+
+ const sent: string[] = [];
+ const chatProvider = {
+ sendPrompt: async (text: string) => {
+ sent.push(text);
+ },
+ } as any;
+
+ await sendCodeLensPrompt(
+ chatProvider,
+ 'Explain what this code does.',
+ vscode.Uri.file('/repo/src/thing.ts'),
+ new vscode.Range(11, 0, 13, 1),
+ );
+
+ t.deepEqual(sent, [
+ [
+ 'Explain what this code does.',
+ '',
+ // The range is 0-based; the locator the agent sees is not.
+ 'src/thing.ts:12-14',
+ '```typescript',
+ 'const answer = 42;',
+ '```',
+ ].join('\n'),
+ ]);
+});
+
+test('sendCodeLensPrompt - explains itself when invoked without lens arguments', async t => {
+ let sendPromptCalls = 0;
+ const chatProvider = {
+ sendPrompt: async () => {
+ sendPromptCalls++;
+ },
+ } as any;
+
+ // A keybinding or another extension can reach the command directly, with
+ // nothing to describe.
+ await sendCodeLensPrompt(chatProvider, 'Explain what this code does.');
+
+ t.is(sendPromptCalls, 0);
+ t.deepEqual(
+ __test.shownMessages.map((m: any) => m.kind),
+ ['info'],
+ );
+ t.regex(__test.shownMessages[0].message, /Explain Code \/ Generate Tests/);
+});
diff --git a/plugins/vscode/src/code-lens-provider.ts b/plugins/vscode/src/code-lens-provider.ts
new file mode 100644
index 000000000..0a31acbbb
--- /dev/null
+++ b/plugins/vscode/src/code-lens-provider.ts
@@ -0,0 +1,200 @@
+import * as vscode from 'vscode';
+import type { ChatWebviewProvider } from './chat-webview-provider';
+
+/**
+ * Symbols worth a lens. Anything finer-grained (properties, variables) would
+ * bury the editor in links. Constructors are included because they read as
+ * ordinary methods to the user, and a class lens covers the whole body rather
+ * than the constructor on its own.
+ */
+export const LENS_SYMBOL_KINDS: ReadonlySet = new Set([
+ vscode.SymbolKind.Function,
+ vscode.SymbolKind.Method,
+ vscode.SymbolKind.Constructor,
+ vscode.SymbolKind.Class,
+]);
+
+/**
+ * Caps on the source inlined into a lens prompt. `Generate Tests` on a
+ * thousand-line class would otherwise paste the entire body into the
+ * conversation and spend a local model's whole context on one turn. The head of
+ * a symbol carries the signature and the shape, so a truncated body plus the
+ * `file:start-end` locator still leaves the agent enough to work from - it can
+ * read the file for the remainder.
+ */
+export const MAX_LENS_SOURCE_LINES = 200;
+export const MAX_LENS_SOURCE_CHARS = 8_000;
+
+export interface TruncatedSource {
+ text: string;
+ /** Whole lines dropped from the end. Zero when a lone long line was cut. */
+ omittedLines: number;
+ truncated: boolean;
+}
+
+/**
+ * Clip `source` to the line and character caps, whichever binds first. At least
+ * one line is always kept so the symbol's signature survives.
+ */
+export function truncateLensSource(
+ source: string,
+ maxLines: number = MAX_LENS_SOURCE_LINES,
+ maxChars: number = MAX_LENS_SOURCE_CHARS,
+): TruncatedSource {
+ const lines = source.split('\n');
+
+ let keptLines = 0;
+ let chars = 0;
+ for (const line of lines) {
+ if (keptLines >= maxLines) break;
+ const next = chars + line.length + (keptLines > 0 ? 1 : 0);
+ if (keptLines > 0 && next > maxChars) break;
+ chars = next;
+ keptLines++;
+ }
+
+ if (keptLines === lines.length && chars <= maxChars) {
+ return { text: source, omittedLines: 0, truncated: false };
+ }
+
+ // A single line over the character cap - minified or generated code - has no
+ // line boundary to fall back to, so it is cut mid-line.
+ const text = lines.slice(0, keptLines).join('\n').slice(0, maxChars);
+ return { text, omittedLines: lines.length - keptLines, truncated: true };
+}
+
+export interface CodeLensPromptInput {
+ instruction: string;
+ /** Workspace-relative path of the clicked symbol's file. */
+ relativePath: string;
+ /** 1-based, inclusive. */
+ startLine: number;
+ /** 1-based, inclusive. */
+ endLine: number;
+ languageId: string;
+ source: string;
+}
+
+/**
+ * Build the prompt a lens click sends. The symbol source is inlined rather than
+ * attached as a file: the agent should see the one function the user clicked,
+ * not everything around it. The locator is always present, so a truncated body
+ * still points at the rest.
+ */
+export function buildCodeLensPrompt(input: CodeLensPromptInput): string {
+ const { text, omittedLines, truncated } = truncateLensSource(input.source);
+ const location = `${input.relativePath}:${input.startLine}-${input.endLine}`;
+
+ const parts = [
+ input.instruction,
+ '',
+ location,
+ '```' + input.languageId,
+ text,
+ '```',
+ ];
+
+ if (truncated) {
+ parts.push(
+ omittedLines > 0
+ ? `(truncated - ${omittedLines} more lines; read ${input.relativePath} for the rest)`
+ : `(truncated; read ${input.relativePath} for the rest)`,
+ );
+ }
+
+ return parts.join('\n');
+}
+
+export class NanocoderCodeLensProvider
+ implements vscode.CodeLensProvider, vscode.Disposable {
+ private readonly _onDidChangeCodeLenses = new vscode.EventEmitter();
+ public readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event;
+
+ public refresh(): void {
+ this._onDidChangeCodeLenses.fire();
+ }
+
+ public dispose(): void {
+ this._onDidChangeCodeLenses.dispose();
+ }
+
+ public async provideCodeLenses(
+ document: vscode.TextDocument,
+ token: vscode.CancellationToken,
+ ): Promise {
+ // Scoped to the document so a folder-level override wins in a
+ // multi-root workspace.
+ const config = vscode.workspace.getConfiguration('nanocoder', document.uri);
+ if (!config.get('codeLens', true)) {
+ return [];
+ }
+
+ // The language server already knows where the functions are, so nothing
+ // here has to parse a single line of source.
+ const symbols = await vscode.commands.executeCommand(
+ 'vscode.executeDocumentSymbolProvider',
+ document.uri,
+ );
+ if (token.isCancellationRequested || !Array.isArray(symbols)) {
+ return [];
+ }
+
+ const lenses: vscode.CodeLens[] = [];
+ const walk = (nodes: vscode.DocumentSymbol[]) => {
+ for (const symbol of nodes) {
+ // selectionRange is absent on the legacy SymbolInformation shape
+ // some providers still return; skip those rather than throw.
+ if (LENS_SYMBOL_KINDS.has(symbol.kind) && symbol.selectionRange) {
+ // Anchored on the name so the lens sits on the declaration
+ // line instead of above a preceding doc comment, while the
+ // command still receives the symbol's whole body.
+ const args = [document.uri, symbol.range];
+ lenses.push(
+ new vscode.CodeLens(symbol.selectionRange, {
+ title: 'Explain Code',
+ command: 'nanocoder.explainCode',
+ arguments: args,
+ }),
+ new vscode.CodeLens(symbol.selectionRange, {
+ title: 'Generate Tests',
+ command: 'nanocoder.generateTests',
+ arguments: args,
+ }),
+ );
+ }
+ walk(symbol.children ?? []);
+ }
+ };
+ walk(symbols);
+
+ return lenses;
+ }
+}
+
+export async function sendCodeLensPrompt(
+ chatProvider: ChatWebviewProvider,
+ instruction: string,
+ uri?: vscode.Uri,
+ range?: vscode.Range,
+): Promise {
+ // The commands are hidden from the palette, but a keybinding or another
+ // extension can still invoke them with no lens arguments.
+ if (!uri || !range) {
+ vscode.window.showInformationMessage(
+ 'Nanocoder: use the Explain Code / Generate Tests links above a function to run this.',
+ );
+ return;
+ }
+
+ const document = await vscode.workspace.openTextDocument(uri);
+ await chatProvider.sendPrompt(
+ buildCodeLensPrompt({
+ instruction,
+ relativePath: vscode.workspace.asRelativePath(uri),
+ startLine: range.start.line + 1,
+ endLine: range.end.line + 1,
+ languageId: document.languageId,
+ source: document.getText(range),
+ }),
+ );
+}
diff --git a/plugins/vscode/src/extension.ts b/plugins/vscode/src/extension.ts
index 73ff365e4..49827d49e 100644
--- a/plugins/vscode/src/extension.ts
+++ b/plugins/vscode/src/extension.ts
@@ -13,6 +13,10 @@ import {AcpStateManager, ACPStatus} from './acp-state';
import {NanocoderAcpClient} from './acp-client';
import {AcpProcessManager} from './acp-process-manager';
import {ChatWebviewProvider} from './chat-webview-provider';
+import {
+ NanocoderCodeLensProvider,
+ sendCodeLensPrompt,
+} from './code-lens-provider';
const DEFAULT_PORT = 51820;
const ACTIVE_EDITOR_DEBOUNCE_MS = 150;
@@ -55,6 +59,7 @@ export function activate(context: vscode.ExtensionContext) {
// Register Webview Provider
const chatProvider = new ChatWebviewProvider(context.extensionUri, outputChannel, acpClient, diffManager);
context.subscriptions.push(
+ chatProvider,
vscode.window.registerWebviewViewProvider(ChatWebviewProvider.viewType, chatProvider, {
// Preserve DOM when user switches to Explorer/SCM/etc. and back.
// Without this VS Code destroys the webview on hide, wiping the transcript.
@@ -62,10 +67,13 @@ export function activate(context: vscode.ExtensionContext) {
})
);
- // Register Title Bar Action
+ // Register Title Bar Actions
context.subscriptions.push(
vscode.commands.registerCommand('nanocoder.toggleHistory', () => {
chatProvider.toggleHistory();
+ }),
+ vscode.commands.registerCommand('nanocoder.toggleSettings', () => {
+ chatProvider.toggleSettings();
})
);
@@ -113,6 +121,25 @@ export function activate(context: vscode.ExtensionContext) {
}),
);
+ // Inline "Explain Code" / "Generate Tests" links above every function and
+ // class, so a symbol can be handed to the agent without leaving the editor.
+ const codeLensProvider = new NanocoderCodeLensProvider();
+ context.subscriptions.push(
+ codeLensProvider,
+ vscode.languages.registerCodeLensProvider({scheme: 'file'}, codeLensProvider),
+ vscode.workspace.onDidChangeConfiguration(event => {
+ if (event.affectsConfiguration('nanocoder.codeLens')) {
+ codeLensProvider.refresh();
+ }
+ }),
+ vscode.commands.registerCommand('nanocoder.explainCode', (uri?: vscode.Uri, range?: vscode.Range) =>
+ sendCodeLensPrompt(chatProvider, 'Explain what this code does.', uri, range),
+ ),
+ vscode.commands.registerCommand('nanocoder.generateTests', (uri?: vscode.Uri, range?: vscode.Range) =>
+ sendCodeLensPrompt(chatProvider, 'Write unit tests for this code.', uri, range),
+ ),
+ );
+
// Push active editor state to the CLI so the input box can show an
// "In " pill and auto-attach a selection as context on submit.
context.subscriptions.push(
diff --git a/plugins/vscode/src/settings-manager.spec.ts b/plugins/vscode/src/settings-manager.spec.ts
new file mode 100644
index 000000000..e9d7a9ddd
--- /dev/null
+++ b/plugins/vscode/src/settings-manager.spec.ts
@@ -0,0 +1,144 @@
+import test from 'ava';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as crypto from 'crypto';
+import * as os from 'os';
+import { SettingsManager } from './settings-manager';
+
+// Create a mock output channel
+const mockOutputChannel = {
+ appendLine: (msg: string) => {},
+};
+
+test.serial('SettingsManager - getConfigPaths resolves project paths correctly', (t) => {
+ const manager = new SettingsManager(mockOutputChannel);
+ const cwd = process.cwd(); // mock cwd
+ const paths = manager.getConfigPaths(cwd);
+
+ t.is(typeof paths.agentsConfig, 'string');
+ t.is(typeof paths.preferences, 'string');
+});
+
+test.serial('SettingsManager - returns fallback values for empty or missing config', (t) => {
+ const manager = new SettingsManager(mockOutputChannel);
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanocoder-test-'));
+
+ // Create a dummy SettingsManager that uses this temp dir as cwd and global dir
+ // We'll override getGlobalConfigDir to point to tempDir to avoid reading real configs
+ const anyManager = manager as any;
+ anyManager.getGlobalConfigDir = () => tempDir;
+
+ const settings = manager.readSettings(tempDir);
+ t.deepEqual(settings.providers, []);
+ t.deepEqual(settings.mcpServers, []);
+ t.deepEqual(settings.alwaysAllow, []);
+ t.is(settings.defaultMode, null);
+ t.is(settings.autoCompact.enabled, true);
+ t.is(settings.autoCompact.threshold, 60);
+ t.is(settings.autoCompact.mode, 'conservative');
+ t.is(settings.reasoningTraces, false);
+ t.is(settings.sessions.autoSave, true);
+ t.is(settings.webSearch.configured, false);
+
+ fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+test.serial('SettingsManager - updates setting correctly (atomic write)', (t) => {
+ const manager = new SettingsManager(mockOutputChannel);
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanocoder-test-'));
+ const anyManager = manager as any;
+ anyManager.getGlobalConfigDir = () => tempDir;
+
+ // Initial write
+ const result = manager.updateSetting(tempDir, 'defaultMode', 'yolo');
+ t.is(result.success, true);
+
+ // Verify file was written
+ const agentsConfigPath = path.join(tempDir, 'agents.config.json');
+ t.is(fs.existsSync(agentsConfigPath), true);
+
+ // Verify content
+ const content = JSON.parse(fs.readFileSync(agentsConfigPath, 'utf8'));
+ t.is(content.nanocoder.defaultMode, 'yolo');
+
+ // Verify readSettings picks it up
+ const settings = manager.readSettings(tempDir);
+ t.is(settings.defaultMode, 'yolo');
+
+ fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+test.serial('SettingsManager - handles invalid JSON gracefully on read', (t) => {
+ const manager = new SettingsManager(mockOutputChannel);
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanocoder-test-'));
+ const anyManager = manager as any;
+ anyManager.getGlobalConfigDir = () => tempDir;
+
+ const agentsConfigPath = path.join(tempDir, 'agents.config.json');
+ fs.writeFileSync(agentsConfigPath, '{ invalid: json }'); // Syntax error
+
+ const settings = manager.readSettings(tempDir);
+ t.is(settings.defaultMode, null); // Falls back to default gracefully
+
+ fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+test.serial('SettingsManager - prevents update when JSON is invalid', (t) => {
+ const manager = new SettingsManager(mockOutputChannel);
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanocoder-test-'));
+ const anyManager = manager as any;
+ anyManager.getGlobalConfigDir = () => tempDir;
+
+ const agentsConfigPath = path.join(tempDir, 'agents.config.json');
+ fs.writeFileSync(agentsConfigPath, '{ invalid: json }'); // Syntax error
+
+ const result = manager.updateSetting(tempDir, 'defaultMode', 'yolo');
+ t.is(result.success, false);
+ t.regex(result.error || '', /invalid JSON/);
+
+ fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+test.serial('SettingsManager - validates defaultMode values', (t) => {
+ const manager = new SettingsManager(mockOutputChannel);
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanocoder-test-'));
+ const anyManager = manager as any;
+ anyManager.getGlobalConfigDir = () => tempDir;
+
+ // Invalid value
+ const result = manager.updateSetting(tempDir, 'defaultMode', 'chat');
+ t.is(result.success, false);
+ t.regex(result.error || '', /Invalid defaultMode/);
+
+ // Invalid type
+ const result2 = manager.updateSetting(tempDir, 'defaultMode', 123);
+ t.is(result2.success, false);
+ t.regex(result2.error || '', /Invalid defaultMode value type/);
+
+ // Valid value
+ const result3 = manager.updateSetting(tempDir, 'defaultMode', 'yolo');
+ t.is(result3.success, true);
+
+ fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+test.serial('SettingsManager - validates autoCompact.threshold values', (t) => {
+ const manager = new SettingsManager(mockOutputChannel);
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanocoder-test-'));
+ const anyManager = manager as any;
+ anyManager.getGlobalConfigDir = () => tempDir;
+
+ // Invalid type
+ const result = manager.updateSetting(tempDir, 'autoCompact.threshold', 'high');
+ t.is(result.success, false);
+ t.regex(result.error || '', /must be a number/);
+
+ // Valid value is clamped
+ const result2 = manager.updateSetting(tempDir, 'autoCompact.threshold', 200);
+ t.is(result2.success, true);
+
+ const settings = manager.readSettings(tempDir);
+ t.is(settings.autoCompact.threshold, 95); // clamped to max 95
+
+ fs.rmSync(tempDir, { recursive: true, force: true });
+});
diff --git a/plugins/vscode/src/settings-manager.ts b/plugins/vscode/src/settings-manager.ts
new file mode 100644
index 000000000..7cbeb6783
--- /dev/null
+++ b/plugins/vscode/src/settings-manager.ts
@@ -0,0 +1,307 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import * as crypto from 'crypto';
+
+/**
+ * Shape of the settings data sent to the webview. This is a flattened,
+ * UI-friendly view of agents.config.json + nanocoder-preferences.json.
+ */
+export interface SettingsData {
+ providers: Array<{ name: string; baseUrl?: string; models: string[]; apiKeySet: boolean }>;
+ mcpServers: Array<{ name: string; transport: string; command?: string; url?: string }>;
+ alwaysAllow: string[];
+ defaultMode: string | null;
+ autoCompact: { enabled: boolean; threshold: number; mode: string };
+ reasoningTraces: boolean;
+ sessions: { autoSave: boolean };
+ webSearch: { configured: boolean };
+}
+
+/**
+ * Manages reading and writing Nanocoder configuration files from the
+ * extension host. Mirrors the resolution logic in the CLI's config/index.ts:
+ * 1. Check /agents.config.json
+ * 2. Fall back to ~/.config/nanocoder/agents.config.json
+ * Same for nanocoder-preferences.json.
+ */
+export class SettingsManager {
+ constructor(private outputChannel: { appendLine: (msg: string) => void }) {}
+
+ /**
+ * Discover the active config file paths, preferring project-level files.
+ */
+ getConfigPaths(cwd: string): { agentsConfig: string; preferences: string } {
+ const globalDir = this.getGlobalConfigDir();
+
+ const agentsConfig = this.resolveConfigPath(cwd, globalDir, 'agents.config.json');
+ const preferences = this.resolveConfigPath(cwd, globalDir, 'nanocoder-preferences.json');
+
+ return { agentsConfig, preferences };
+ }
+
+ /**
+ * Read current settings from disk and return a flattened SettingsData.
+ */
+ readSettings(cwd: string): SettingsData {
+ const paths = this.getConfigPaths(cwd);
+ const agentsConfig = this.readJsonSafe(paths.agentsConfig);
+ const preferences = this.readJsonSafe(paths.preferences);
+
+ const nc = agentsConfig?.nanocoder ?? {};
+
+ // Parse providers — mask API keys
+ const providers = Array.isArray(nc.providers) ? nc.providers.map((p: any) => ({
+ name: p.name || 'unnamed',
+ baseUrl: p.baseUrl,
+ models: Array.isArray(p.models) ? p.models : [],
+ apiKeySet: Boolean(p.apiKey),
+ })) : [];
+
+ // Parse MCP servers
+ const mcpServers = Array.isArray(nc.mcpServers) ? nc.mcpServers.map((s: any) => ({
+ name: s.name || 'unnamed',
+ transport: s.transport || 'stdio',
+ command: s.command,
+ url: s.url,
+ })) : [];
+
+ // Parse alwaysAllow
+ const alwaysAllow: string[] = Array.isArray(nc.alwaysAllow)
+ ? nc.alwaysAllow.filter((x: unknown) => typeof x === 'string')
+ : [];
+
+ // Parse defaultMode
+ const validModes = ['normal', 'auto-accept', 'yolo', 'plan'];
+ let defaultMode: string | null = typeof nc.defaultMode === 'string' ? nc.defaultMode : null;
+ if (defaultMode && !validModes.includes(defaultMode)) {
+ defaultMode = 'normal';
+ }
+
+ // Parse autoCompact
+ const ac = nc.autoCompact ?? {};
+ const autoCompact = {
+ enabled: ac.enabled !== false,
+ threshold: typeof ac.threshold === 'number' ? ac.threshold : 60,
+ mode: typeof ac.mode === 'string' ? ac.mode : 'conservative',
+ };
+
+ // Parse reasoning traces from preferences
+ const reasoningTraces = preferences?.reasoningExpanded ?? false;
+
+ // Parse sessions from preferences
+ const sessionsPref = preferences?.nanocoder?.sessions ?? {};
+ const sessions = {
+ autoSave: sessionsPref.autoSave !== false,
+ };
+
+ // Web search
+ const webSearch = {
+ configured: Boolean(nc.nanocoderTools?.webSearch?.apiKey),
+ };
+
+ return {
+ providers,
+ mcpServers,
+ alwaysAllow,
+ defaultMode,
+ autoCompact,
+ reasoningTraces,
+ sessions,
+ webSearch,
+ };
+ }
+
+ /**
+ * Update a setting by dot-notated key. Returns success/error.
+ *
+ * Supported keys:
+ * - 'defaultMode' → agents.config.json → nanocoder.defaultMode
+ * - 'autoCompact.enabled' → agents.config.json → nanocoder.autoCompact.enabled
+ * - 'autoCompact.threshold' → agents.config.json → nanocoder.autoCompact.threshold
+ * - 'autoCompact.mode' → agents.config.json → nanocoder.autoCompact.mode
+ * - 'reasoningTraces' → nanocoder-preferences.json → reasoningExpanded
+ * - 'sessions.autoSave' → nanocoder-preferences.json → nanocoder.sessions.autoSave
+ */
+ updateSetting(cwd: string, key: string, value: unknown): { success: boolean; error?: string } {
+ try {
+ const paths = this.getConfigPaths(cwd);
+
+ if (key === 'defaultMode') {
+ if (value === null) {
+ this.updateAgentsConfig(paths.agentsConfig, 'defaultMode', null);
+ } else if (typeof value === 'string') {
+ const normalized = value.toLowerCase().trim();
+ const validModes = ['normal', 'auto-accept', 'yolo', 'plan'] as const;
+ if (!validModes.includes(normalized as (typeof validModes)[number])) {
+ return { success: false, error: `Invalid defaultMode: ${value}` };
+ }
+ this.updateAgentsConfig(paths.agentsConfig, 'defaultMode', normalized);
+ } else {
+ return {
+ success: false,
+ error: `Invalid defaultMode value type: ${typeof value}`,
+ };
+ }
+ } else if (key === 'autoCompact.enabled') {
+ if (typeof value !== 'boolean') {
+ return { success: false, error: 'autoCompact.enabled must be a boolean' };
+ }
+ this.updateAgentsConfigNested(paths.agentsConfig, 'autoCompact', 'enabled', value);
+ } else if (key === 'autoCompact.threshold') {
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ return { success: false, error: 'autoCompact.threshold must be a number' };
+ }
+ const threshold = Math.max(50, Math.min(95, Math.round(value)));
+ this.updateAgentsConfigNested(paths.agentsConfig, 'autoCompact', 'threshold', threshold);
+ } else if (key === 'autoCompact.mode') {
+ if (typeof value !== 'string') {
+ return { success: false, error: 'autoCompact.mode must be a string' };
+ }
+ const validModes = ['conservative', 'default', 'aggressive'] as const;
+ if (!validModes.includes(value as (typeof validModes)[number])) {
+ return { success: false, error: `Invalid autoCompact.mode: ${value}` };
+ }
+ this.updateAgentsConfigNested(paths.agentsConfig, 'autoCompact', 'mode', value);
+ } else if (key === 'reasoningTraces') {
+ if (typeof value !== 'boolean') {
+ return { success: false, error: 'reasoningTraces must be a boolean' };
+ }
+ this.updatePreferences(paths.preferences, 'reasoningExpanded', value);
+ } else if (key === 'sessions.autoSave') {
+ if (typeof value !== 'boolean') {
+ return { success: false, error: 'sessions.autoSave must be a boolean' };
+ }
+ this.updatePreferencesNested(paths.preferences, 'nanocoder', 'sessions', 'autoSave', value);
+ } else {
+ return { success: false, error: `Unknown setting key: ${key}` };
+ }
+
+ return { success: true };
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ this.outputChannel.appendLine(`[Settings] Failed to update ${key}: ${msg}`);
+ return { success: false, error: msg };
+ }
+ }
+
+ // ----- Private helpers -----
+
+ private getGlobalConfigDir(): string {
+ if (process.env.NANOCODER_CONFIG_DIR) {
+ return process.env.NANOCODER_CONFIG_DIR;
+ }
+
+ let baseConfigPath: string;
+ switch (process.platform) {
+ case 'win32':
+ baseConfigPath = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming');
+ break;
+ case 'darwin':
+ baseConfigPath = path.join(os.homedir(), 'Library', 'Preferences');
+ break;
+ default:
+ baseConfigPath = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config');
+ }
+ return path.join(baseConfigPath, 'nanocoder');
+ }
+
+ /**
+ * Resolve a config file: project-level first, then global.
+ * If neither exists, return the global path (it will be created on write).
+ */
+ private resolveConfigPath(cwd: string, globalDir: string, fileName: string): string {
+ // fileName is never user input - both call sites pass a string literal.
+ // Same shape as getConfigPath() in source/config/index.ts.
+ const projectPath = path.join(cwd, fileName); // nosemgrep
+ if (fs.existsSync(projectPath)) {
+ return projectPath;
+ }
+ return path.join(globalDir, fileName); // nosemgrep
+ }
+
+ private readJsonSafe(filePath: string): any {
+ try {
+ if (fs.existsSync(filePath)) {
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
+ }
+ return {};
+ } catch (error) {
+ this.outputChannel.appendLine(`[Settings] Failed to read ${filePath}: ${error}`);
+ return null;
+ }
+ }
+
+ private updateAgentsConfig(configPath: string, key: string, value: unknown): void {
+ let config = this.readJsonSafe(configPath);
+ if (config === null) throw new Error(`Config file ${configPath} contains invalid JSON. Cannot update.`);
+ config = config || {};
+ if (!config.nanocoder || typeof config.nanocoder !== 'object') {
+ config.nanocoder = {};
+ }
+ config.nanocoder[key] = value;
+ this.atomicWrite(configPath, config);
+ }
+
+ private updateAgentsConfigNested(configPath: string, parentKey: string, childKey: string, value: unknown): void {
+ let config = this.readJsonSafe(configPath);
+ if (config === null) throw new Error(`Config file ${configPath} contains invalid JSON. Cannot update.`);
+ config = config || {};
+ if (!config.nanocoder || typeof config.nanocoder !== 'object') {
+ config.nanocoder = {};
+ }
+ if (!config.nanocoder[parentKey] || typeof config.nanocoder[parentKey] !== 'object') {
+ config.nanocoder[parentKey] = {};
+ }
+ config.nanocoder[parentKey][childKey] = value;
+ this.atomicWrite(configPath, config);
+ }
+
+ private updatePreferences(filePath: string, key: string, value: unknown): void {
+ let prefs = this.readJsonSafe(filePath);
+ if (prefs === null) throw new Error(`Preferences file ${filePath} contains invalid JSON. Cannot update.`);
+ prefs = prefs || {};
+ prefs[key] = value;
+ this.atomicWrite(filePath, prefs);
+ }
+
+ private updatePreferencesNested(filePath: string, ...keys: (string | unknown)[]): void {
+ let prefs = this.readJsonSafe(filePath);
+ if (prefs === null) throw new Error(`Preferences file ${filePath} contains invalid JSON. Cannot update.`);
+ prefs = prefs || {};
+ const value = keys[keys.length - 1];
+ const pathArgs = keys.slice(0, -1) as string[];
+
+ let obj = prefs;
+ for (let i = 0; i < pathArgs.length - 1; i++) {
+ if (!obj[pathArgs[i]] || typeof obj[pathArgs[i]] !== 'object') {
+ obj[pathArgs[i]] = {};
+ }
+ // Keys are string literals from this file's own call sites, never
+ // user input, so no __proto__ can reach the walk.
+ obj = obj[pathArgs[i]]; // nosemgrep
+ }
+ obj[pathArgs[pathArgs.length - 1]] = value;
+ this.atomicWrite(filePath, prefs);
+ }
+
+ /**
+ * Atomic write: write to a temp file, then rename. Mirrors
+ * config-writer.ts's atomicWriteFileSync pattern to prevent
+ * truncated config files on crash.
+ */
+ private atomicWrite(filePath: string, data: unknown): void {
+ const dir = path.dirname(filePath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`;
+ try {
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
+ fs.renameSync(tmpPath, filePath);
+ } catch (error) {
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore cleanup error */ }
+ throw error;
+ }
+ }
+}
diff --git a/plugins/vscode/src/styles.css b/plugins/vscode/src/styles.css
index 4b91827af..af2468fe2 100644
--- a/plugins/vscode/src/styles.css
+++ b/plugins/vscode/src/styles.css
@@ -268,3 +268,38 @@ select option {
cursor: pointer;
font-family: var(--vscode-font-family);
}
+
+/* ── Settings UI ────────────────────────────────────── */
+.settings-tab { background: transparent; border: none; border-bottom: 2px solid transparent; color: var(--vscode-editor-foreground); opacity: 0.6; padding: 0.5rem 0.75rem; font-family: var(--vscode-font-family); font-size: 0.85em; cursor: pointer; transition: opacity 0.15s, border-color 0.15s; }
+.settings-tab:hover { opacity: 0.9; }
+.settings-tab.active { opacity: 1; border-bottom-color: var(--vscode-focusBorder); font-weight: 600; }
+.settings-section { background: var(--vscode-editorWidget-background); border: 1px solid var(--vscode-widget-border); border-radius: 6px; padding: 0.75rem; }
+.settings-section-title { font-size: 0.8em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.6; margin-bottom: 0.5rem; }
+.settings-list { display: flex; flex-direction: column; gap: 0; }
+.settings-list-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.9em; border-bottom: 1px solid var(--vscode-widget-border); }
+.settings-list-item:last-child { border-bottom: none; }
+.settings-list-item-name { font-weight: 500; flex-shrink: 0; }
+.settings-list-item-detail { opacity: 0.6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
+.settings-list-empty { font-size: 0.85em; opacity: 0.5; padding: 0.25rem 0; font-style: italic; }
+.settings-row { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.375rem 0; border-bottom: 1px solid var(--vscode-widget-border); }
+.settings-row:last-child { border-bottom: none; }
+.settings-row-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
+.settings-row-label { font-size: 0.9em; font-weight: 500; }
+.settings-row-desc { font-size: 0.78em; opacity: 0.55; margin-top: 0.1em; }
+.settings-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; cursor: pointer; }
+.settings-toggle input { opacity: 0; width: 0; height: 0; }
+.settings-toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, transparent); border-radius: 20px; transition: background-color 0.2s; }
+.settings-toggle-slider:before { content: ""; position: absolute; height: 14px; width: 14px; left: 2px; bottom: 2px; background-color: var(--vscode-editor-foreground); opacity: 0.6; border-radius: 50%; transition: transform 0.2s, opacity 0.2s; }
+.settings-toggle input:checked + .settings-toggle-slider { background-color: var(--vscode-button-background); border-color: var(--vscode-button-background); }
+.settings-toggle input:checked + .settings-toggle-slider:before { transform: translateX(16px); opacity: 1; background-color: var(--vscode-button-foreground); }
+.settings-select { background-color: var(--vscode-dropdown-background); color: var(--vscode-dropdown-foreground); border: 1px solid var(--vscode-dropdown-border, transparent); border-radius: 4px; padding: 0.25rem 0.5rem; font-family: var(--vscode-font-family); font-size: 0.85em; cursor: pointer; outline: none; min-width: 100px; }
+.settings-select:focus { border-color: var(--vscode-focusBorder); }
+.settings-number-input { background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border, transparent); border-radius: 4px; padding: 0.25rem 0.5rem; font-family: var(--vscode-font-family); font-size: 0.85em; width: 70px; outline: none; text-align: center; }
+.settings-number-input:focus { border-color: var(--vscode-focusBorder); }
+.settings-action-btn { display: flex; align-items: center; gap: 0.5rem; background: transparent; border: 1px solid var(--vscode-button-secondaryBackground); color: var(--vscode-editor-foreground); border-radius: 4px; padding: 0.375rem 0.625rem; font-family: var(--vscode-font-family); font-size: 0.85em; cursor: pointer; transition: background-color 0.15s; }
+.settings-action-btn:hover { background-color: var(--vscode-button-secondaryHoverBackground); }
+.settings-action-btn-danger { border-color: rgba(241, 76, 76, 0.4); color: #f14c4c; }
+.settings-action-btn-danger:hover { background-color: rgba(241, 76, 76, 0.1); }
+.settings-badge { display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.78em; padding: 0.125rem 0.375rem; border-radius: 3px; font-weight: 500; }
+.settings-badge-ok { background-color: rgba(137, 209, 133, 0.15); color: #89d185; }
+.settings-badge-off { background-color: hsla(0, 0%, 80%, 0.1); color: #999; }
diff --git a/plugins/vscode/src/webview-protocol.ts b/plugins/vscode/src/webview-protocol.ts
index 1b9e57e77..98eb7eb9d 100644
--- a/plugins/vscode/src/webview-protocol.ts
+++ b/plugins/vscode/src/webview-protocol.ts
@@ -82,6 +82,12 @@ export interface ExtensionMessageCopyLastCodeBlock {
type: 'copyLastCodeBlock';
}
+/** Prompt built by an editor code lens; the composer submits it verbatim. */
+export interface ExtensionMessageRunPrompt {
+ type: 'runPrompt';
+ text: string;
+}
+
export interface ExtensionMessageCopyResult {
type: 'copyResult';
ok: boolean;
@@ -114,6 +120,31 @@ export interface ExtensionMessageUpdateSessions {
}>;
}
+export interface ExtensionMessageSettingsData {
+ type: 'settingsData';
+ settings: {
+ providers: Array<{ name: string; baseUrl?: string; models: string[]; apiKeySet: boolean }>;
+ mcpServers: Array<{ name: string; transport: string; command?: string; url?: string }>;
+ alwaysAllow: string[];
+ defaultMode: string | null;
+ autoCompact: { enabled: boolean; threshold: number; mode: string };
+ reasoningTraces: boolean;
+ sessions: { autoSave: boolean };
+ webSearch: { configured: boolean };
+ };
+}
+
+export interface ExtensionMessageSettingsUpdated {
+ type: 'settingsUpdated';
+ key: string;
+ success: boolean;
+ error?: string;
+}
+
+export interface ExtensionMessageToggleSettings {
+ type: 'toggleSettings';
+}
+
export interface ExtensionMessagePathInfoResolved {
type: 'pathInfoResolved';
path: string;
@@ -162,9 +193,13 @@ export type ExtensionToWebviewMessage =
| ExtensionMessageSyncState
| ExtensionMessageUpdateSessions
| ExtensionMessageSessionLoaded
+ | ExtensionMessageSettingsData
+ | ExtensionMessageSettingsUpdated
+ | ExtensionMessageToggleSettings
| ExtensionMessagePathInfoResolved
| ExtensionMessageCopyLastCodeBlock
| ExtensionMessageCopyResult
+ | ExtensionMessageRunPrompt
| ExtensionMessageMentionCompletions
| ExtensionMessageUpdateTimeline;
@@ -239,6 +274,25 @@ export interface WebviewMessageDeleteSession {
sessionId: string;
}
+export interface WebviewMessageRequestSettings {
+ type: 'requestSettings';
+}
+
+export interface WebviewMessageUpdateSetting {
+ type: 'updateSetting';
+ key: string;
+ value: unknown;
+}
+
+export interface WebviewMessageOpenConfigFile {
+ type: 'openConfigFile';
+ file: 'agents.config.json' | 'nanocoder-preferences.json';
+}
+
+export interface WebviewMessageRestartAcp {
+ type: 'restartAcp';
+}
+
export interface WebviewMessageRenameSession {
type: 'renameSession';
sessionId: string;
@@ -306,6 +360,10 @@ export type WebviewToExtensionMessage =
| WebviewMessageListSessions
| WebviewMessageResumeSession
| WebviewMessageDeleteSession
+ | WebviewMessageRequestSettings
+ | WebviewMessageUpdateSetting
+ | WebviewMessageOpenConfigFile
+ | WebviewMessageRestartAcp
| WebviewMessageRenameSession
| WebviewMessageRequestPathInfo
| WebviewMessageRequestOpenDialog
diff --git a/plugins/vscode/test-stubs/vscode.ts b/plugins/vscode/test-stubs/vscode.ts
new file mode 100644
index 000000000..7b256b822
--- /dev/null
+++ b/plugins/vscode/test-stubs/vscode.ts
@@ -0,0 +1,288 @@
+/**
+ * Runtime stand-in for the `vscode` module.
+ *
+ * The real module is injected by the extension host and cannot be resolved from
+ * plain Node, so nothing under `plugins/vscode/src` that imports it was
+ * loadable by AVA. The root tsconfig maps the bare `vscode` specifier here (see
+ * its `paths` entry) so the extension's units can be exercised without an
+ * extension host.
+ *
+ * Only the surface the extension actually touches is implemented. Anything a
+ * test needs to steer is overridable through `__test`.
+ */
+
+export class Position {
+ constructor(
+ public readonly line: number,
+ public readonly character: number,
+ ) {}
+}
+
+export class Range {
+ public readonly start: Position;
+ public readonly end: Position;
+
+ constructor(start: Position, end: Position);
+ constructor(
+ startLine: number,
+ startCharacter: number,
+ endLine: number,
+ endCharacter: number,
+ );
+ constructor(
+ a: Position | number,
+ b?: Position | number,
+ c?: number,
+ d?: number,
+ ) {
+ if (typeof a === 'number') {
+ this.start = new Position(a, b as number);
+ this.end = new Position(c as number, d as number);
+ } else {
+ this.start = a;
+ this.end = b as Position;
+ }
+ }
+
+ get isEmpty(): boolean {
+ return (
+ this.start.line === this.end.line &&
+ this.start.character === this.end.character
+ );
+ }
+}
+
+export class Selection extends Range {}
+
+export interface Command {
+ title: string;
+ command: string;
+ arguments?: unknown[];
+}
+
+export class CodeLens {
+ constructor(
+ public readonly range: Range,
+ public readonly command?: Command,
+ ) {}
+
+ get isResolved(): boolean {
+ return this.command !== undefined;
+ }
+}
+
+export class Disposable {
+ constructor(private readonly _callOnDispose: () => void) {}
+ dispose(): void {
+ this._callOnDispose();
+ }
+}
+
+export class EventEmitter {
+ private readonly _listeners = new Set<(e: T) => unknown>();
+
+ // A bound property, not a method: consumers hand `emitter.event` out
+ // directly as their public `onDidX`.
+ public readonly event = (listener: (e: T) => unknown) => {
+ this._listeners.add(listener);
+ return new Disposable(() => this._listeners.delete(listener));
+ };
+
+ fire(data: T): void {
+ for (const listener of [...this._listeners]) listener(data);
+ }
+
+ dispose(): void {
+ this._listeners.clear();
+ }
+}
+
+export class Uri {
+ private constructor(
+ public readonly scheme: string,
+ public readonly fsPath: string,
+ ) {}
+
+ static file(fsPath: string): Uri {
+ return new Uri('file', fsPath);
+ }
+
+ static joinPath(base: Uri, ...parts: string[]): Uri {
+ return new Uri(base.scheme, [base.fsPath, ...parts].join('/'));
+ }
+
+ get path(): string {
+ return this.fsPath;
+ }
+
+ with(_change: Record): Uri {
+ return this;
+ }
+
+ toString(): string {
+ return `${this.scheme}://${this.fsPath}`;
+ }
+}
+
+export enum SymbolKind {
+ File = 0,
+ Module = 1,
+ Namespace = 2,
+ Package = 3,
+ Class = 4,
+ Method = 5,
+ Property = 6,
+ Field = 7,
+ Constructor = 8,
+ Enum = 9,
+ Interface = 10,
+ Function = 11,
+ Variable = 12,
+ Constant = 13,
+ String = 14,
+ Number = 15,
+ Boolean = 16,
+ Array = 17,
+ Object = 18,
+ Key = 19,
+ Null = 20,
+ EnumMember = 21,
+ Struct = 22,
+ Event = 23,
+ Operator = 24,
+ TypeParameter = 25,
+}
+
+export enum ConfigurationTarget {
+ Global = 1,
+ Workspace = 2,
+ WorkspaceFolder = 3,
+}
+
+export enum StatusBarAlignment {
+ Left = 1,
+ Right = 2,
+}
+
+export enum DiagnosticSeverity {
+ Error = 0,
+ Warning = 1,
+ Information = 2,
+ Hint = 3,
+}
+
+export enum ViewColumn {
+ Active = -1,
+ One = 1,
+}
+
+/**
+ * Test-controlled behaviour. Every hook falls back to an inert default, so a
+ * test only overrides what it cares about; `reset()` restores all of them.
+ */
+export const __test = {
+ /** Backs `workspace.getConfiguration(section, scope).get(key, default)`. */
+ configuration: (_section: string, _key: string, fallback: unknown): unknown =>
+ fallback,
+ /** Backs `commands.executeCommand`. */
+ executeCommand: async (_command: string, ..._args: unknown[]): Promise =>
+ undefined,
+ /** Backs `workspace.openTextDocument`. */
+ openTextDocument: async (_uri: Uri): Promise => ({
+ languageId: 'plaintext',
+ getText: () => '',
+ }),
+ /** Backs `workspace.asRelativePath`. */
+ asRelativePath: (target: Uri | string): string =>
+ typeof target === 'string' ? target : target.fsPath,
+ /** Messages surfaced through `window.show*Message`, newest last. */
+ shownMessages: [] as {kind: 'info' | 'warning' | 'error'; message: string}[],
+
+ reset(): void {
+ __test.configuration = (_s, _k, fallback) => fallback;
+ __test.executeCommand = async () => undefined;
+ __test.openTextDocument = async () => ({
+ languageId: 'plaintext',
+ getText: () => '',
+ });
+ __test.asRelativePath = target =>
+ typeof target === 'string' ? target : target.fsPath;
+ __test.shownMessages = [];
+ },
+};
+
+const noopDisposable = new Disposable(() => {});
+
+export const workspace = {
+ workspaceFolders: undefined as {uri: Uri}[] | undefined,
+ textDocuments: [] as unknown[],
+ getConfiguration(section: string, _scope?: unknown) {
+ return {
+ get: (key: string, fallback?: T): T =>
+ __test.configuration(section, key, fallback) as T,
+ update: async () => undefined,
+ };
+ },
+ openTextDocument: (uri: Uri) => __test.openTextDocument(uri),
+ asRelativePath: (target: Uri | string) => __test.asRelativePath(target),
+ onDidChangeConfiguration: () => noopDisposable,
+ onDidChangeTextDocument: () => noopDisposable,
+};
+
+export const commands = {
+ executeCommand: (command: string, ...args: unknown[]) =>
+ __test.executeCommand(command, ...args),
+ registerCommand: () => noopDisposable,
+};
+
+export const window = {
+ activeTextEditor: undefined as unknown,
+ showInformationMessage: async (message: string) => {
+ __test.shownMessages.push({kind: 'info', message});
+ return undefined;
+ },
+ showWarningMessage: async (message: string) => {
+ __test.shownMessages.push({kind: 'warning', message});
+ return undefined;
+ },
+ showErrorMessage: async (message: string) => {
+ __test.shownMessages.push({kind: 'error', message});
+ return undefined;
+ },
+ createOutputChannel: (_name: string) => ({
+ appendLine: () => {},
+ append: () => {},
+ show: () => {},
+ dispose: () => {},
+ }),
+ createStatusBarItem: () => ({
+ text: '',
+ tooltip: '',
+ command: '',
+ show: () => {},
+ hide: () => {},
+ dispose: () => {},
+ }),
+ createTerminal: () => ({sendText: () => {}, show: () => {}, dispose: () => {}}),
+ showTextDocument: async () => undefined,
+ showOpenDialog: async () => undefined,
+ registerWebviewViewProvider: () => noopDisposable,
+ onDidChangeActiveTextEditor: () => noopDisposable,
+ onDidChangeTextEditorSelection: () => noopDisposable,
+};
+
+export const languages = {
+ registerCodeLensProvider: () => noopDisposable,
+ getDiagnostics: () => [] as unknown[],
+};
+
+export const extensions = {
+ getExtension: (_id: string) => undefined as unknown,
+};
+
+export const env = {
+ clipboard: {
+ writeText: async (_text: string) => undefined,
+ readText: async () => '',
+ },
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fac397f81..83176a66f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -59,7 +59,7 @@ importers:
version: 0.6.5
clipboardy:
specifier: ^5.3.1
- version: 5.3.2
+ version: 5.3.1
croner:
specifier: ^10.0.1
version: 10.0.1
@@ -119,7 +119,7 @@ importers:
version: 10.0.0
ws:
specifier: ^8.18.0
- version: 8.21.3
+ version: 8.21.1
xdg-basedir:
specifier: ^5.1.0
version: 5.1.0
@@ -135,10 +135,10 @@ importers:
version: 2.5.0
'@changesets/cli':
specifier: ^2.31.0
- version: 2.31.0(@types/node@26.1.2)
+ version: 2.31.0(@types/node@26.2.0)
'@types/node':
specifier: ^26.1.2
- version: 26.1.2
+ version: 26.2.0
'@types/react':
specifier: ^19.0.0
version: 19.2.17
@@ -183,11 +183,11 @@ importers:
version: 1.3.0(zod@4.4.3)
ws:
specifier: ^8.16.0
- version: 8.21.3
+ version: 8.21.1
devDependencies:
'@types/node':
specifier: ^26.1.2
- version: 26.1.2
+ version: 26.2.0
'@types/vscode':
specifier: ^1.125.0
version: 1.125.0
@@ -199,7 +199,7 @@ importers:
version: 3.9.2
autoprefixer:
specifier: ^10.4.19
- version: 10.5.4(postcss@8.5.26)
+ version: 10.5.4(postcss@8.5.25)
concurrently:
specifier: ^8.2.2
version: 8.2.2
@@ -211,7 +211,7 @@ importers:
version: 10.4.1(jiti@1.21.7)
postcss:
specifier: ^8.4.38
- version: 8.5.26
+ version: 8.5.25
tailwindcss:
specifier: ^3.4.4
version: 3.4.19(tsx@4.22.4)(yaml@2.9.0)
@@ -1231,8 +1231,11 @@ packages:
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
- '@types/node@26.1.2':
- resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==}
+ '@types/node@25.9.3':
+ resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
+
+ '@types/node@26.2.0':
+ resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@@ -1655,8 +1658,8 @@ packages:
engines: {node: '>=20'}
hasBin: true
- clipboardy@5.3.2:
- resolution: {integrity: sha512-R35PENCHFCw6lsd5SjYPuAVV3Zawr74mKc7ogFNzoDPoQsmWDoJgUNNnWCk/czeqdZGZs8Y0M8zkOlVoySfHEQ==}
+ clipboardy@5.3.1:
+ resolution: {integrity: sha512-fPWgBqpp9ctiOQCkE5yjYGzv11ZU55g6ahEgr3COiio6dXdt1mbchCPXQrSR2Y9sZqfi8L7QD3+UosgXVIuPdg==}
engines: {node: '>=20'}
cliui@7.0.4:
@@ -2867,8 +2870,8 @@ packages:
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
- nanoid@3.3.18:
- resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
+ nanoid@3.3.16:
+ resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -3211,8 +3214,8 @@ packages:
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
- postcss@8.5.26:
- resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
+ postcss@8.5.25:
+ resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==}
engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.2.0:
@@ -3422,11 +3425,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
- semver@7.8.5:
- resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
- engines: {node: '>=10'}
- hasBin: true
-
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
@@ -3818,6 +3816,9 @@ packages:
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
+ undici-types@7.24.6:
+ resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
+
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
@@ -3959,8 +3960,8 @@ packages:
resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==}
engines: {node: ^20.17.0 || >=22.9.0}
- ws@8.21.3:
- resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
+ ws@8.21.1:
+ resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -4307,7 +4308,7 @@ snapshots:
dependencies:
'@changesets/types': 6.1.0
- '@changesets/cli@2.31.0(@types/node@26.1.2)':
+ '@changesets/cli@2.31.0(@types/node@26.2.0)':
dependencies:
'@changesets/apply-release-plan': 7.1.1
'@changesets/assemble-release-plan': 6.0.10
@@ -4323,7 +4324,7 @@ snapshots:
'@changesets/should-skip-package': 0.1.2
'@changesets/types': 6.1.0
'@changesets/write': 0.4.0
- '@inquirer/external-editor': 1.0.3(@types/node@26.1.2)
+ '@inquirer/external-editor': 1.0.3(@types/node@26.2.0)
'@manypkg/get-packages': 1.1.3
ansi-colors: 4.1.3
enquirer: 2.4.1
@@ -4579,12 +4580,12 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
- '@inquirer/external-editor@1.0.3(@types/node@26.1.2)':
+ '@inquirer/external-editor@1.0.3(@types/node@26.2.0)':
dependencies:
chardet: 2.2.0
iconv-lite: 0.7.2
optionalDependencies:
- '@types/node': 26.1.2
+ '@types/node': 26.2.0
'@isaacs/fs-minipass@4.0.1':
dependencies:
@@ -5023,7 +5024,11 @@ snapshots:
dependencies:
undici-types: 5.26.5
- '@types/node@26.1.2':
+ '@types/node@25.9.3':
+ dependencies:
+ undici-types: 7.24.6
+
+ '@types/node@26.2.0':
dependencies:
undici-types: 8.3.0
@@ -5041,7 +5046,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
- '@types/node': 26.1.2
+ '@types/node': 25.9.3
'@typespec/ts-http-runtime@0.3.6':
dependencies:
@@ -5245,13 +5250,13 @@ snapshots:
auto-bind@5.0.1: {}
- autoprefixer@10.5.4(postcss@8.5.26):
+ autoprefixer@10.5.4(postcss@8.5.25):
dependencies:
browserslist: 4.28.6
caniuse-lite: 1.0.30001806
fraction.js: 5.3.4
picocolors: 1.1.1
- postcss: 8.5.26
+ postcss: 8.5.25
postcss-value-parser: 4.2.0
ava@7.0.0(@ava/typescript@7.0.0):
@@ -5523,7 +5528,7 @@ snapshots:
dependencies:
run-jxa: 3.0.0
- clipboardy@5.3.2:
+ clipboardy@5.3.1:
dependencies:
clipboard-image: 0.1.0
execa: 9.6.1
@@ -6361,7 +6366,7 @@ snapshots:
type-fest: 5.7.0
widest-line: 6.0.0
wrap-ansi: 9.0.2
- ws: 8.21.3
+ ws: 8.21.1
yoga-layout: 3.2.1
optionalDependencies:
'@types/react': 19.2.17
@@ -6645,7 +6650,7 @@ snapshots:
macos-version@6.0.0:
dependencies:
- semver: 7.8.5
+ semver: 7.8.2
make-dir@4.0.0:
dependencies:
@@ -6739,7 +6744,7 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
- nanoid@3.3.18: {}
+ nanoid@3.3.16: {}
napi-build-utils@2.0.0:
optional: true
@@ -7054,30 +7059,30 @@ snapshots:
pluralize@8.0.0: {}
- postcss-import@15.1.0(postcss@8.5.26):
+ postcss-import@15.1.0(postcss@8.5.25):
dependencies:
- postcss: 8.5.26
+ postcss: 8.5.25
postcss-value-parser: 4.2.0
read-cache: 1.0.0
resolve: 1.22.12
- postcss-js@4.1.0(postcss@8.5.26):
+ postcss-js@4.1.0(postcss@8.5.25):
dependencies:
camelcase-css: 2.0.1
- postcss: 8.5.26
+ postcss: 8.5.25
- postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.22.4)(yaml@2.9.0):
+ postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.25)(tsx@4.22.4)(yaml@2.9.0):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 1.21.7
- postcss: 8.5.26
+ postcss: 8.5.25
tsx: 4.22.4
yaml: 2.9.0
- postcss-nested@6.2.0(postcss@8.5.26):
+ postcss-nested@6.2.0(postcss@8.5.25):
dependencies:
- postcss: 8.5.26
+ postcss: 8.5.25
postcss-selector-parser: 6.1.4
postcss-selector-parser@6.1.4:
@@ -7087,9 +7092,9 @@ snapshots:
postcss-value-parser@4.2.0: {}
- postcss@8.5.26:
+ postcss@8.5.25:
dependencies:
- nanoid: 3.3.18
+ nanoid: 3.3.16
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -7309,8 +7314,6 @@ snapshots:
semver@7.8.2: {}
- semver@7.8.5: {}
-
send@1.2.1:
dependencies:
debug: 4.4.3
@@ -7562,11 +7565,11 @@ snapshots:
normalize-path: 3.0.0
object-hash: 3.0.0
picocolors: 1.1.1
- postcss: 8.5.26
- postcss-import: 15.1.0(postcss@8.5.26)
- postcss-js: 4.1.0(postcss@8.5.26)
- postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.22.4)(yaml@2.9.0)
- postcss-nested: 6.2.0(postcss@8.5.26)
+ postcss: 8.5.25
+ postcss-import: 15.1.0(postcss@8.5.25)
+ postcss-js: 4.1.0(postcss@8.5.25)
+ postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.25)(tsx@4.22.4)(yaml@2.9.0)
+ postcss-nested: 6.2.0(postcss@8.5.25)
postcss-selector-parser: 6.1.4
resolve: 1.22.12
sucrase: 3.35.1
@@ -7741,6 +7744,8 @@ snapshots:
undici-types@5.26.5: {}
+ undici-types@7.24.6: {}
+
undici-types@8.3.0: {}
undici@7.29.0: {}
@@ -7857,7 +7862,7 @@ snapshots:
dependencies:
signal-exit: 4.1.0
- ws@8.21.3: {}
+ ws@8.21.1: {}
wsl-utils@0.1.0:
dependencies:
diff --git a/scripts/test.sh b/scripts/test.sh
index 4c1c24d36..7ecf127e6 100755
--- a/scripts/test.sh
+++ b/scripts/test.sh
@@ -15,6 +15,7 @@ echo ""
echo "🔍 Checking TypeScript types..."
pnpm test:types
+pnpm test:types:vscode
echo ""
echo "✅ Type check passed"
echo ""
diff --git a/source/acp/acp-agent.spec.ts b/source/acp/acp-agent.spec.ts
index 5db3b8c39..ad5447827 100644
--- a/source/acp/acp-agent.spec.ts
+++ b/source/acp/acp-agent.spec.ts
@@ -465,6 +465,44 @@ test('AcpAgent.cancel - aborts session for known session', async t => {
t.pass();
});
+test('AcpAgent.cancel - stops a turn cancelled before the loop reads the signal', async t => {
+ const context = createMockInitContext();
+ let chatCalls = 0;
+ (context.client as any).chat = async () => {
+ chatCalls++;
+ return {choices: [{message: {content: 'Test response'}}]};
+ };
+ const agent = new AcpAgent(context, createMockConn());
+ const session = await agent.newSession({cwd: '/tmp'});
+
+ const turn = agent.prompt({
+ sessionId: session.sessionId,
+ prompt: [{type: 'text', text: 'hi'}],
+ });
+ await agent.cancel({sessionId: session.sessionId});
+
+ t.is((await turn).stopReason, 'cancelled');
+ t.is(chatCalls, 0);
+});
+
+test('AcpAgent.prompt - a cancelled turn does not block the next prompt', async t => {
+ const {agent} = createAgent();
+ const session = await agent.newSession({cwd: '/tmp'});
+
+ const cancelled = agent.prompt({
+ sessionId: session.sessionId,
+ prompt: [{type: 'text', text: 'first'}],
+ });
+ await agent.cancel({sessionId: session.sessionId});
+ t.is((await cancelled).stopReason, 'cancelled');
+
+ const next = await agent.prompt({
+ sessionId: session.sessionId,
+ prompt: [{type: 'text', text: 'second'}],
+ });
+ t.is(next.stopReason, 'end_turn');
+});
+
// ============================================================================
// setSessionMode()
// ============================================================================
diff --git a/source/acp/acp-agent.ts b/source/acp/acp-agent.ts
index 385d70953..533ab18e0 100644
--- a/source/acp/acp-agent.ts
+++ b/source/acp/acp-agent.ts
@@ -165,6 +165,8 @@ export class AcpAgent implements Agent {
);
}
+ session.beginTurn();
+
const {text: userText, images} = await acpContentToUserMessage(
params.prompt,
{
diff --git a/source/acp/acp-session.spec.ts b/source/acp/acp-session.spec.ts
index 6b0c39de6..671a28c65 100644
--- a/source/acp/acp-session.spec.ts
+++ b/source/acp/acp-session.spec.ts
@@ -113,7 +113,7 @@ test('AcpSession - cancel aborts the old controller', t => {
t.true(oldController.signal.aborted);
});
-test('AcpSession - cancel creates fresh abort controller', t => {
+test('AcpSession - cancel leaves the session signal aborted', t => {
const session = new AcpSession({
sessionId: 'test-id',
cwd: '/tmp',
@@ -121,10 +121,35 @@ test('AcpSession - cancel creates fresh abort controller', t => {
});
const original = session.abortController;
session.cancel();
- t.not(session.abortController, original);
+ t.is(session.abortController, original);
+ t.true(session.abortController.signal.aborted);
+});
+
+test('AcpSession - beginTurn creates fresh abort controller', t => {
+ const session = new AcpSession({
+ sessionId: 'test-id',
+ cwd: '/tmp',
+ conn: createMockConn(),
+ });
+ session.cancel();
+ const cancelled = session.abortController;
+ session.beginTurn();
+ t.not(session.abortController, cancelled);
t.false(session.abortController.signal.aborted);
});
+test('AcpSession - cancel after beginTurn aborts the turn controller', t => {
+ const session = new AcpSession({
+ sessionId: 'test-id',
+ cwd: '/tmp',
+ conn: createMockConn(),
+ });
+ session.beginTurn();
+ const turnController = session.abortController;
+ session.cancel();
+ t.true(turnController.signal.aborted);
+});
+
test('AcpSession - cancel can be called multiple times safely', t => {
const session = new AcpSession({
sessionId: 'test-id',
@@ -134,7 +159,7 @@ test('AcpSession - cancel can be called multiple times safely', t => {
session.cancel();
session.cancel();
session.cancel();
- t.false(session.abortController.signal.aborted);
+ t.true(session.abortController.signal.aborted);
});
// ============================================================================
diff --git a/source/acp/acp-session.ts b/source/acp/acp-session.ts
index 40a079560..ccd12f5ae 100644
--- a/source/acp/acp-session.ts
+++ b/source/acp/acp-session.ts
@@ -38,7 +38,9 @@ export class AcpSession {
cancel(): void {
this.abortController.abort();
- // Create a fresh controller for potential subsequent prompts
+ }
+
+ beginTurn(): void {
this.abortController = new AbortController();
}
}
diff --git a/source/app/App.tsx b/source/app/App.tsx
index 7570e5e69..604868d67 100644
--- a/source/app/App.tsx
+++ b/source/app/App.tsx
@@ -415,6 +415,7 @@ export default function App({
getMessageTokens: appState.getMessageTokens,
setActiveMode: appState.setActiveMode,
setIsSettingsMode: appState.setIsSettingsMode,
+ setSettingsActiveTab: appState.setSettingsActiveTab,
addToChatQueue: appState.addToChatQueue,
reinitializeMCPServers: appInitialization.reinitializeMCPServers,
setTune: appState.setTune,
@@ -523,9 +524,7 @@ export default function App({
getMessageTokens: appState.getMessageTokens,
enterModelSelectionMode: modeHandlers.enterModelSelectionMode,
enterModelDatabaseMode: modeHandlers.enterModelDatabaseMode,
- enterConfigWizardMode: modeHandlers.enterConfigWizardMode,
enterSettingsMode: modeHandlers.enterSettingsMode,
- enterMcpWizardMode: modeHandlers.enterMcpWizardMode,
enterExplorerMode: modeHandlers.enterExplorerMode,
enterIdeSelectionMode: modeHandlers.enterIdeSelectionMode,
enterTune: modeHandlers.enterTune,
diff --git a/source/app/components/app-container.tsx b/source/app/components/app-container.tsx
index 8b4c267fa..222c493ff 100644
--- a/source/app/components/app-container.tsx
+++ b/source/app/components/app-container.tsx
@@ -10,6 +10,7 @@ import {
getGitStatusSummarySync,
} from '@/tools/git/utils';
import {DEVELOPMENT_MODE_LABELS, type DevelopmentMode} from '@/types/core';
+import {homeRelative} from '@/utils/path';
/**
* Format a {@link GitStatusSummary} for inline display next to the
@@ -54,8 +55,7 @@ function BootSummary({
const {colors} = useTheme();
const {isNarrow} = useResponsiveTerminal();
const configPath = getClosestConfigFile('agents.config.json');
- const homedir = process.env.HOME || process.env.USERPROFILE || '';
- const shortConfig = homedir ? configPath.replace(homedir, '~') : configPath;
+ const shortConfig = homeRelative(configPath);
const modeLabel = mode ? DEVELOPMENT_MODE_LABELS[mode] : undefined;
const gitStatus = getGitStatusSummarySync();
const gitLabel = gitStatus ? formatBootSummaryGitLabel(gitStatus) : undefined;
diff --git a/source/app/components/modal-selectors.spec.tsx b/source/app/components/modal-selectors.spec.tsx
index 4bd7922fd..dd58b4830 100644
--- a/source/app/components/modal-selectors.spec.tsx
+++ b/source/app/components/modal-selectors.spec.tsx
@@ -20,8 +20,6 @@ function createDefaultProps(
onModelDatabaseCancel: () => {},
onConfigWizardComplete: async () => {},
onConfigWizardCancel: () => {},
- onMcpWizardComplete: async () => {},
- onMcpWizardCancel: () => {},
onCheckpointSelect: async () => {},
onCheckpointCancel: () => {},
onSessionSelect: () => {},
@@ -72,17 +70,6 @@ test('ModalSelectors renders ConfigWizard when activeMode is configWizard', t =>
unmount();
});
-test('ModalSelectors renders McpWizard when activeMode is mcpWizard', t => {
- const props = createDefaultProps({activeMode: 'mcpWizard'});
- const component = ModalSelectors(props);
- t.truthy(component);
-
- const {lastFrame, unmount} = renderWithTheme(<>{component}>);
- const output = lastFrame();
- t.truthy(output);
- unmount();
-});
-
test('ModalSelectors renders SettingsSelector when isSettingsMode is true', t => {
const props = createDefaultProps({isSettingsMode: true});
const component = ModalSelectors(props);
diff --git a/source/app/components/modal-selectors.tsx b/source/app/components/modal-selectors.tsx
index 87b03eb27..67e6b6780 100644
--- a/source/app/components/modal-selectors.tsx
+++ b/source/app/components/modal-selectors.tsx
@@ -5,8 +5,8 @@ import ModelSelector from '@/components/model-selector';
import SessionSelector from '@/components/session-selector';
import type {ActiveMode} from '@/hooks/useAppState';
import type {CheckpointListItem, TuneConfig} from '@/types';
-import {McpWizard} from '@/wizards/mcp-wizard';
import {ProviderWizard} from '@/wizards/provider-wizard';
+import type {SettingsTabId} from './settings-constants';
import {SettingsSelector} from './settings-tabs';
import {TuneSelector} from './tune-selector';
@@ -14,8 +14,11 @@ export interface ModalSelectorsProps {
onLaunchTune?: () => void;
onLaunchIde?: () => void;
onMcpChanged?: () => void | Promise;
+ onProvidersChanged?: () => void | Promise;
activeMode: ActiveMode;
isSettingsMode: boolean;
+ settingsInitialTab?: SettingsTabId;
+ onSettingsTabChange?: (tab: SettingsTabId) => void;
showAllSessions: boolean;
// Current values
@@ -37,10 +40,6 @@ export interface ModalSelectorsProps {
onConfigWizardComplete: (configPath: string) => Promise;
onConfigWizardCancel: () => void;
- // Handlers - MCP Wizard
- onMcpWizardComplete: (configPath: string) => Promise;
- onMcpWizardCancel: () => void;
-
// Handlers - Checkpoint
onCheckpointSelect: (name: string, backup: boolean) => Promise;
onCheckpointCancel: () => void;
@@ -65,6 +64,8 @@ export interface ModalSelectorsProps {
export function ModalSelectors({
activeMode,
isSettingsMode,
+ settingsInitialTab,
+ onSettingsTabChange,
showAllSessions,
currentModel,
currentProvider,
@@ -74,8 +75,6 @@ export function ModalSelectors({
onModelDatabaseCancel,
onConfigWizardComplete,
onConfigWizardCancel,
- onMcpWizardComplete,
- onMcpWizardCancel,
onCheckpointSelect,
onCheckpointCancel,
onSessionSelect,
@@ -84,6 +83,7 @@ export function ModalSelectors({
onLaunchTune,
onLaunchIde,
onMcpChanged,
+ onProvidersChanged,
tuneConfig,
onTuneSelect,
onTuneCancel,
@@ -116,6 +116,9 @@ export function ModalSelectors({
onLaunchTune={onLaunchTune}
onLaunchIde={onLaunchIde}
onMcpChanged={onMcpChanged}
+ onProvidersChanged={onProvidersChanged}
+ initialTab={settingsInitialTab}
+ onTabChange={onSettingsTabChange}
/>
);
}
@@ -134,16 +137,6 @@ export function ModalSelectors({
);
}
- if (activeMode === 'mcpWizard') {
- return (
- void onMcpWizardComplete(configPath)}
- onCancel={onMcpWizardCancel}
- />
- );
- }
-
if (activeMode === 'checkpointLoad' && checkpointLoadData) {
return (
{
+ const {lastFrame} = render(
+ {}} onCancel={() => {}} />,
+ );
+
+ const output = lastFrame()!;
+ t.regex(output, /filesystem/);
+ t.regex(output, /github/);
+});
+
+test('offers an explicit add row separate from the server rows', t => {
+ const {lastFrame} = render(
+ {}} onCancel={() => {}} />,
+ );
+
+ const output = lastFrame()!;
+ // The old panel had a single "+ Add or edit MCP servers…" row and every
+ // server row opened that same generic flow.
+ t.regex(output, /\+ Add an MCP server/);
+ t.notRegex(output, /Add or edit MCP servers/);
+});
+
+test('tells the user that Enter acts on the selected server', t => {
+ const {lastFrame} = render(
+ {}} onCancel={() => {}} />,
+ );
+
+ t.regex(lastFrame()!, /edits or deletes the selected server/);
+});
diff --git a/source/app/components/settings-mcp-list.tsx b/source/app/components/settings-mcp-list.tsx
index 5506140c6..acb812649 100644
--- a/source/app/components/settings-mcp-list.tsx
+++ b/source/app/components/settings-mcp-list.tsx
@@ -8,8 +8,8 @@ import {useTheme} from '@/hooks/useTheme';
import {McpWizard} from '@/wizards/mcp-wizard';
/**
- * Lists the configured MCP servers first, then opens the existing MCP wizard to
- * add/edit rather than jumping straight into it.
+ * Lists the configured MCP servers first. Selecting a server opens the wizard on
+ * that entry's edit/delete choice; the trailing row adds a new one.
*/
export function SettingsMcpListPanel({
onBack,
@@ -21,40 +21,43 @@ export function SettingsMcpListPanel({
}) {
const {colors} = useTheme();
const {boxWidth, isNarrow} = useResponsiveTerminal();
- const [editing, setEditing] = useState(false);
+ // null = not editing. '' = adding a new server (no entry targeted).
+ const [editTarget, setEditTarget] = useState(null);
const servers = getAppConfig().mcpServers ?? [];
useInput((_, key) => {
- if (editing) return;
+ if (editTarget !== null) return;
if (key.escape) onBack();
if (key.shift && key.tab) onBack();
});
- if (editing) {
+ if (editTarget !== null) {
return (
{
+ initialEditName={editTarget || undefined}
+ onComplete={async () => {
// Rebuild the running session's MCP connections; otherwise a server
// added here stays inert until the next launch.
- void onMcpChanged?.();
- onBack();
+ await onMcpChanged?.();
+ setEditTarget(null);
}}
- onCancel={() => setEditing(false)}
+ onCancel={() => setEditTarget(null)}
/>
);
}
const items = [
- ...servers.map((s, i) => {
+ ...servers.map(s => {
const detail = s.command ? s.command : s.url ? s.url : '(no endpoint)';
+ // Value is the server name so the wizard can target this entry.
return {
label: `${s.name} · ${s.transport} · ${detail}`,
- value: String(i),
+ value: s.name,
};
}),
- {label: '+ Add or edit MCP servers…', value: 'edit'},
+ {label: '+ Add an MCP server…', value: ''},
];
return (
@@ -70,10 +73,13 @@ export function SettingsMcpListPanel({
{servers.length} server{servers.length === 1 ? '' : 's'} configured.
- Enter opens the wizard to add or edit.
+ Enter edits or deletes the selected server.
- setEditing(true)} />
+ setEditTarget(item.value)}
+ />
Shift+Tab back · Esc back
diff --git a/source/app/components/settings-providers-list.spec.tsx b/source/app/components/settings-providers-list.spec.tsx
new file mode 100644
index 000000000..7f73395e1
--- /dev/null
+++ b/source/app/components/settings-providers-list.spec.tsx
@@ -0,0 +1,76 @@
+import {mkdtempSync, writeFileSync} from 'node:fs';
+import {tmpdir} from 'node:os';
+import {join} from 'node:path';
+import test from 'ava';
+import React from 'react';
+
+// CRITICAL: point config reads at a temp dir BEFORE the panel's @/config/index
+// import chain loads, so this spec never reads the developer's real config.
+// The providers must live in a *project* config: loadAllProviderConfigs skips
+// global providers entirely when NODE_ENV is 'test' (which AVA sets), so a
+// global-only config would load as zero providers.
+const configDir = mkdtempSync(join(tmpdir(), 'nanocoder-spec-'));
+process.env.NANOCODER_CONFIG_DIR = configDir;
+// AVA runs each spec file in its own process (workerThreads: false), so this
+// chdir cannot leak into another spec and needs no restore hook — and a hook
+// declared between top-level awaits would not be picked up anyway.
+process.chdir(configDir);
+writeFileSync(
+ join(configDir, 'agents.config.json'),
+ JSON.stringify({
+ nanocoder: {
+ providers: [
+ {
+ name: 'ollama',
+ baseUrl: 'http://localhost:11434/v1',
+ models: ['llama2'],
+ },
+ {
+ name: 'openrouter',
+ baseUrl: 'https://openrouter.ai/api/v1',
+ models: ['gpt-4', 'claude'],
+ },
+ ],
+ },
+ }),
+);
+
+const {reloadAppConfig} = await import('@/config/index');
+reloadAppConfig();
+
+const {renderWithTheme: render} = await import(
+ '../../test-utils/render-with-theme'
+);
+const {SettingsProvidersListPanel} = await import('./settings-providers-list');
+
+console.log(`\nsettings-providers-list.spec.tsx – ${React.version}`);
+
+test('lists every configured provider as its own row', t => {
+ const {lastFrame} = render(
+ {}} onCancel={() => {}} />,
+ );
+
+ const output = lastFrame()!;
+ t.regex(output, /ollama/);
+ t.regex(output, /openrouter/);
+});
+
+test('offers an explicit add row separate from the provider rows', t => {
+ const {lastFrame} = render(
+ {}} onCancel={() => {}} />,
+ );
+
+ const output = lastFrame()!;
+ // The old panel had a single "+ Add or edit providers…" row and every
+ // provider row opened that same generic flow.
+ t.regex(output, /\+ Add a provider/);
+ t.notRegex(output, /Add or edit providers/);
+});
+
+test('tells the user that Enter acts on the selected provider', t => {
+ const {lastFrame} = render(
+ {}} onCancel={() => {}} />,
+ );
+
+ t.regex(lastFrame()!, /edits or deletes the selected provider/);
+});
diff --git a/source/app/components/settings-providers-list.tsx b/source/app/components/settings-providers-list.tsx
index 785c0d558..b235ff339 100644
--- a/source/app/components/settings-providers-list.tsx
+++ b/source/app/components/settings-providers-list.tsx
@@ -9,46 +9,58 @@ import {ProviderWizard} from '@/wizards/provider-wizard';
/**
* Lists the configured AI providers first (inspired by openclaude's
- * ProviderManager and codex/opencode provider pickers), then opens the existing
- * provider wizard to add/edit rather than jumping straight into it.
+ * ProviderManager and codex/opencode provider pickers). Selecting a provider
+ * opens the wizard on that entry's edit/delete choice; the trailing row adds a
+ * new one.
*/
export function SettingsProvidersListPanel({
onBack,
+ onProvidersChanged,
}: {
onBack: () => void;
onCancel: () => void;
+ onProvidersChanged?: () => void | Promise;
}) {
const {colors} = useTheme();
const {boxWidth, isNarrow} = useResponsiveTerminal();
- const [editing, setEditing] = useState(false);
+ // null = not editing. '' = adding a new provider (no entry targeted).
+ const [editTarget, setEditTarget] = useState(null);
const providers = getAppConfig().providers ?? [];
useInput((_, key) => {
- if (editing) return;
+ if (editTarget !== null) return;
if (key.escape) onBack();
if (key.shift && key.tab) onBack();
});
- if (editing) {
+ if (editTarget !== null) {
return (
setEditing(false)}
+ initialEditName={editTarget || undefined}
+ onComplete={async () => {
+ // Rebuild the client for current provider/model without clearing
+ // messages. The parent owns closing the editing state.
+ await onProvidersChanged?.();
+ setEditTarget(null);
+ }}
+ onCancel={() => setEditTarget(null)}
/>
);
}
const items = [
- ...providers.map((p, i) => {
+ ...providers.map(p => {
const where = p.baseUrl ? p.baseUrl : 'default endpoint';
const models = p.models?.length
? `${p.models[0]}${p.models.length > 1 ? ` +${p.models.length - 1}` : ''}`
: 'no models';
- return {label: `${p.name} · ${where} · ${models}`, value: String(i)};
+ // Value is the provider name so the wizard can target this entry even
+ // though it loads a single config file rather than the resolved config.
+ return {label: `${p.name} · ${where} · ${models}`, value: p.name};
}),
- {label: '+ Add or edit providers…', value: 'edit'},
+ {label: '+ Add a provider…', value: ''},
];
return (
@@ -64,10 +76,13 @@ export function SettingsProvidersListPanel({
{providers.length} provider{providers.length === 1 ? '' : 's'}{' '}
- configured. Enter opens the wizard to add or edit.
+ configured. Enter edits or deletes the selected provider.
- setEditing(true)} />
+ setEditTarget(item.value)}
+ />
Shift+Tab back · Esc back
diff --git a/source/app/components/settings-selector.tsx b/source/app/components/settings-selector.tsx
index facc1a736..4e0ed1355 100644
--- a/source/app/components/settings-selector.tsx
+++ b/source/app/components/settings-selector.tsx
@@ -28,6 +28,7 @@ import type {NotificationsConfig} from '@/types/config';
import type {NanocoderShape, ThemePreset} from '@/types/ui';
import {setNotificationsConfig} from '@/utils/notifications';
import {DEFAULT_SINGLE_LINE_PASTE_THRESHOLD} from '@/utils/paste-utils';
+import type {SettingsTabId} from './settings-constants';
/**
* The set of "managed" settings panels: preserved full-featured sub-UIs that
@@ -65,6 +66,20 @@ export interface SettingsSelectorProps {
* this, servers added here only take effect on the next launch.
*/
onMcpChanged?: () => void | Promise;
+ /**
+ * Rebuild the client for the current provider/model after the Providers panel
+ * edits config, without clearing messages or resetting to default provider.
+ */
+ onProvidersChanged?: () => void | Promise;
+ /**
+ * The tab to open initially. Defaults to 'appearance' if not specified.
+ */
+ initialTab?: SettingsTabId;
+ /**
+ * Called when the active tab changes, so the parent can track it for
+ * returning after launching wizards.
+ */
+ onTabChange?: (tab: SettingsTabId) => void;
}
function ThemePreviewMessage({
diff --git a/source/app/components/settings-tabs.tsx b/source/app/components/settings-tabs.tsx
index 126f017a9..62deb24e4 100644
--- a/source/app/components/settings-tabs.tsx
+++ b/source/app/components/settings-tabs.tsx
@@ -19,6 +19,7 @@ import {useTitleShape} from '@/hooks/useTitleShape';
import {fuzzyScore} from '@/utils/fuzzy-matching';
import {DEFAULT_SINGLE_LINE_PASTE_THRESHOLD} from '@/utils/paste-utils';
import {SettingsAutoCompactPanel} from './settings-auto-compact';
+import {SETTINGS_TAB_IDS, type SettingsTabId} from './settings-constants';
import {SettingsDefaultModePanel} from './settings-default-mode';
import {SettingsEnvironmentPanel} from './settings-environment';
import {SettingsJsonConfigPanel} from './settings-json-config';
@@ -48,25 +49,29 @@ import {SettingsWebSearchPanel} from './settings-web-search';
* Every existing preference must be reachable from exactly one of these
* its tabs.
*/
-export type SettingsTabId =
- | 'appearance'
- | 'input'
- | 'behavior'
- | 'providers'
- | 'advanced';
interface TabDefinition {
id: SettingsTabId;
label: string;
}
-const TABS: TabDefinition[] = [
- {id: 'appearance', label: 'Appearance'},
- {id: 'input', label: 'Input'},
- {id: 'behavior', label: 'Behavior'},
- {id: 'providers', label: 'Providers'},
- {id: 'advanced', label: 'Advanced'},
-];
+/**
+ * Labels for each tab. Keyed by `SettingsTabId`, so adding an id to
+ * settings-constants.ts without a label here is a compile error.
+ */
+const TAB_LABELS: Record = {
+ appearance: 'Appearance',
+ input: 'Input',
+ behavior: 'Behavior',
+ providers: 'Providers',
+ mcp: 'MCP',
+ advanced: 'Advanced',
+};
+
+const TABS: TabDefinition[] = SETTINGS_TAB_IDS.map(id => ({
+ id,
+ label: TAB_LABELS[id],
+}));
type SettingRow =
| {
@@ -216,13 +221,6 @@ function buildRowsForTab(
value: `${getAppConfig().providers?.length ?? 0} configured`,
panel: 'providers-config',
},
- {
- kind: 'managed',
- id: 'mcp-config',
- label: 'Configure MCP Servers',
- value: `${getAppConfig().mcpServers?.length ?? 0} configured`,
- panel: 'mcp-config',
- },
{
kind: 'managed',
id: 'web-search',
@@ -240,6 +238,16 @@ function buildRowsForTab(
panel: 'tool-approval',
},
];
+ case 'mcp':
+ return [
+ {
+ kind: 'managed',
+ id: 'mcp-config',
+ label: 'Configure MCP Servers',
+ value: `${getAppConfig().mcpServers?.length ?? 0} configured`,
+ panel: 'mcp-config',
+ },
+ ];
case 'advanced': {
const rows: SettingRow[] = [
{
@@ -376,6 +384,7 @@ function renderManagedPanel(
panel: ManagedSettingsPanel,
onBack: () => void,
onMcpChanged?: () => void | Promise,
+ onProvidersChanged?: () => void | Promise,
): ReactElement {
switch (panel) {
case 'theme':
@@ -409,7 +418,13 @@ function renderManagedPanel(
case 'environment':
return ;
case 'providers-config':
- return ;
+ return (
+
+ );
case 'mcp-config':
return (
('appearance');
+ const [activeTab, setActiveTabState] = useState(
+ initialTab ?? 'appearance',
+ );
+
+ // The only sanctioned way to change tabs: keeps the parent's tracked tab in
+ // step so returning from Tune/IDE lands back where the user was. Nothing
+ // should call setActiveTabState directly.
+ const updateActiveTab = (tab: SettingsTabId) => {
+ setActiveTabState(tab);
+ onTabChange?.(tab);
+ };
const [focus, setFocus] = useState('header');
const [openPanel, setOpenPanel] = useState(null);
@@ -576,7 +604,7 @@ export function SettingsSelector({
const goToTab = (direction: 1 | -1) => {
const idx = TABS.findIndex(t => t.id === activeTab);
const next = TABS[(idx + direction + TABS.length) % TABS.length];
- if (next) setActiveTab(next.id);
+ if (next) updateActiveTab(next.id);
};
const activateRow = (row: SettingRow) => {
@@ -731,7 +759,12 @@ export function SettingsSelector({
setVersion(v => v + 1);
setOpenPanel(null);
};
- return renderManagedPanel(openPanel, onBack, onMcpChanged);
+ return renderManagedPanel(
+ openPanel,
+ onBack,
+ onMcpChanged,
+ onProvidersChanged,
+ );
}
const width = isNarrow ? '100%' : boxWidth;
diff --git a/source/app/hooks/useAppLogging.tsx b/source/app/hooks/useAppLogging.tsx
index 05e0555fd..71bc6ba39 100644
--- a/source/app/hooks/useAppLogging.tsx
+++ b/source/app/hooks/useAppLogging.tsx
@@ -109,7 +109,6 @@ export function useAppLogging({
!isToolExecuting &&
!isToolConfirmationMode &&
activeMode !== 'configWizard' &&
- activeMode !== 'mcpWizard' &&
pendingToolCallsLength === 0
) {
const correlationId = generateCorrelationId();
diff --git a/source/app/sections/interactive-app.spec.tsx b/source/app/sections/interactive-app.spec.tsx
index 4e26cfa1a..9c610779a 100644
--- a/source/app/sections/interactive-app.spec.tsx
+++ b/source/app/sections/interactive-app.spec.tsx
@@ -109,8 +109,6 @@ function makeProps(o: Overrides = {}) {
handleModelDatabaseCancel: noop,
handleConfigWizardComplete: noop,
handleConfigWizardCancel: noop,
- handleMcpWizardComplete: noop,
- handleMcpWizardCancel: noop,
handleSettingsCancel: noop,
handleTuneSelect: noop,
handleTuneCancel: noop,
diff --git a/source/app/sections/interactive-app.tsx b/source/app/sections/interactive-app.tsx
index 5639ee9fe..486e7494e 100644
--- a/source/app/sections/interactive-app.tsx
+++ b/source/app/sections/interactive-app.tsx
@@ -3,6 +3,7 @@ import React from 'react';
import {ChatHistory} from '@/app/components/chat-history';
import {ChatInput} from '@/app/components/chat-input';
import {ModalSelectors} from '@/app/components/modal-selectors';
+import type {SettingsTabId} from '@/app/components/settings-constants';
import {FileExplorer} from '@/components/file-explorer';
import {IdeSelector} from '@/components/ide-selector';
import PlanReviewPrompt from '@/components/plan-review-prompt';
@@ -78,12 +79,16 @@ export function InteractiveApp({
// Tune / IDE are launched by closing settings first, so their exit has no way
// to know it should land back in settings rather than in chat.
const launchedFromSettingsRef = React.useRef(false);
+ // Track which tab was active when launching Tune/IDE so we can return to it.
+ const launchedFromTabRef = React.useRef(undefined);
const returnFromLaunchedWizard = React.useCallback(
(exit: () => void) => () => {
exit();
if (launchedFromSettingsRef.current) {
launchedFromSettingsRef.current = false;
- modeHandlers.enterSettingsMode();
+ // Return to the tab that was active when the wizard was launched.
+ modeHandlers.enterSettingsMode(launchedFromTabRef.current);
+ launchedFromTabRef.current = undefined;
}
},
[modeHandlers],
@@ -335,6 +340,8 @@ export function InteractiveApp({
{
+ // Capture the current tab before closing settings.
+ launchedFromTabRef.current = appState.settingsActiveTab;
launchedFromSettingsRef.current = true;
modeHandlers.handleSettingsCancel();
modeHandlers.enterTune();
}}
onLaunchIde={() => {
+ // Capture the current tab before closing settings.
+ launchedFromTabRef.current = appState.settingsActiveTab;
launchedFromSettingsRef.current = true;
modeHandlers.handleSettingsCancel();
modeHandlers.enterIdeSelectionMode();
diff --git a/source/app/utils/app-util.spec.ts b/source/app/utils/app-util.spec.ts
index 4bb599ae2..0ea86a516 100644
--- a/source/app/utils/app-util.spec.ts
+++ b/source/app/utils/app-util.spec.ts
@@ -3,9 +3,9 @@ import React from 'react';
import {
createClearMessagesHandler,
handleMessageSubmission,
- parseContextLimit,
parseCustomCommandArgs,
} from './app-util.js';
+import {SETTINGS_TAB_IDS} from '@/app/components/settings-constants';
import {lazyCommands} from '@/commands/lazy-registry';
import BashProgress from '@/components/bash-progress';
import type {MessageSubmissionOptions} from '@/types/index';
@@ -143,19 +143,6 @@ test('checkpoint load detection - other checkpoint subcommand', t => {
t.false(isCheckpointLoad);
});
-// Test setup-mcp command parsing
-test('setup-mcp command parsing - extracts command name correctly', t => {
- const message = '/setup-mcp';
- const commandName = message.slice(1).split(/\s+/)[0];
- t.is(commandName, 'setup-mcp');
-});
-
-test('setup-mcp command parsing - handles command with extra whitespace', t => {
- const message = '/setup-mcp ';
- const commandName = message.slice(1).split(/\s+/)[0];
- t.is(commandName, 'setup-mcp');
-});
-
// Test /commands create detection
test('commands create detection - matches commands create', t => {
const message = '/commands create my-tool';
@@ -217,51 +204,6 @@ test('commands create - preserves .md extension when present', t => {
t.is(safeName, 'my-tool.md');
});
-// Test parseContextLimit
-test('parseContextLimit - plain number', t => {
- t.is(parseContextLimit('8192'), 8192);
-});
-
-test('parseContextLimit - k suffix lowercase', t => {
- t.is(parseContextLimit('128k'), 128000);
-});
-
-test('parseContextLimit - K suffix uppercase', t => {
- t.is(parseContextLimit('128K'), 128000);
-});
-
-test('parseContextLimit - fractional k value', t => {
- t.is(parseContextLimit('4.5k'), 4500);
-});
-
-test('parseContextLimit - zero returns null', t => {
- t.is(parseContextLimit('0'), null);
-});
-
-test('parseContextLimit - negative returns null', t => {
- t.is(parseContextLimit('-5'), null);
-});
-
-test('parseContextLimit - non-numeric returns null', t => {
- t.is(parseContextLimit('abc'), null);
-});
-
-test('parseContextLimit - just k returns null', t => {
- t.is(parseContextLimit('k'), null);
-});
-
-test('parseContextLimit - whitespace is trimmed', t => {
- t.is(parseContextLimit(' 8192 '), 8192);
-});
-
-test('parseContextLimit - large value with k suffix', t => {
- t.is(parseContextLimit('256k'), 256000);
-});
-
-test('parseContextLimit - decimal without k suffix', t => {
- t.is(parseContextLimit('1024.5'), 1025);
-});
-
// Test /ide command parsing
test('ide command parsing - extracts command name correctly', t => {
const message = '/ide';
@@ -274,8 +216,6 @@ test('ide command parsing - recognized as special command', t => {
CLEAR: 'clear',
MODEL: 'model',
MODEL_DATABASE: 'model-database',
- SETUP_PROVIDERS: 'setup-providers',
- SETUP_MCP: 'setup-mcp',
SETTINGS: 'settings',
STATUS: 'status',
CHECKPOINT: 'checkpoint',
@@ -306,9 +246,7 @@ function createResumeTestOptions(overrides: {
onClearMessages: async () => {},
onEnterModelSelectionMode: () => {},
onEnterModelDatabaseMode: () => {},
- onEnterConfigWizardMode: () => {},
onEnterSettingsMode: () => {},
- onEnterMcpWizardMode: () => {},
onEnterExplorerMode: () => {},
onEnterIdeSelectionMode: () => {},
onEnterCheckpointLoadMode: () => {},
@@ -744,9 +682,7 @@ function createRenameTestOptions(overrides: {
commandArgs: overrides.commandArgs,
onEnterModelSelectionMode: () => {},
onEnterModelDatabaseMode: () => {},
- onEnterConfigWizardMode: () => {},
onEnterSettingsMode: () => {},
- onEnterMcpWizardMode: () => {},
onEnterExplorerMode: () => {},
onEnterIdeSelectionMode: () => {},
onEnterCheckpointLoadMode: () => {},
@@ -887,3 +823,143 @@ test('createClearMessagesHandler - does not throw when client is null', async t
const handler = createClearMessagesHandler(() => {}, null);
await t.notThrowsAsync(() => handler());
});
+
+// --- /settings tabs and retired /setup-* commands ---
+
+function createSettingsTestOptions(overrides: {
+ onEnterSettingsMode?: (tab?: string) => void;
+ onAddToChatQueue?: (component: React.ReactNode) => void;
+ commandArgs?: string[];
+}): MessageSubmissionOptions {
+ return {
+ customCommandCache: new Map(),
+ customCommandLoader: null,
+ customCommandExecutor: null,
+ onClearMessages: async () => {},
+ onRenameSession: () => {},
+ commandArgs: overrides.commandArgs,
+ onEnterModelSelectionMode: () => {},
+ onEnterModelDatabaseMode: () => {},
+ onEnterSettingsMode: overrides.onEnterSettingsMode ?? (() => {}),
+ onEnterExplorerMode: () => {},
+ onEnterIdeSelectionMode: () => {},
+ onEnterTune: () => {},
+ onEnterCheckpointLoadMode: () => {},
+ onShowStatus: () => {},
+ onHandleChatMessage: async () => {},
+ onAddToChatQueue: overrides.onAddToChatQueue ?? (() => {}),
+ setLiveComponent: () => {},
+ setIsToolExecuting: () => {},
+ setMessages: () => {},
+ messages: [],
+ provider: 'test',
+ model: 'test',
+ theme: 'dark',
+ updateInfo: null,
+ getMessageTokens: () => 0,
+ } as unknown as MessageSubmissionOptions;
+}
+
+test('settings command - no argument leaves the tab unset', async t => {
+ let captured: string | undefined | symbol = Symbol('uncalled');
+ const options = createSettingsTestOptions({
+ onEnterSettingsMode: tab => {
+ captured = tab;
+ },
+ });
+ await handleMessageSubmission('/settings', options);
+ t.is(captured, undefined);
+});
+
+test('settings command - known tab argument opens that tab', async t => {
+ let captured: string | undefined;
+ const options = createSettingsTestOptions({
+ onEnterSettingsMode: tab => {
+ captured = tab;
+ },
+ commandArgs: ['mcp'],
+ });
+ await handleMessageSubmission('/settings mcp', options);
+ t.is(captured, 'mcp');
+});
+
+test('settings command - tab argument is case-insensitive', async t => {
+ let captured: string | undefined;
+ const options = createSettingsTestOptions({
+ onEnterSettingsMode: tab => {
+ captured = tab;
+ },
+ commandArgs: ['Providers'],
+ });
+ await handleMessageSubmission('/settings Providers', options);
+ t.is(captured, 'providers');
+});
+
+test('settings command - unknown tab reports an error instead of opening', async t => {
+ let called = false;
+ const queue: React.ReactNode[] = [];
+ const options = createSettingsTestOptions({
+ onEnterSettingsMode: () => {
+ called = true;
+ },
+ onAddToChatQueue: c => queue.push(c),
+ commandArgs: ['bogus'],
+ });
+ await handleMessageSubmission('/settings bogus', options);
+ t.false(called, 'an unknown tab should not silently open the default tab');
+ t.true(
+ findMessageInQueue(queue, m => m.includes('Unknown settings tab: "bogus"')),
+ 'the error names the offending argument',
+ );
+});
+
+test('settings command - unknown tab error lists the valid tabs', async t => {
+ const queue: React.ReactNode[] = [];
+ const options = createSettingsTestOptions({
+ onAddToChatQueue: c => queue.push(c),
+ commandArgs: ['providrs'],
+ });
+ await handleMessageSubmission('/settings providrs', options);
+ t.true(
+ findMessageInQueue(queue, m =>
+ SETTINGS_TAB_IDS.every(tab => m.includes(tab)),
+ ),
+ 'the error lists every valid tab so the typo is recoverable',
+ );
+});
+
+test('retired setup-providers - forwards to the settings providers tab', async t => {
+ let captured: string | undefined;
+ const queue: React.ReactNode[] = [];
+ const options = createSettingsTestOptions({
+ onEnterSettingsMode: tab => {
+ captured = tab;
+ },
+ onAddToChatQueue: c => queue.push(c),
+ });
+ await handleMessageSubmission('/setup-providers', options);
+ t.is(captured, 'providers');
+ t.true(findMessageInQueue(queue, m => m.includes('/settings providers')));
+});
+
+test('retired setup-mcp - forwards to the settings mcp tab', async t => {
+ let captured: string | undefined;
+ const queue: React.ReactNode[] = [];
+ const options = createSettingsTestOptions({
+ onEnterSettingsMode: tab => {
+ captured = tab;
+ },
+ onAddToChatQueue: c => queue.push(c),
+ });
+ await handleMessageSubmission('/setup-mcp', options);
+ t.is(captured, 'mcp');
+ t.true(findMessageInQueue(queue, m => m.includes('/settings mcp')));
+});
+
+test('retired setup commands - no longer registered in the slash menu', t => {
+ const names = lazyCommands.map(c => c.name);
+ t.false(names.includes('setup-providers'));
+ t.false(names.includes('setup-mcp'));
+ t.true(names.includes('settings'));
+ t.true(names.includes('setup-config'), 'unrelated /setup-config stays');
+});
diff --git a/source/app/utils/app-util.ts b/source/app/utils/app-util.ts
index 265583618..44b5950b7 100644
--- a/source/app/utils/app-util.ts
+++ b/source/app/utils/app-util.ts
@@ -1,4 +1,8 @@
import React from 'react';
+import {
+ SETTINGS_TAB_IDS,
+ type SettingsTabId,
+} from '@/app/components/settings-constants';
import {parseInput} from '@/command-parser';
import {commandRegistry} from '@/commands';
import {CodexLogin} from '@/commands/codex-login';
@@ -26,9 +30,6 @@ import {
import {handleRetryCommand} from './handlers/retry-handler';
import {handleResumeCommand} from './handlers/session-handler';
-// Re-export for consumers that import parseContextLimit from here
-export {parseContextLimit} from './handlers/context-max-handler';
-
/**
* "Special commands" need access to app-level state (setting modes, mutating
* messages, swapping live components) that the standard `Command.handler`
@@ -43,8 +44,6 @@ const SPECIAL_COMMANDS = {
CLEAR: 'clear',
MODEL: 'model',
MODEL_DATABASE: 'model-database',
- SETUP_PROVIDERS: 'setup-providers',
- SETUP_MCP: 'setup-mcp',
SETTINGS: 'settings',
STATUS: 'status',
CHECKPOINT: 'checkpoint',
@@ -54,6 +53,12 @@ const SPECIAL_COMMANDS = {
RENAME: 'rename',
} as const;
+/** Retired in favour of `/settings`; forwarded so they don't error out. */
+const RETIRED_SETUP_COMMANDS: Record = {
+ 'setup-providers': 'providers',
+ 'setup-mcp': 'mcp',
+};
+
/** Checkpoint subcommands */
const CHECKPOINT_SUBCOMMANDS = {
LOAD: 'load',
@@ -228,6 +233,10 @@ async function handleCustomCommand(
return true;
}
+function isSettingsTabId(value: string): value is SettingsTabId {
+ return (SETTINGS_TAB_IDS as readonly string[]).includes(value);
+}
+
/**
* Handles special commands that need app state access (/clear, /model, etc.)
* Returns true if a special command was handled.
@@ -241,9 +250,7 @@ async function handleSpecialCommand(
onRenameSession,
onEnterModelSelectionMode,
onEnterModelDatabaseMode,
- onEnterConfigWizardMode,
onEnterSettingsMode,
- onEnterMcpWizardMode,
onEnterExplorerMode,
onShowStatus,
onCommandComplete,
@@ -256,9 +263,6 @@ async function handleSpecialCommand(
const enterModeCommands: Record void> = {
[SPECIAL_COMMANDS.MODEL]: onEnterModelSelectionMode,
[SPECIAL_COMMANDS.MODEL_DATABASE]: onEnterModelDatabaseMode,
- [SPECIAL_COMMANDS.SETUP_PROVIDERS]: onEnterConfigWizardMode,
- [SPECIAL_COMMANDS.SETUP_MCP]: onEnterMcpWizardMode,
- [SPECIAL_COMMANDS.SETTINGS]: onEnterSettingsMode,
[SPECIAL_COMMANDS.EXPLORER]: onEnterExplorerMode,
[SPECIAL_COMMANDS.IDE]: options.onEnterIdeSelectionMode,
[SPECIAL_COMMANDS.TUNE]: options.onEnterTune,
@@ -271,7 +275,41 @@ async function handleSpecialCommand(
return true;
}
+ const retiredTab = RETIRED_SETUP_COMMANDS[commandName];
+ if (retiredTab) {
+ onAddToChatQueue(
+ infoMsg(
+ `/${commandName} has moved to /settings — opening the ${retiredTab} tab. Use /settings ${retiredTab} next time.`,
+ `${commandName}-retired`,
+ ),
+ );
+ onEnterSettingsMode(retiredTab);
+ onCommandComplete?.();
+ return true;
+ }
+
switch (commandName) {
+ case SPECIAL_COMMANDS.SETTINGS: {
+ const rawTab = commandArgs?.[0];
+ const tabArg = rawTab?.toLowerCase();
+ let tab: SettingsTabId | undefined;
+ if (tabArg) {
+ if (!isSettingsTabId(tabArg)) {
+ onAddToChatQueue(
+ errorMsg(
+ `Unknown settings tab: "${rawTab}". Valid tabs: ${SETTINGS_TAB_IDS.join(', ')}`,
+ 'settings-error',
+ ),
+ );
+ setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS);
+ return true;
+ }
+ tab = tabArg;
+ }
+ onEnterSettingsMode(tab);
+ onCommandComplete?.();
+ return true;
+ }
case SPECIAL_COMMANDS.CLEAR:
await onClearMessages();
await clearAllTasks();
diff --git a/source/app/utils/handlers/context-max-handler.spec.ts b/source/app/utils/handlers/context-max-handler.spec.ts
index 24cb8c910..be6356690 100644
--- a/source/app/utils/handlers/context-max-handler.spec.ts
+++ b/source/app/utils/handlers/context-max-handler.spec.ts
@@ -12,9 +12,7 @@ function createOptions(overrides: Partial = {}): Messa
onEnterModelSelectionMode: () => {},
onEnterProviderSelectionMode: () => {},
onEnterModelDatabaseMode: () => {},
- onEnterConfigWizardMode: () => {},
onEnterSettingsMode: () => {},
- onEnterMcpWizardMode: () => {},
onEnterExplorerMode: () => {},
onEnterIdeSelectionMode: () => {},
onEnterTune: () => {},
diff --git a/source/app/utils/handlers/context-max-handler.ts b/source/app/utils/handlers/context-max-handler.ts
index aee9f1190..f0e6f6368 100644
--- a/source/app/utils/handlers/context-max-handler.ts
+++ b/source/app/utils/handlers/context-max-handler.ts
@@ -9,28 +9,7 @@ import {
import {generateKey} from '@/session/key-generator';
import type {MessageSubmissionOptions} from '@/types/index';
import {errorMsg, infoMsg, successMsg} from '@/utils/message-factory';
-
-/**
- * Parses a context limit value string, supporting k/K suffix.
- * e.g. "8192" -> 8192, "128k" -> 128000, "128K" -> 128000
- */
-export function parseContextLimit(value: string): number | null {
- const trimmed = value.trim().toLowerCase();
- let multiplier = 1;
- let numStr = trimmed;
-
- if (trimmed.endsWith('k')) {
- multiplier = 1000;
- numStr = trimmed.slice(0, -1);
- }
-
- const parsed = Number.parseFloat(numStr);
- if (Number.isNaN(parsed) || parsed <= 0) {
- return null;
- }
-
- return Math.round(parsed * multiplier);
-}
+import {parseContextLimit} from '@/utils/parse-context-limit';
/**
* Handles /context-max command. Returns true if handled.
diff --git a/source/cli.tsx b/source/cli.tsx
index ec2baffd5..d8c7bcc7f 100644
--- a/source/cli.tsx
+++ b/source/cli.tsx
@@ -121,18 +121,9 @@ function isValidOutputFormat(value: unknown): value is 'text' | 'json' {
}
async function main(): Promise {
- // Dynamic imports so the fast-path flag handlers above never pay for them.
- const [
- {render},
- {default: App},
- {parseContextLimit},
- {setSessionContextLimit},
- ] = await Promise.all([
- import('ink'),
- import('@/app'),
- import('@/app/utils/handlers/context-max-handler'),
- import('@/models/index'),
- ]);
+ // Parse args and dispatch non-TUI branches BEFORE importing ink or @/app.
+ // Those packages pull ~thousand+ modules; --acp / --plain / auth must stay
+ // on the lightweight path. Ink + App load only in the final TUI branch.
const vscodeMode = args.includes('--vscode');
@@ -178,9 +169,13 @@ async function main(): Promise {
}
}
- // Extract --context-max if specified
+ // Extract --context-max if specified (framework-free parser — no React/Ink)
const contextMaxArgIndex = args.findIndex(arg => arg === '--context-max');
if (contextMaxArgIndex !== -1 && args[contextMaxArgIndex + 1]) {
+ const [{parseContextLimit}, {setSessionContextLimit}] = await Promise.all([
+ import('@/utils/parse-context-limit'),
+ import('@/models/index'),
+ ]);
const limit = parseContextLimit(args[contextMaxArgIndex + 1]);
if (limit !== null) {
setSessionContextLimit(limit);
@@ -193,6 +188,7 @@ async function main(): Promise {
}
// Extract --mode if specified. Accept `--mode value` and `--mode=value`.
+ // `@/app/types` is a tiny const module (no React/Ink) — safe before TUI.
const {VALID_MODES} = await import('@/app/types');
type CliMode = (typeof VALID_MODES)[number];
let cliMode: CliMode | undefined;
@@ -460,11 +456,16 @@ async function main(): Promise {
outputFormat,
});
} else {
+ // Interactive TUI — load Ink + App only now.
+ const [{render}, {default: App}] = await Promise.all([
+ import('ink'),
+ import('@/app'),
+ ]);
+
// Prevent Node's global performance entry buffer from growing without
// bound during long Ink sessions. See issue #521.
const {installPerfBufferGuard} = await import('@/utils/perf-buffer');
installPerfBufferGuard();
-
// Resolve --continue/--resume into a Session BEFORE rendering, so
// the app can apply it on first mount (see App's initialSession prop).
// A bare --resume (no id) instead opens the picker at startup — no
diff --git a/source/commands/lazy-registry.ts b/source/commands/lazy-registry.ts
index 57b8260bc..a8383832b 100644
--- a/source/commands/lazy-registry.ts
+++ b/source/commands/lazy-registry.ts
@@ -142,17 +142,6 @@ export const lazyCommands: LazyCommand[] = [
load: () =>
import('@/commands/setup-config').then(m => m.setupConfigCommand),
},
- {
- name: 'setup-providers',
- description: 'Launch interactive configuration wizard',
- load: () =>
- import('@/commands/setup-providers').then(m => m.setupProvidersCommand),
- },
- {
- name: 'setup-mcp',
- description: 'Launch interactive MCP server configuration wizard',
- load: () => import('@/commands/setup-mcp').then(m => m.setupMcpCommand),
- },
{
name: 'usage',
description: 'Display token usage statistics',
@@ -189,7 +178,7 @@ export const lazyCommands: LazyCommand[] = [
{
name: 'settings',
description:
- 'Configure UI settings (theme, shapes, branding, paste threshold)',
+ 'Configure settings (providers, MCP, theme, shapes, paste threshold). Accepts a tab: /settings providers',
load: () => import('@/commands/settings').then(m => m.settingsCommand),
},
{
diff --git a/source/commands/mcp.tsx b/source/commands/mcp.tsx
index 49ec77e1f..95ddc0901 100644
--- a/source/commands/mcp.tsx
+++ b/source/commands/mcp.tsx
@@ -75,8 +75,8 @@ export function MCP({toolManager}: MCPProps) {
- Use /setup-providers to
- configure servers interactively.
+ Use /settings mcp to configure
+ servers interactively.
>
) : (
diff --git a/source/commands/settings.ts b/source/commands/settings.ts
index fa7a0a4b8..bfc7456f6 100644
--- a/source/commands/settings.ts
+++ b/source/commands/settings.ts
@@ -2,5 +2,5 @@ import {createStubCommand} from '@/commands/create-stub-command';
export const settingsCommand = createStubCommand(
'settings',
- 'Configure UI settings (theme, shapes, branding, paste threshold, notifications)',
+ 'Configure settings (providers, MCP, theme, shapes, paste threshold). Accepts a tab: /settings providers',
);
diff --git a/source/commands/setup-mcp.tsx b/source/commands/setup-mcp.tsx
deleted file mode 100644
index 1a65fc32f..000000000
--- a/source/commands/setup-mcp.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import {Text} from 'ink';
-import React from 'react';
-import {Command} from '@/types/index';
-
-/**
- * `/setup-mcp` is a "special command": registered here for slash-menu
- * discovery and `/help` text, but actually dispatched in
- * `source/app/utils/app-util.ts` (see `SPECIAL_COMMANDS.SETUP_MCP`), which
- * calls `onEnterMcpWizardMode()` to swap the chat UI for the wizard.
- *
- * The handler is unreachable; it returns an empty Text so the Command type's
- * required handler shape is satisfied.
- */
-export const setupMcpCommand: Command = {
- name: 'setup-mcp',
- description: 'Launch interactive MCP server configuration wizard',
- handler: () => Promise.resolve(React.createElement(Text, {}, '')),
-};
diff --git a/source/commands/setup-providers.tsx b/source/commands/setup-providers.tsx
deleted file mode 100644
index 08d9c420c..000000000
--- a/source/commands/setup-providers.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import {Text} from 'ink';
-import React from 'react';
-import {Command} from '@/types/index';
-
-/**
- * `/setup-providers` is a "special command": registered here for slash-menu
- * discovery and `/help` text, but actually dispatched in
- * `source/app/utils/app-util.ts` (see `SPECIAL_COMMANDS.SETUP_PROVIDERS`),
- * which calls `onEnterConfigWizardMode()` to swap the chat UI for the wizard.
- *
- * The handler is unreachable; it returns an empty Text so the Command type's
- * required handler shape is satisfied.
- */
-export const setupProvidersCommand: Command = {
- name: 'setup-providers',
- description: 'Launch interactive configuration wizard',
- handler: () => Promise.resolve(React.createElement(Text, {}, '')),
-};
diff --git a/source/components/ui/styled-select-input.tsx b/source/components/ui/styled-select-input.tsx
index 4fc59b992..93bc5ad2b 100644
--- a/source/components/ui/styled-select-input.tsx
+++ b/source/components/ui/styled-select-input.tsx
@@ -1,6 +1,6 @@
import {Box, Text} from 'ink';
import SelectInput from 'ink-select-input';
-import type {ReactElement} from 'react';
+import type {ComponentProps, ReactElement} from 'react';
import {useTheme} from '@/hooks/useTheme';
@@ -25,25 +25,37 @@ interface Item {
* and selected-label colour are a hardcoded `blue` that all but disappears
* against a dark terminal background.
*/
-interface StyledSelectInputProps {
- items?: Array- >;
+interface StyledSelectInputProps = Item> {
+ items?: Array;
isFocused?: boolean;
initialIndex?: number;
limit?: number;
- onSelect?: (item: Item) => void;
- onHighlight?: (item: Item) => void;
- itemComponent?: (props: {
- isSelected?: boolean;
- label: string;
- }) => ReactElement;
+ onSelect?: (item: I) => void;
+ onHighlight?: (item: I) => void;
+ itemComponent?: (props: I & {isSelected?: boolean}) => ReactElement;
}
-export function StyledSelectInput(props: StyledSelectInputProps) {
+export function StyledSelectInput = Item>(
+ props: StyledSelectInputProps,
+) {
const {colors} = useTheme();
+ const itemComponent =
+ props.itemComponent ??
+ (({isSelected, label}) => (
+
+ {label}
+
+ ));
+
+ // ink-select-input's runtime spreads the whole item into these; its own
+ // types just don't say so.
return (
)}
// Fixed-width indicator: Ink trims a trailing space only on rows that
// overflow, which left truncated rows a column left of short ones.
indicatorComponent={({isSelected}) => (
@@ -56,15 +68,9 @@ export function StyledSelectInput(props: StyledSelectInputProps) {
// Truncate rather than wrap: a long label (a path, a URL) reflowed with
// no hanging indent and the list read as a jumble on narrow terminals.
itemComponent={
- props.itemComponent ??
- (({isSelected, label}) => (
-
- {label}
-
- ))
+ itemComponent as unknown as ComponentProps<
+ typeof SelectInput
+ >['itemComponent']
}
/>
);
diff --git a/source/constants.ts b/source/constants.ts
index 8524dd6ee..ece26b47d 100644
--- a/source/constants.ts
+++ b/source/constants.ts
@@ -77,6 +77,8 @@ export const EMPTY_CONTENT_MARKER = '[file is empty]';
export const PATH_LENGTH_NARROW_TERMINAL = 30;
export const PATH_LENGTH_NORMAL_TERMINAL = 60;
export const TABLE_COLUMN_MIN_WIDTH = 10;
+export const WIZARD_ROW_CHROME_CHARS = 10;
+export const MIN_PATH_BUDGET_CHARS = 10;
// === TOKEN THRESHOLDS (percentages - useChatHandler) ===
export const TOKEN_THRESHOLD_WARNING_PERCENT = 80;
diff --git a/source/hooks/useAppHandlers.spec.tsx b/source/hooks/useAppHandlers.spec.tsx
index 9102578f9..0b93a73c9 100644
--- a/source/hooks/useAppHandlers.spec.tsx
+++ b/source/hooks/useAppHandlers.spec.tsx
@@ -68,9 +68,7 @@ function makeProps(overrides: ProbeOverrides) {
const setLiveComponent = spy<[React.ReactNode]>();
const enterModelSelectionMode = spy<[]>();
const enterModelDatabaseMode = spy<[]>();
- const enterConfigWizardMode = spy<[]>();
const enterSettingsMode = spy<[]>();
- const enterMcpWizardMode = spy<[]>();
const enterExplorerMode = spy<[]>();
const enterIdeSelectionMode = spy<[]>();
const enterTune = spy<[]>();
@@ -114,9 +112,7 @@ function makeProps(overrides: ProbeOverrides) {
getMessageTokens: () => 0,
enterModelSelectionMode,
enterModelDatabaseMode,
- enterConfigWizardMode,
enterSettingsMode,
- enterMcpWizardMode,
enterExplorerMode,
enterIdeSelectionMode,
enterTune,
diff --git a/source/hooks/useAppHandlers.tsx b/source/hooks/useAppHandlers.tsx
index ee18724c7..1d58d3769 100644
--- a/source/hooks/useAppHandlers.tsx
+++ b/source/hooks/useAppHandlers.tsx
@@ -1,5 +1,6 @@
import {randomBytes} from 'node:crypto';
import React from 'react';
+import type {SettingsTabId} from '@/app/components/settings-constants';
import {
createClearMessagesHandler,
handleMessageSubmission,
@@ -105,9 +106,7 @@ interface UseAppHandlersProps {
// Mode handlers
enterModelSelectionMode: () => void;
enterModelDatabaseMode: () => void;
- enterConfigWizardMode: () => void;
- enterSettingsMode: () => void;
- enterMcpWizardMode: () => void;
+ enterSettingsMode: (tab?: SettingsTabId) => void;
enterExplorerMode: () => void;
enterIdeSelectionMode: () => void;
enterTune: () => void;
@@ -666,9 +665,7 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers {
commandArgs,
onEnterModelSelectionMode: props.enterModelSelectionMode,
onEnterModelDatabaseMode: props.enterModelDatabaseMode,
- onEnterConfigWizardMode: props.enterConfigWizardMode,
onEnterSettingsMode: props.enterSettingsMode,
- onEnterMcpWizardMode: props.enterMcpWizardMode,
onEnterExplorerMode: props.enterExplorerMode,
onEnterIdeSelectionMode: props.enterIdeSelectionMode,
onEnterTune: props.enterTune,
@@ -707,9 +704,7 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers {
props.customCommandExecutor,
props.enterModelSelectionMode,
props.enterModelDatabaseMode,
- props.enterConfigWizardMode,
props.enterSettingsMode,
- props.enterMcpWizardMode,
props.enterExplorerMode,
props.enterIdeSelectionMode,
props.enterTune,
diff --git a/source/hooks/useAppState.tsx b/source/hooks/useAppState.tsx
index e9e9c6d94..28030d511 100644
--- a/source/hooks/useAppState.tsx
+++ b/source/hooks/useAppState.tsx
@@ -1,4 +1,5 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import type {SettingsTabId} from '@/app/components/settings-constants';
import type {TitleShape} from '@/components/ui/styled-title';
import {getAppConfig} from '@/config/index';
import {loadPreferences} from '@/config/preferences';
@@ -34,7 +35,6 @@ export type ActiveMode =
| 'model'
| 'modelDatabase'
| 'configWizard'
- | 'mcpWizard'
| 'explorer'
| 'ideSelection'
| 'checkpointLoad'
@@ -97,6 +97,9 @@ export function useAppState(
const [isConversationComplete, setIsConversationComplete] =
useState(false);
const [isSettingsMode, setIsSettingsMode] = useState(false);
+ const [settingsActiveTab, setSettingsActiveTab] = useState<
+ SettingsTabId | undefined
+ >(undefined);
// Plan review state (post-plan-generation action bar)
const [planReviewState, setPlanReviewState] = useState<{
@@ -341,6 +344,7 @@ export function useAppState(
isCancelling,
isConversationComplete,
isSettingsMode,
+ settingsActiveTab,
planReviewState,
planTurnCompleted,
pendingPlanProceed,
@@ -407,6 +411,7 @@ export function useAppState(
setIsCancelling,
setIsConversationComplete,
setIsSettingsMode,
+ setSettingsActiveTab,
setPlanReviewState,
setPlanTurnCompleted,
setPendingPlanProceed,
diff --git a/source/hooks/useModeHandlers.spec.tsx b/source/hooks/useModeHandlers.spec.tsx
index 1309ecc76..812c09b2a 100644
--- a/source/hooks/useModeHandlers.spec.tsx
+++ b/source/hooks/useModeHandlers.spec.tsx
@@ -13,6 +13,7 @@ process.env.NANOCODER_CONFIG_DIR = mkdtempSync(
const {resetPreferencesCache} = await import('@/config/preferences');
resetPreferencesCache();
+import type {SettingsTabId} from '@/app/components/settings-constants';
import type {ActiveMode} from '@/hooks/useAppState';
import type {LLMClient, Message} from '@/types/core';
import type {AIProviderConfig, TuneConfig} from '@/types/config';
@@ -72,6 +73,7 @@ function setup(probe: ProbeProps = {}) {
const setMessages = spy<[Message[]]>();
const setActiveMode = spy<[ActiveMode]>();
const setIsSettingsMode = spy<[boolean]>();
+ const setSettingsActiveTab = spy<[SettingsTabId | undefined]>();
const addToChatQueue = spy<[React.ReactNode]>();
const reinitializeMCPServers = spy<[unknown]>();
const setTune = spy<[TuneConfig]>();
@@ -89,6 +91,7 @@ function setup(probe: ProbeProps = {}) {
getMessageTokens: () => 0,
setActiveMode,
setIsSettingsMode,
+ setSettingsActiveTab,
addToChatQueue,
reinitializeMCPServers: async () => {
reinitializeMCPServers(undefined);
@@ -105,6 +108,7 @@ function setup(probe: ProbeProps = {}) {
setMessages,
setActiveMode,
setIsSettingsMode,
+ setSettingsActiveTab,
addToChatQueue,
setTune,
};
@@ -117,15 +121,12 @@ test('returns the expected handler surface', t => {
t.is(typeof handlers.exitMode, 'function');
t.is(typeof handlers.enterModelSelectionMode, 'function');
t.is(typeof handlers.enterModelDatabaseMode, 'function');
- t.is(typeof handlers.enterConfigWizardMode, 'function');
- t.is(typeof handlers.enterMcpWizardMode, 'function');
t.is(typeof handlers.enterExplorerMode, 'function');
t.is(typeof handlers.enterIdeSelectionMode, 'function');
t.is(typeof handlers.enterSettingsMode, 'function');
t.is(typeof handlers.enterTune, 'function');
t.is(typeof handlers.handleModelSelect, 'function');
t.is(typeof handlers.handleConfigWizardComplete, 'function');
- t.is(typeof handlers.handleMcpWizardComplete, 'function');
t.is(typeof handlers.handleTuneSelect, 'function');
});
@@ -151,8 +152,6 @@ test('each enter*Mode helper sets the matching active mode', t => {
handlers.enterModelSelectionMode();
handlers.enterModelDatabaseMode();
- handlers.enterConfigWizardMode();
- handlers.enterMcpWizardMode();
handlers.enterExplorerMode();
handlers.enterIdeSelectionMode();
handlers.enterTune();
@@ -160,8 +159,6 @@ test('each enter*Mode helper sets the matching active mode', t => {
t.deepEqual(setActiveMode.calls, [
['model'],
['modelDatabase'],
- ['configWizard'],
- ['mcpWizard'],
['explorer'],
['ideSelection'],
['tune'],
@@ -183,12 +180,11 @@ test('cancel handlers all return active mode to null', t => {
handlers.handleModelSelectionCancel();
handlers.handleModelDatabaseCancel();
handlers.handleConfigWizardCancel();
- handlers.handleMcpWizardCancel();
handlers.handleExplorerCancel();
handlers.handleIdeSelectionCancel();
handlers.handleTuneCancel();
- t.is(setActiveMode.calls.length, 7);
+ t.is(setActiveMode.calls.length, 6);
for (const args of setActiveMode.calls) {
t.deepEqual(args, [null]);
}
@@ -289,11 +285,33 @@ test('handleConfigWizardComplete with no path only exits mode', async t => {
t.is(addToChatQueue.calls.length, 0);
});
-test('handleMcpWizardComplete with no path only exits mode', async t => {
- const {handlers, setActiveMode, addToChatQueue} = setup();
+test('reloadProviders leaves the conversation and settings panel intact', async t => {
+ const {
+ handlers,
+ setMessages,
+ setActiveMode,
+ setIsSettingsMode,
+ addToChatQueue,
+ } = setup({client: createMockClient()});
+
+ await handlers.reloadProviders();
+
+ // This is why reloadProviders exists rather than reusing
+ // handleConfigWizardComplete: editing a provider mid-session must not wipe
+ // the model's history or tear down the settings panel around the user.
+ // These hold whether the rebuild succeeds or fails - handleConfigWizardComplete
+ // would have called exitMode() before it ever reached the client swap.
+ t.is(setMessages.calls.length, 0, 'conversation history is left alone');
+ t.is(setActiveMode.calls.length, 0, 'does not exit the current mode');
+ t.is(setIsSettingsMode.calls.length, 0, 'leaves the settings panel open');
+ t.is(addToChatQueue.calls.length, 1, 'reports the outcome exactly once');
+});
- await handlers.handleMcpWizardComplete();
+test('enterSettingsMode forwards the requested tab', t => {
+ const {handlers, setSettingsActiveTab} = setup();
- t.deepEqual(setActiveMode.calls, [[null]]);
- t.is(addToChatQueue.calls.length, 0);
+ handlers.enterSettingsMode('mcp');
+ handlers.enterSettingsMode();
+
+ t.deepEqual(setSettingsActiveTab.calls, [['mcp'], [undefined]]);
});
diff --git a/source/hooks/useModeHandlers.tsx b/source/hooks/useModeHandlers.tsx
index 72769c1d9..09c97ed05 100644
--- a/source/hooks/useModeHandlers.tsx
+++ b/source/hooks/useModeHandlers.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import type {SettingsTabId} from '@/app/components/settings-constants';
import {createLLMClient} from '@/client-factory';
import {
ErrorMessage,
@@ -33,6 +34,7 @@ interface UseModeHandlersProps {
getMessageTokens: (message: Message) => number;
setActiveMode: (mode: ActiveMode) => void;
setIsSettingsMode: (mode: boolean) => void;
+ setSettingsActiveTab: (tab: SettingsTabId | undefined) => void;
addToChatQueue: (component: React.ReactNode) => void;
reinitializeMCPServers: (
toolManager: import('@/tools/tool-manager').ToolManager,
@@ -53,6 +55,7 @@ export function useModeHandlers({
getMessageTokens,
setActiveMode,
setIsSettingsMode,
+ setSettingsActiveTab,
addToChatQueue,
reinitializeMCPServers,
setTune,
@@ -307,19 +310,43 @@ export function useModeHandlers({
}
};
- // Handle MCP wizard complete - reinitializes MCP servers
- const handleMcpWizardComplete = async (configPath?: string) => {
- exitMode();
- if (configPath) {
+ /**
+ * Pick up provider config written to disk and rebuild the client for the
+ * current provider/model, leaving messages alone. Split out of the wizard
+ * handler so the settings panel can reuse it without the mode-exit side
+ * effects (clearing conversation, resetting to default provider/model).
+ */
+ const reloadProviders = async () => {
+ reloadAppConfig();
+
+ try {
+ const {client: newClient, actualProvider} = await createLLMClient(
+ currentProvider,
+ currentModel,
+ );
+
+ setClient(newClient);
+ setCurrentProvider(actualProvider);
+ setCurrentProviderConfig(newClient.getProviderConfig());
+
+ const newModel = newClient.getCurrentModel();
+ setCurrentModel(newModel);
+
addToChatQueue(
,
+ );
+ } catch (error) {
+ addToChatQueue(
+ ,
);
-
- await reloadMcpServers();
}
};
@@ -373,20 +400,20 @@ export function useModeHandlers({
// Convenience enter helpers
enterModelSelectionMode: () => enterMode('model'),
enterModelDatabaseMode: () => enterMode('modelDatabase'),
- enterConfigWizardMode: () => enterMode('configWizard'),
- enterMcpWizardMode: () => enterMode('mcpWizard'),
enterExplorerMode: () => enterMode('explorer'),
enterIdeSelectionMode: () => enterMode('ideSelection'),
- enterSettingsMode: () => setIsSettingsMode(true),
+ enterSettingsMode: (tab?: SettingsTabId) => {
+ setSettingsActiveTab(tab);
+ setIsSettingsMode(true);
+ },
// Cancel/complete handlers
handleModelSelect,
handleModelSelectionCancel: exitMode,
handleModelDatabaseCancel: exitMode,
handleConfigWizardComplete,
handleConfigWizardCancel: exitMode,
- handleMcpWizardComplete,
- handleMcpWizardCancel: exitMode,
reloadMcpServers,
+ reloadProviders,
handleSettingsCancel: () => setIsSettingsMode(false),
handleExplorerCancel: exitMode,
handleIdeSelectionCancel: exitMode,
diff --git a/source/types/app.ts b/source/types/app.ts
index 044f769d1..20a81c687 100644
--- a/source/types/app.ts
+++ b/source/types/app.ts
@@ -1,4 +1,5 @@
import React from 'react';
+import type {SettingsTabId} from '@/app/components/settings-constants';
import {CustomCommandExecutor} from '@/custom-commands/executor';
import {CustomCommandLoader} from '@/custom-commands/loader';
import type {Session} from '@/session/session-manager';
@@ -24,9 +25,7 @@ export interface MessageSubmissionOptions {
commandArgs?: string[];
onEnterModelSelectionMode: () => void;
onEnterModelDatabaseMode: () => void;
- onEnterConfigWizardMode: () => void;
- onEnterSettingsMode: () => void;
- onEnterMcpWizardMode: () => void;
+ onEnterSettingsMode: (tab?: SettingsTabId) => void;
onEnterExplorerMode: () => void;
onEnterIdeSelectionMode: () => void;
onEnterTune: () => void;
diff --git a/source/utils/parse-context-limit.spec.ts b/source/utils/parse-context-limit.spec.ts
new file mode 100644
index 000000000..65da78b6f
--- /dev/null
+++ b/source/utils/parse-context-limit.spec.ts
@@ -0,0 +1,46 @@
+import test from 'ava';
+import {parseContextLimit} from './parse-context-limit';
+
+test('parseContextLimit - plain number', t => {
+ t.is(parseContextLimit('8192'), 8192);
+});
+
+test('parseContextLimit - k suffix lowercase', t => {
+ t.is(parseContextLimit('128k'), 128000);
+});
+
+test('parseContextLimit - K suffix uppercase', t => {
+ t.is(parseContextLimit('128K'), 128000);
+});
+
+test('parseContextLimit - fractional k value', t => {
+ t.is(parseContextLimit('4.5k'), 4500);
+});
+
+test('parseContextLimit - zero returns null', t => {
+ t.is(parseContextLimit('0'), null);
+});
+
+test('parseContextLimit - negative returns null', t => {
+ t.is(parseContextLimit('-5'), null);
+});
+
+test('parseContextLimit - non-numeric returns null', t => {
+ t.is(parseContextLimit('abc'), null);
+});
+
+test('parseContextLimit - just k returns null', t => {
+ t.is(parseContextLimit('k'), null);
+});
+
+test('parseContextLimit - whitespace is trimmed', t => {
+ t.is(parseContextLimit(' 8192 '), 8192);
+});
+
+test('parseContextLimit - large value with k suffix', t => {
+ t.is(parseContextLimit('256k'), 256000);
+});
+
+test('parseContextLimit - decimal without k suffix', t => {
+ t.is(parseContextLimit('1024.5'), 1025);
+});
diff --git a/source/utils/parse-context-limit.ts b/source/utils/parse-context-limit.ts
new file mode 100644
index 000000000..5c1b23c3e
--- /dev/null
+++ b/source/utils/parse-context-limit.ts
@@ -0,0 +1,24 @@
+/**
+ * Parses a context limit value string, supporting k/K suffix.
+ * e.g. "8192" -> 8192, "128k" -> 128000, "128K" -> 128000
+ *
+ * Framework-free so the CLI can apply `--context-max` without loading
+ * React/Ink (needed for the ACP / plain / auth fast paths).
+ */
+export function parseContextLimit(value: string): number | null {
+ const trimmed = value.trim().toLowerCase();
+ let multiplier = 1;
+ let numStr = trimmed;
+
+ if (trimmed.endsWith('k')) {
+ multiplier = 1000;
+ numStr = trimmed.slice(0, -1);
+ }
+
+ const parsed = Number.parseFloat(numStr);
+ if (Number.isNaN(parsed) || parsed <= 0) {
+ return null;
+ }
+
+ return Math.round(parsed * multiplier);
+}
diff --git a/source/utils/path.spec.ts b/source/utils/path.spec.ts
new file mode 100644
index 000000000..9551d0b73
--- /dev/null
+++ b/source/utils/path.spec.ts
@@ -0,0 +1,44 @@
+import test from 'ava';
+import {resolve, sep} from 'node:path';
+import {homeRelative, truncateMiddle} from './path.js';
+
+const HOME = resolve('/Users/will');
+
+test('homeRelative shortens a path inside home to a tilde form', t => {
+ const input = resolve('/Users/will/projects/app');
+ t.is(homeRelative(input, HOME), `~${sep}projects${sep}app`);
+});
+
+test('homeRelative returns a bare tilde for the home directory itself', t => {
+ t.is(homeRelative(resolve('/Users/will'), HOME), '~');
+});
+
+test('homeRelative does not mangle a sibling directory that shares a prefix', t => {
+ const input = resolve('/Users/willy/projects/app');
+ t.is(homeRelative(input, HOME), input);
+});
+
+test('homeRelative leaves unrelated paths untouched', t => {
+ const input = resolve('/etc/config');
+ t.is(homeRelative(input, HOME), input);
+});
+
+test('homeRelative leaves paths untouched when home is the filesystem root', t => {
+ const root = resolve('/');
+ const child = resolve('/foo');
+ t.is(homeRelative(child, root), child);
+ t.is(homeRelative(root, root), root);
+});
+
+test('truncateMiddle leaves short strings untouched', t => {
+ t.is(truncateMiddle('/short/path', 40), '/short/path');
+});
+
+test('truncateMiddle keeps both the root and the leaf segment', t => {
+ const long = '/Users/will/projects/some-really-long-monorepo-name/src/index.ts';
+ const result = truncateMiddle(long, 30);
+ t.is(result.length, 30);
+ t.true(result.startsWith('/Users/wi'));
+ t.true(result.endsWith('index.ts'));
+ t.true(result.includes('...'));
+});
diff --git a/source/utils/path.ts b/source/utils/path.ts
new file mode 100644
index 000000000..939e3cc8b
--- /dev/null
+++ b/source/utils/path.ts
@@ -0,0 +1,38 @@
+import {homedir} from 'node:os';
+import {resolve, sep} from 'node:path';
+
+export function homeRelative(path: string, home: string = homedir()): string {
+ const resolved = resolve(path);
+ const resolvedHome = resolve(home);
+
+ if (resolvedHome === sep || /^[A-Za-z]:\\$/.test(resolvedHome)) {
+ return resolved;
+ }
+
+ if (resolved === resolvedHome) {
+ return '~';
+ }
+
+ if (resolved.startsWith(resolvedHome + sep)) {
+ return `~${resolved.slice(resolvedHome.length)}`;
+ }
+
+ return resolved;
+}
+
+// Keeps root and leaf visible; truncatePath (useTerminalWidth.tsx) only keeps the tail.
+export function truncateMiddle(str: string, maxLength: number): string {
+ if (str.length <= maxLength) {
+ return str;
+ }
+
+ const ellipsis = '...';
+ if (maxLength <= ellipsis.length) {
+ return str.slice(0, Math.max(0, maxLength));
+ }
+
+ const keepStart = Math.ceil((maxLength - ellipsis.length) / 2);
+ const keepEnd = Math.floor((maxLength - ellipsis.length) / 2);
+
+ return str.slice(0, keepStart) + ellipsis + str.slice(str.length - keepEnd);
+}
diff --git a/source/vscode/chat-panel-harness.ts b/source/vscode/chat-panel-harness.ts
index d02716c2d..47972c96b 100644
--- a/source/vscode/chat-panel-harness.ts
+++ b/source/vscode/chat-panel-harness.ts
@@ -19,6 +19,8 @@ const PANEL_SOURCE = readFileSync(mediaUrl('chat-panel.js'), 'utf8');
const SHELL_IDS = [
'add-image-btn',
+ 'add-menu-btn',
+ 'add-menu-dropdown',
'attach-btn',
'chat-input',
'chat-view',
@@ -32,6 +34,8 @@ const SHELL_IDS = [
'image-modal',
'image-preview-container',
'image-upload',
+ 'menu-attach-file',
+ 'menu-upload-image',
'mention-dropdown',
'messages-container',
'modal-image',
@@ -77,6 +81,9 @@ function queryAll(root: StubElement, selector: string): StubElement[] {
export function createElement(tagName: string): StubElement {
const classes = new Set();
const attributes = new Map();
+ // Registered handlers, so a test can drive a real listener rather than only
+ // the `onclick` properties the panel assigns directly.
+ const listeners = new Map void)[]>();
let html = '';
let text = '';
@@ -122,10 +129,21 @@ export function createElement(tagName: string): StubElement {
closest: () => null,
setAttribute: (name: string, value: string) => attributes.set(name, value),
getAttribute: (name: string) => attributes.get(name) ?? null,
- addEventListener: () => {},
- removeEventListener: () => {},
+ addEventListener: (type: string, fn: (event: StubElement) => void) => {
+ const registered = listeners.get(type);
+ if (registered) registered.push(fn);
+ else listeners.set(type, [fn]);
+ },
+ removeEventListener: (type: string, fn: (event: StubElement) => void) => {
+ listeners.set(
+ type,
+ (listeners.get(type) ?? []).filter(candidate => candidate !== fn),
+ );
+ },
focus: () => {},
- click: () => {},
+ click: (event: StubElement = {}) => {
+ for (const fn of listeners.get('click') ?? []) fn(event);
+ },
scrollTop: 0,
scrollHeight: 0,
};
@@ -202,6 +220,8 @@ export function createPanel(options: {marked?: boolean} = {}) {
const messageListeners: ((event: {data: unknown}) => void)[] = [];
// Everything the panel posts back to the extension host.
const sent: unknown[] = [];
+ // Everything a copy button has put on the clipboard, newest last.
+ const copied: string[] = [];
const sandbox: Record = {
document: {
body,
@@ -221,7 +241,14 @@ export function createPanel(options: {marked?: boolean} = {}) {
if (type === 'message') messageListeners.push(fn);
},
},
- navigator: {userAgent: '', clipboard: {writeText: async () => {}}},
+ navigator: {
+ userAgent: '',
+ clipboard: {
+ writeText: async (value: string) => {
+ copied.push(value);
+ },
+ },
+ },
acquireVsCodeApi: () => ({
postMessage: (message: unknown) => {
sent.push(message);
@@ -240,6 +267,7 @@ export function createPanel(options: {marked?: boolean} = {}) {
sandbox.marked = {parse: (value: string) => `${value}`};
}
+ sandbox.globalThis = sandbox;
createContext(sandbox);
runInContext(MENTION_UTILS_SOURCE, sandbox);
runInContext(PANEL_SOURCE, sandbox);
@@ -249,6 +277,7 @@ export function createPanel(options: {marked?: boolean} = {}) {
return {
container,
sent,
+ copied,
post(message: unknown) {
for (const listener of messageListeners) listener({data: message});
},
@@ -298,5 +327,15 @@ export function createPanel(options: {marked?: boolean} = {}) {
child.className.includes('thought-aggregator'),
);
},
+ /** The tool-call cards, in the order they were inserted. */
+ aggregators(): StubElement[] {
+ return container.children.filter((child: StubElement) =>
+ child.className.includes('tool-aggregator'),
+ );
+ },
+ /** The copy/timestamp footers currently in the transcript. */
+ footers(): StubElement[] {
+ return container.querySelectorAll('.message-footer');
+ },
};
}
diff --git a/source/vscode/chat-panel-tool-aggregation.spec.ts b/source/vscode/chat-panel-tool-aggregation.spec.ts
new file mode 100644
index 000000000..78344ac23
--- /dev/null
+++ b/source/vscode/chat-panel-tool-aggregation.spec.ts
@@ -0,0 +1,231 @@
+import test from 'ava';
+import {createPanel, type StubElement} from '@/vscode/chat-panel-harness';
+
+console.log('\nchat-panel-tool-aggregation.spec.ts');
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+/** A non-edit call that starts running and stays unfinished. */
+const startTool = (panel: any, toolCallId: string, path = 'src/a.ts') =>
+ panel.update({
+ sessionUpdate: 'tool_call',
+ toolCallId,
+ title: `read_file: ${path}`,
+ kind: 'read',
+ status: 'in_progress',
+ });
+
+const finishTool = (panel: any, toolCallId: string) =>
+ panel.update({
+ sessionUpdate: 'tool_call_update',
+ toolCallId,
+ status: 'completed',
+ });
+
+/** A call that runs to completion, so the phase it belongs to is idle. */
+const runTool = (panel: any, toolCallId: string, path?: string) => {
+ startTool(panel, toolCallId, path);
+ finishTool(panel, toolCallId);
+};
+
+const runEdit = (panel: any, toolCallId = 'edit-1') => {
+ panel.update({
+ sessionUpdate: 'tool_call',
+ toolCallId,
+ title: 'write_file: src/b.ts',
+ kind: 'edit',
+ status: 'in_progress',
+ });
+ finishTool(panel, toolCallId);
+};
+
+const sendPlan = (panel: any) =>
+ panel.update({
+ sessionUpdate: 'plan',
+ entries: [{content: 'Do the thing', status: 'in_progress'}],
+ });
+
+/** The tools listed in one aggregated card, top to bottom. */
+const rowsOf = (card: StubElement): string[] =>
+ card.querySelectorAll('.tool-label').map((row: StubElement) => row.textContent);
+
+const isOpen = (card: StubElement) => card.children[1].style.display !== 'none';
+
+const collapse = (card: StubElement) => card.children[0].onclick();
+
+/** What the transcript holds, in order, one word per element. */
+const transcript = (panel: any): string[] =>
+ panel.container.children.map((child: StubElement) => {
+ if (child.className.includes('tool-aggregator')) return 'tools';
+ if (child.className.includes('thought-aggregator')) return 'thoughts';
+ if (child.className.includes('tool-card')) return 'edit';
+ if (String(child.id).startsWith('plan-card')) return 'plan';
+ return 'text';
+ });
+
+// ============================================================================
+// A finished tool phase closes when anything else is inserted (#856)
+//
+// The aggregated card used to be reset only at the end of a turn, so a tool
+// arriving after an interruption was appended to the card ABOVE whatever the
+// agent had just inserted, putting the transcript out of order.
+// ============================================================================
+
+test('a thought between two tool phases starts a fresh card', t => {
+ const panel = createPanel();
+ runTool(panel, 'r1', 'first.ts');
+ panel.thought('considering');
+ runTool(panel, 'r2', 'second.ts');
+
+ const cards = panel.aggregators();
+ t.is(cards.length, 2);
+ t.deepEqual(rowsOf(cards[0]), ['Reading first.ts']);
+ t.deepEqual(rowsOf(cards[1]), ['Reading second.ts']);
+ t.deepEqual(transcript(panel), ['tools', 'thoughts', 'tools']);
+});
+
+test('reply text between two tool phases starts a fresh card', t => {
+ const panel = createPanel();
+ runTool(panel, 'r1', 'first.ts');
+ panel.text('Here is what I found.');
+ runTool(panel, 'r2', 'second.ts');
+
+ const cards = panel.aggregators();
+ t.is(cards.length, 2);
+ t.deepEqual(rowsOf(cards[1]), ['Reading second.ts']);
+ t.deepEqual(transcript(panel), ['tools', 'text', 'tools']);
+});
+
+test('an edit card between two tool phases starts a fresh card', t => {
+ const panel = createPanel();
+ runTool(panel, 'r1', 'first.ts');
+ runEdit(panel);
+ runTool(panel, 'r2', 'second.ts');
+
+ const cards = panel.aggregators();
+ t.is(cards.length, 2);
+ // The read after the edit must not fall back into the card above it.
+ t.deepEqual(rowsOf(cards[0]), ['Reading first.ts']);
+ t.deepEqual(rowsOf(cards[1]), ['Reading second.ts']);
+ t.deepEqual(transcript(panel), ['tools', 'edit', 'tools']);
+});
+
+test('a plan card between two tool phases starts a fresh card', t => {
+ const panel = createPanel();
+ runTool(panel, 'r1', 'first.ts');
+ sendPlan(panel);
+ runTool(panel, 'r2', 'second.ts');
+
+ const cards = panel.aggregators();
+ t.is(cards.length, 2);
+ t.deepEqual(rowsOf(cards[1]), ['Reading second.ts']);
+ t.deepEqual(transcript(panel), ['tools', 'plan', 'tools']);
+});
+
+test('an uninterrupted run of tools stays in one card', t => {
+ const panel = createPanel();
+ runTool(panel, 'r1', 'first.ts');
+ runTool(panel, 'r2', 'second.ts');
+
+ const cards = panel.aggregators();
+ t.is(cards.length, 1);
+ t.deepEqual(rowsOf(cards[0]), ['Reading first.ts', 'Reading second.ts']);
+});
+
+test('a finished phase collapses once the agent moves on', t => {
+ const panel = createPanel();
+ runTool(panel, 'r1');
+ t.true(isOpen(panel.aggregators()[0]), 'stays open while it is the phase');
+
+ panel.text('Done looking.');
+ t.false(isOpen(panel.aggregators()[0]));
+});
+
+// ============================================================================
+// An unfinished tool keeps its card, so it cannot be duplicated
+//
+// Closing a card while one of its tools was still running orphaned that tool's
+// row: the next update for it built a second row in a new card, leaving the
+// first spinning forever.
+// ============================================================================
+
+test('a running tool holds its card open across an interruption', t => {
+ const panel = createPanel();
+ startTool(panel, 'r1');
+ panel.thought('while that runs');
+
+ t.is(panel.aggregators().length, 1);
+ t.true(isOpen(panel.aggregators()[0]));
+});
+
+test('a running tool interrupted mid-flight is not duplicated', t => {
+ const panel = createPanel();
+ startTool(panel, 'r1');
+ panel.thought('while that runs');
+ finishTool(panel, 'r1');
+
+ const cards = panel.aggregators();
+ t.is(cards.length, 1, 'the late completion reuses the original card');
+ t.deepEqual(rowsOf(cards[0]), ['Reading src/a.ts']);
+ t.is(panel.container.querySelectorAll('.tool-status').length, 1);
+});
+
+test('a queued tool also holds its card open', t => {
+ const panel = createPanel();
+ // 'pending' is the queued announcement, before the call starts running.
+ panel.update({
+ sessionUpdate: 'tool_call',
+ toolCallId: 'r1',
+ title: 'read_file: src/a.ts',
+ kind: 'read',
+ status: 'pending',
+ });
+ panel.text('Queued that up.');
+ finishTool(panel, 'r1');
+
+ const cards = panel.aggregators();
+ t.is(cards.length, 1);
+ t.deepEqual(rowsOf(cards[0]), ['Reading src/a.ts']);
+});
+
+test('a phase closes once its last tool finishes', t => {
+ const panel = createPanel();
+ startTool(panel, 'r1', 'first.ts');
+ finishTool(panel, 'r1');
+ panel.text('All done.');
+ runTool(panel, 'r2', 'second.ts');
+
+ t.is(panel.aggregators().length, 2);
+});
+
+// ============================================================================
+// A manual collapse survives the card being closed
+//
+// close() collapsed by calling toggle(false), but toggle ignored its argument
+// and simply flipped, so closing a card the user had already collapsed
+// re-expanded it.
+// ============================================================================
+
+test('closing does not re-expand a card the user collapsed', t => {
+ const panel = createPanel();
+ runTool(panel, 'r1');
+
+ collapse(panel.aggregators()[0]);
+ t.false(isOpen(panel.aggregators()[0]));
+
+ panel.text('Moving on.');
+ t.false(isOpen(panel.aggregators()[0]), 'stays as the user left it');
+});
+
+test('a collapsed card can still be reopened by hand', t => {
+ const panel = createPanel();
+ runTool(panel, 'r1');
+
+ collapse(panel.aggregators()[0]);
+ panel.text('Moving on.');
+ collapse(panel.aggregators()[0]);
+
+ t.true(isOpen(panel.aggregators()[0]));
+});
diff --git a/source/vscode/chat-panel-turn-footer.spec.ts b/source/vscode/chat-panel-turn-footer.spec.ts
new file mode 100644
index 000000000..3205a8930
--- /dev/null
+++ b/source/vscode/chat-panel-turn-footer.spec.ts
@@ -0,0 +1,143 @@
+import test from 'ava';
+import {createPanel, type StubElement} from '@/vscode/chat-panel-harness';
+
+console.log('\nchat-panel-turn-footer.spec.ts');
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+/** Agent footers only - a user message carries its own, aligned the other way. */
+const agentFooters = (panel: any): StubElement[] =>
+ panel.footers().filter((footer: StubElement) =>
+ footer.classList.contains('self-start'),
+ );
+
+const copyButton = (footer: StubElement): StubElement =>
+ footer.children.find((child: StubElement) => child.title === 'Copy');
+
+const timestamp = (footer: StubElement): string =>
+ footer.children.find((child: StubElement) => child.title !== 'Copy')
+ .textContent;
+
+const runTool = (panel: any, toolCallId: string) => {
+ panel.update({
+ sessionUpdate: 'tool_call',
+ toolCallId,
+ title: 'read_file: src/a.ts',
+ kind: 'read',
+ status: 'in_progress',
+ });
+ panel.update({
+ sessionUpdate: 'tool_call_update',
+ toolCallId,
+ status: 'completed',
+ });
+};
+
+// ============================================================================
+// One footer per response, not one per text segment
+//
+// A tool card splits a response into several text blocks. Each block used to
+// grow its own copy button and timestamp, so a single answer ended up with a
+// row of them down the transcript.
+// ============================================================================
+
+test('a response split by a tool card keeps one footer', t => {
+ const panel = createPanel();
+ panel.userMessage('go');
+ panel.text('Looking into it.');
+ runTool(panel, 'r1');
+ panel.text('Here is the answer.');
+
+ t.is(agentFooters(panel).length, 1);
+});
+
+test('the footer moves to the newest text block', t => {
+ const panel = createPanel();
+ panel.userMessage('go');
+ panel.text('Looking into it.');
+ runTool(panel, 'r1');
+ panel.text('Here is the answer.');
+
+ const [footer] = agentFooters(panel);
+ const blocks = panel.container.children;
+ t.is(
+ blocks.indexOf(footer.parentElement),
+ blocks.length - 1,
+ 'the footer sits under the last block, not the first',
+ );
+});
+
+test('each response gets its own footer', t => {
+ const panel = createPanel();
+ panel.userMessage('first');
+ panel.text('Response A');
+ panel.finish();
+ panel.userMessage('second');
+ panel.text('Response B');
+
+ t.is(agentFooters(panel).length, 2);
+});
+
+// ============================================================================
+// A footer copies its own response
+//
+// The copy closure read the turn wrapper it happened to be sitting in, so once
+// a newer response arrived, an older footer handed back the newer text.
+// ============================================================================
+
+test('an older response copies its own text, not a newer one', t => {
+ const panel = createPanel();
+ panel.userMessage('first');
+ panel.text('Response A');
+ panel.finish();
+ panel.userMessage('second');
+ panel.text('Response B');
+
+ const [first, second] = agentFooters(panel);
+ copyButton(first).click();
+ t.deepEqual(panel.copied, ['Response A']);
+
+ copyButton(second).click();
+ t.deepEqual(panel.copied, ['Response A', 'Response B']);
+});
+
+test('copying a split response yields every segment', t => {
+ const panel = createPanel();
+ panel.userMessage('go');
+ panel.text('Looking into it.');
+ runTool(panel, 'r1');
+ panel.text('Here is the answer.');
+
+ copyButton(agentFooters(panel)[0]).click();
+ t.deepEqual(panel.copied, ['Looking into it.\n\nHere is the answer.']);
+});
+
+test('the footer keeps up with text still streaming in', t => {
+ const panel = createPanel();
+ panel.userMessage('go');
+ panel.text('Half a ');
+ panel.text('sentence.');
+
+ copyButton(agentFooters(panel)[0]).click();
+ t.deepEqual(panel.copied, ['Half a sentence.']);
+});
+
+test('clearing the session drops the footer', t => {
+ const panel = createPanel();
+ panel.userMessage('go');
+ panel.text('Response A');
+ const before = timestamp(agentFooters(panel)[0]);
+
+ panel.post({type: 'clear'});
+ panel.advance(90 * 60 * 1000);
+ panel.text('Response B');
+
+ const footers = agentFooters(panel);
+ t.is(footers.length, 1, 'the cleared turn does not leave its footer behind');
+ // A reused footer would still be stamped with the cleared session's time.
+ t.not(timestamp(footers[0]), before);
+ copyButton(footers[0]).click();
+ t.deepEqual(panel.copied, ['Response B']);
+});
diff --git a/source/wizards/mcp-wizard.tsx b/source/wizards/mcp-wizard.tsx
index 07f737034..8bf66e735 100644
--- a/source/wizards/mcp-wizard.tsx
+++ b/source/wizards/mcp-wizard.tsx
@@ -9,6 +9,8 @@ interface McpWizardProps {
projectDir: string;
onComplete: (configPath: string) => void;
onCancel?: () => void;
+ /** Open straight into the edit/delete choice for this server. */
+ initialEditName?: string;
}
type McpServers = Record;
@@ -42,7 +44,12 @@ function McpSummaryItems({items}: {items: McpServers}) {
);
}
-export function McpWizard({projectDir, onComplete, onCancel}: McpWizardProps) {
+export function McpWizard({
+ projectDir,
+ onComplete,
+ onCancel,
+ initialEditName,
+}: McpWizardProps) {
return (
title="MCP Server Configuration"
@@ -65,6 +72,7 @@ export function McpWizard({projectDir, onComplete, onCancel}: McpWizardProps) {
onBack={onBack}
onDelete={onDelete}
configExists={configExists}
+ initialEditName={initialEditName}
/>
)}
renderSummaryItems={items => }
diff --git a/source/wizards/provider-wizard.tsx b/source/wizards/provider-wizard.tsx
index 9618c3260..7f75f3a24 100644
--- a/source/wizards/provider-wizard.tsx
+++ b/source/wizards/provider-wizard.tsx
@@ -15,6 +15,8 @@ interface ProviderWizardProps {
projectDir: string;
onComplete: (configPath: string) => void;
onCancel?: () => void;
+ /** Open straight into the edit/delete choice for this provider. */
+ initialEditName?: string;
}
function parseProviderConfig(raw: unknown): ProviderWizardState {
@@ -127,12 +129,14 @@ function ProviderWizardSteps({
onBack,
onDelete,
configExists,
+ initialEditName,
}: {
items: ProviderWizardState;
onComplete: (items: ProviderWizardState) => void;
onBack: () => void;
onDelete: () => void;
configExists: boolean;
+ initialEditName?: string;
}) {
const [step, setStep] = useState<'providers' | 'modes'>('providers');
const [providers, setProviders] = useState(items.providers);
@@ -148,6 +152,7 @@ function ProviderWizardSteps({
onBack={onBack}
onDelete={onDelete}
configExists={configExists}
+ initialEditName={initialEditName}
/>
);
}
@@ -168,6 +173,7 @@ export function ProviderWizard({
projectDir,
onComplete,
onCancel,
+ initialEditName,
}: ProviderWizardProps) {
return (
@@ -178,7 +184,9 @@ export function ProviderWizard({
parseConfig={parseProviderConfig}
buildConfig={buildProviderConfigObject}
hasItems={items => items.providers.length > 0}
- renderConfigureStep={args => }
+ renderConfigureStep={args => (
+
+ )}
renderSummaryItems={items => }
renderCompleteExtras={items => }
projectDir={projectDir}
diff --git a/source/wizards/steps/location-step.spec.tsx b/source/wizards/steps/location-step.spec.tsx
index a2873edbd..4b0f014d3 100644
--- a/source/wizards/steps/location-step.spec.tsx
+++ b/source/wizards/steps/location-step.spec.tsx
@@ -1,8 +1,34 @@
+import {resolve} from 'node:path';
import test from 'ava';
+import {TitledBoxWithPreferences} from '@/components/ui/titled-box';
+import {useResponsiveTerminal} from '@/hooks/useTerminalWidth';
import {renderWithTheme as render} from '@/test-utils/render-with-theme';
import React from 'react';
import {LocationStep} from './location-step.js';
+// Mirrors base-config-wizard.tsx's real LocationStep box.
+function WizardBox({
+ projectDir,
+ onComplete,
+}: {
+ projectDir: string;
+ onComplete: () => void;
+}) {
+ const {boxWidth} = useResponsiveTerminal();
+ return (
+
+
+
+ );
+}
+
// ============================================================================
// Tests for LocationStep Component Rendering
// ============================================================================
@@ -37,6 +63,51 @@ test('LocationStep shows global config option', t => {
t.regex(output!, /Global user config/);
});
+test('LocationStep shows the resolved project path next to the option', t => {
+ const {lastFrame} = render(
+ {}} projectDir="/test/project" />,
+ );
+
+ const output = lastFrame();
+ t.truthy(output);
+ const lines = output!.split('\n');
+ const stemIndex = lines.findIndex(line =>
+ line.includes('Current project directory'),
+ );
+ t.true(stemIndex !== -1, 'expected to find the project directory stem');
+ t.is(lines[stemIndex + 1]?.trim(), resolve('/test/project'));
+});
+
+test('LocationStep does not clip the leaf directory inside the real wizard box', t => {
+ const originalColumns = process.stdout.columns;
+ try {
+ for (const columns of [60, 80, 120]) {
+ Object.defineProperty(process.stdout, 'columns', {
+ value: columns,
+ configurable: true,
+ });
+
+ const {lastFrame} = render(
+ {}}
+ projectDir="/Users/will/Documents/GitHub/some-org/some-really-long-monorepo-name/packages/leaf-dir"
+ />,
+ );
+
+ const output = lastFrame();
+ t.true(
+ output!.includes('leaf-dir'),
+ `at ${columns} cols, expected leaf directory to stay visible, got: ${output}`,
+ );
+ }
+ } finally {
+ Object.defineProperty(process.stdout, 'columns', {
+ value: originalColumns,
+ configurable: true,
+ });
+ }
+});
+
test('LocationStep shows tip about config types', t => {
const {lastFrame} = render(
{}} projectDir="/test/project" />,
diff --git a/source/wizards/steps/location-step.tsx b/source/wizards/steps/location-step.tsx
index 178012604..50299f57c 100644
--- a/source/wizards/steps/location-step.tsx
+++ b/source/wizards/steps/location-step.tsx
@@ -5,7 +5,9 @@ import {useState} from 'react';
import {StyledSelectInput} from '@/components/ui/styled-select-input';
import {getColors} from '@/config';
import {getConfigPath} from '@/config/paths';
+import {MIN_PATH_BUDGET_CHARS, WIZARD_ROW_CHROME_CHARS} from '@/constants';
import {useResponsiveTerminal} from '@/hooks/useTerminalWidth';
+import {homeRelative, truncateMiddle} from '@/utils/path';
export type ConfigLocation = 'project' | 'global';
@@ -19,6 +21,7 @@ interface LocationStepProps {
interface LocationOption {
label: string;
value: ConfigLocation;
+ path: string;
}
export function LocationStep({
@@ -28,7 +31,7 @@ export function LocationStep({
configFileName = 'agents.config.json',
}: LocationStepProps) {
const colors = getColors();
- const {isNarrow, truncatePath} = useResponsiveTerminal();
+ const {boxWidth, isNarrow} = useResponsiveTerminal();
const projectPath = join(projectDir, configFileName);
const globalPath = join(getConfigPath(), configFileName);
@@ -51,12 +54,14 @@ export function LocationStep({
const locationOptions: LocationOption[] = [
{
- label: `Global user config`,
+ label: 'Global user config',
value: 'global',
+ path: homeRelative(getConfigPath()),
},
{
- label: `Current project directory`,
+ label: 'Current project directory',
value: 'project',
+ path: homeRelative(projectDir),
},
];
@@ -101,7 +106,7 @@ export function LocationStep({
Configuration found at:{' '}
- {isNarrow ? truncatePath(existingPath, 40) : existingPath}
+ {isNarrow ? truncateMiddle(existingPath, 40) : existingPath}
handleLocationSelect(item)}
+ itemComponent={({isSelected, label, path}) => {
+ const color = isSelected ? colors.primary : colors.text;
+ const pathBudget = Math.max(
+ MIN_PATH_BUDGET_CHARS,
+ boxWidth - WIZARD_ROW_CHROME_CHARS,
+ );
+ return (
+
+
+ {label}
+
+
+
+ {truncateMiddle(path, pathBudget)}
+
+
+
+ );
+ }}
/>
{!isNarrow && (
diff --git a/source/wizards/steps/mcp-step.spec.tsx b/source/wizards/steps/mcp-step.spec.tsx
index 2997bef2b..150708c17 100644
--- a/source/wizards/steps/mcp-step.spec.tsx
+++ b/source/wizards/steps/mcp-step.spec.tsx
@@ -964,3 +964,50 @@ test.serial(
unmount();
},
);
+
+// ============================================================================
+// Deep-linking straight into one server's edit/delete choice
+// ============================================================================
+
+const deepLinkServers = {
+ filesystem: {transport: 'stdio' as const, command: 'mcp-fs'},
+ github: {transport: 'stdio' as const, command: 'mcp-gh'},
+};
+
+test('McpStep with initialEditName opens that server edit/delete choice', t => {
+ const {lastFrame} = render(
+ {}}
+ existingServers={deepLinkServers}
+ initialEditName="github"
+ />,
+ );
+
+ const output = lastFrame()!;
+ t.regex(output, /Edit this server/);
+ t.notRegex(
+ output,
+ /Add MCP servers/,
+ 'should skip the initial menu entirely',
+ );
+});
+
+test('McpStep falls back to the menu when initialEditName is unknown', t => {
+ const {lastFrame} = render(
+ {}}
+ existingServers={deepLinkServers}
+ initialEditName="not-a-configured-server"
+ />,
+ );
+
+ t.regex(lastFrame()!, /Add MCP servers/);
+});
+
+test('McpStep without initialEditName still opens the initial menu', t => {
+ const {lastFrame} = render(
+ {}} existingServers={deepLinkServers} />,
+ );
+
+ t.regex(lastFrame()!, /Add MCP servers/);
+});
diff --git a/source/wizards/steps/mcp-step.tsx b/source/wizards/steps/mcp-step.tsx
index 5d7d98c94..bc6d4819f 100644
--- a/source/wizards/steps/mcp-step.tsx
+++ b/source/wizards/steps/mcp-step.tsx
@@ -26,6 +26,21 @@ interface McpStepProps {
onDelete?: () => void;
existingServers?: Record;
configExists?: boolean;
+ /**
+ * Open straight into the edit/delete choice for this server instead of the
+ * initial menu. An unknown name falls back to the normal menu, since the
+ * caller lists the resolved config while this step lists whichever config
+ * file the wizard loaded.
+ */
+ initialEditName?: string;
+}
+
+function findServerName(
+ servers: Record,
+ name: string | undefined,
+): string | null {
+ if (!name) return null;
+ return Object.hasOwn(servers, name) ? name : null;
}
type Mode =
@@ -47,6 +62,7 @@ export function McpStep({
onDelete,
existingServers = {},
configExists = false,
+ initialEditName,
}: McpStepProps) {
const colors = getColors();
const {isNarrow} = useResponsiveTerminal();
@@ -60,7 +76,11 @@ export function McpStep({
setServers(existingServers);
}, [existingServers]);
- const [mode, setMode] = useState('initial-menu');
+ const [mode, setMode] = useState(() =>
+ findServerName(existingServers, initialEditName) === null
+ ? 'initial-menu'
+ : 'edit-or-delete',
+ );
const {
selectedTemplate,
currentFieldIndex,
@@ -78,7 +98,7 @@ export function McpStep({
} = useWizardForm();
const [multilineBuffer, setMultilineBuffer] = useState('');
const [editingServerName, setEditingServerName] = useState(
- null,
+ () => findServerName(existingServers, initialEditName),
);
const [activeTab, setActiveTab] = useState<'local' | 'remote'>('local');
diff --git a/source/wizards/steps/provider-step.spec.tsx b/source/wizards/steps/provider-step.spec.tsx
index de168b641..fbb42f272 100644
--- a/source/wizards/steps/provider-step.spec.tsx
+++ b/source/wizards/steps/provider-step.spec.tsx
@@ -913,3 +913,68 @@ test.serial(
unmount();
},
);
+
+// ============================================================================
+// Deep-linking straight into one provider's edit/delete choice
+// ============================================================================
+
+const deepLinkProviders = [
+ {name: 'ollama', baseUrl: 'http://localhost:11434/v1', models: ['llama2']},
+ {name: 'openrouter', baseUrl: 'https://openrouter.ai/api/v1', models: ['x']},
+];
+
+test('ProviderStep with initialEditName opens that provider edit/delete choice', t => {
+ const {lastFrame} = render(
+ {}}
+ existingProviders={deepLinkProviders}
+ initialEditName="openrouter"
+ />,
+ );
+
+ const output = lastFrame()!;
+ t.regex(output, /Edit this provider/);
+ t.regex(output, /Delete this provider/);
+ t.notRegex(
+ output,
+ /Let's add AI providers/,
+ 'should skip the template menu entirely',
+ );
+});
+
+test('ProviderStep initialEditName targets the named provider, not the first', t => {
+ const {lastFrame} = render(
+ {}}
+ existingProviders={deepLinkProviders}
+ initialEditName="openrouter"
+ />,
+ );
+
+ t.regex(lastFrame()!, /openrouter/);
+});
+
+test('ProviderStep falls back to the menu when initialEditName is unknown', t => {
+ const {lastFrame} = render(
+ {}}
+ existingProviders={deepLinkProviders}
+ initialEditName="not-a-configured-provider"
+ />,
+ );
+
+ // The settings panel lists the resolved config while the wizard loads a
+ // single file, so a name it cannot find must not strand the user.
+ t.regex(lastFrame()!, /Let's add AI providers/);
+});
+
+test('ProviderStep without initialEditName still opens the template menu', t => {
+ const {lastFrame} = render(
+ {}}
+ existingProviders={deepLinkProviders}
+ />,
+ );
+
+ t.regex(lastFrame()!, /Let's add AI providers/);
+});
diff --git a/source/wizards/steps/provider-step.tsx b/source/wizards/steps/provider-step.tsx
index 07a776757..266410893 100644
--- a/source/wizards/steps/provider-step.tsx
+++ b/source/wizards/steps/provider-step.tsx
@@ -25,6 +25,23 @@ interface ProviderStepProps {
onDelete?: () => void;
existingProviders?: ProviderConfig[];
configExists?: boolean;
+ /**
+ * Open straight into the edit/delete choice for this provider instead of the
+ * template menu. Matched by name rather than index: the caller (the settings
+ * panel) lists the resolved config, while this step lists whichever config
+ * file the wizard loaded, so positions need not line up. An unknown name
+ * falls back to the normal menu.
+ */
+ initialEditName?: string;
+}
+
+function findProviderIndex(
+ providers: ProviderConfig[] | undefined,
+ name: string | undefined,
+): number | null {
+ if (!name || !providers) return null;
+ const index = providers.findIndex(provider => provider.name === name);
+ return index === -1 ? null : index;
}
type Mode =
@@ -82,6 +99,7 @@ export function ProviderStep({
onDelete,
existingProviders,
configExists = false,
+ initialEditName,
}: ProviderStepProps) {
const colors = getColors();
const {isNarrow} = useResponsiveTerminal();
@@ -98,7 +116,11 @@ export function ProviderStep({
}
}, [existingProviders]);
- const [mode, setMode] = useState('select-template-or-custom');
+ const [mode, setMode] = useState(() =>
+ findProviderIndex(existingProviders, initialEditName) === null
+ ? 'select-template-or-custom'
+ : 'edit-or-delete',
+ );
const {
selectedTemplate,
currentFieldIndex,
@@ -115,7 +137,9 @@ export function ProviderStep({
bumpInputKey,
} = useWizardForm();
const [cameFromCustom, setCameFromCustom] = useState(false);
- const [editingIndex, setEditingIndex] = useState(null);
+ const [editingIndex, setEditingIndex] = useState(() =>
+ findProviderIndex(existingProviders, initialEditName),
+ );
const [fetchedModels, setFetchedModels] = useState([]);
const [selectedModelIds, setSelectedModelIds] = useState>(
new Set(),
diff --git a/tsconfig.json b/tsconfig.json
index e248637a1..454e4003c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -18,7 +18,13 @@
"types": ["node"],
"baseUrl": ".",
"paths": {
- "@/*": ["source/*"]
+ "@/*": ["source/*"],
+ // The real `vscode` module only exists inside the extension host, so
+ // AVA (which loads specs through tsx, and tsx honours these paths)
+ // resolves it to a stub. Test-only: the extension is bundled from
+ // plugins/vscode/tsconfig.json with `--external:vscode`, and nothing
+ // under source/ imports it.
+ "vscode": ["plugins/vscode/test-stubs/vscode.ts"]
}
},
"include": ["source/**/*"],