From d472248b779b29799d206fd4d6d492e16e9f3169 Mon Sep 17 00:00:00 2001
From: Jonas Kunert
Date: Wed, 22 Jul 2026 08:43:21 +0200
Subject: [PATCH 01/12] fix: permission requests died with 'Stream closed'
(fork-issue-15)
Root cause (regression from a156881, MCP-file -> stdio migration):
the 'result' event closed the Claude process stdin immediately, tearing
down the stdio control channel while can_use_tool requests could still
be in flight -> every permission prompt aborted instantly.
- Defer stdin.end via _maybeEndClaudeStdin: close only once 'result'
was seen AND no permission request is pending (500ms grace window),
retried after each answered prompt so the process still exits cleanly.
- Re-entrancy guard in _sendMessageToClaude: reject a second send while
a process is running (overlapping --resume spawns fought over the
session lock; queueing stays fork-issue-16).
- [perm] diagnostic logging on spawn/result/can_use_tool/response/kill
to prove the failing path at runtime if it ever reoccurs.
Co-Authored-By: Claude Fable 5
---
src/extension.ts | 77 +++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 73 insertions(+), 4 deletions(-)
diff --git a/src/extension.ts b/src/extension.ts
index 8fa37fb..d8e4779 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -198,6 +198,10 @@ class ClaudeChatProvider {
private _wslDistro: string = 'Ubuntu';
private _selectedModel: string = 'default'; // Default model
private _isProcessing: boolean | undefined;
+ // Set once a 'result' message was seen for the current process. Gates the
+ // deferred stdin close so we never tear down the stdio control channel while
+ // a permission round-trip is still pending (would surface as "Stream closed").
+ private _resultSeen: boolean = false;
private _draftMessage: string = '';
constructor(
@@ -860,6 +864,21 @@ class ClaudeChatProvider {
}
private async _sendMessageToClaude(message: string, planMode?: boolean, thinkingMode?: boolean, images?: string[]) {
+ // Re-entrancy guard: a Claude process is already running for this session.
+ // Spawning a second overlapping process (same --resume session) closes the
+ // first one's stdio control channel and fights over the session lock, which
+ // surfaces as "AbortError: Stream closed" on every permission request.
+ // Reject the new send instead (covers sendMessage, slash-command and
+ // plan-file-save entry points, which all funnel through here).
+ if (this._isProcessing || this._currentClaudeProcess) {
+ console.error(`[perm] rejected re-entrant send (guard hit) processing=${this._isProcessing} pid=${this._currentClaudeProcess?.pid}`);
+ this._postMessage({
+ type: 'error',
+ data: '⏳ Claude is still working on the previous message. Please wait for it to finish or press Stop.'
+ });
+ return;
+ }
+
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : process.cwd();
@@ -1073,6 +1092,10 @@ class ClaudeChatProvider {
// Store process reference for potential termination
this._currentClaudeProcess = claudeProcess;
+ // New process = new turn: no 'result' seen yet, so the deferred stdin
+ // close stays armed until this turn actually completes.
+ this._resultSeen = false;
+ console.error(`[perm] spawned claude pid=${claudeProcess.pid} session=${this._currentSessionId ?? '(new)'}`);
// Send the message to Claude's stdin as JSON (stream-json input format)
// Don't end stdin yet - we need to keep it open for permission responses
@@ -1202,11 +1225,17 @@ class ClaudeChatProvider {
continue;
}
- // Handle result message - end stdin when done
+ // Handle result message - end stdin when the turn is truly done.
+ // Do NOT close immediately: closing stdin tears down the shared
+ // stdio control channel, so any permission request still pending
+ // (or a late can_use_tool arriving right after 'result') aborts with
+ // "Stream closed". Defer via _maybeEndClaudeStdin, which only closes
+ // once no permission round-trip is in flight (plus a short grace
+ // window for a trailing can_use_tool).
if (jsonData.type === 'result') {
- if (claudeProcess.stdin && !claudeProcess.stdin.destroyed) {
- claudeProcess.stdin.end();
- }
+ this._resultSeen = true;
+ console.error(`[perm] result subtype=${jsonData.subtype} pending=${this._pendingPermissionRequests.size} pid=${claudeProcess.pid}`);
+ setTimeout(() => this._maybeEndClaudeStdin(claudeProcess), 500);
}
this._processJsonStreamData(jsonData);
@@ -1965,6 +1994,30 @@ class ClaudeChatProvider {
}
}
+ /**
+ * End the Claude process stdin once the turn is complete (result seen) and no
+ * permission round-trip is still pending. Ending stdin closes the stdio control
+ * channel, so doing it while a can_use_tool is in flight makes the CLI abort the
+ * request with "Stream closed". Safe to call repeatedly (no-op until conditions
+ * hold); called both after 'result' (with a grace delay) and after each
+ * permission response, so a deferred close still fires once the last prompt is
+ * answered — the process then exits cleanly (no zombie, no stuck "working").
+ */
+ private _maybeEndClaudeStdin(claudeProcess: cp.ChildProcess): void {
+ if (!claudeProcess.stdin || claudeProcess.stdin.destroyed) {
+ return;
+ }
+ if (!this._resultSeen) {
+ return;
+ }
+ if (this._pendingPermissionRequests.size > 0) {
+ console.error(`[perm] stdin.end deferred: ${this._pendingPermissionRequests.size} pending pid=${claudeProcess.pid}`);
+ return;
+ }
+ console.error(`[perm] stdin.end (turn done, no pending) pid=${claudeProcess.pid}`);
+ claudeProcess.stdin.end();
+ }
+
/**
* Handle control_request messages from Claude CLI via stdio
* This is the new permission flow that replaces the MCP file-based approach
@@ -1978,6 +2031,8 @@ class ClaudeChatProvider {
return;
}
+ console.error(`[perm] can_use_tool received ts=${new Date().toISOString()} tool=${request.tool_name} reqId=${requestId} resultSeen=${this._resultSeen} pid=${this._currentClaudeProcess?.pid}`);
+
const toolName = request.tool_name || 'Unknown Tool';
const input = request.input || {};
const suggestions = request.permission_suggestions;
@@ -2055,6 +2110,7 @@ class ClaudeChatProvider {
console.error('Cannot send permission response: stdin not available');
return;
}
+ console.error(`[perm] sending response reqId=${requestId} approved=${approved} -> pid=${this._currentClaudeProcess.pid}`);
let response: any;
if (approved) {
@@ -2127,6 +2183,13 @@ class ClaudeChatProvider {
if (alwaysAllow && approved) {
void this._saveLocalPermission(pendingRequest.toolName, pendingRequest.input);
}
+
+ // If 'result' already arrived while this prompt was still open, the stdin
+ // close was deferred — now that the last pending request is answered, close
+ // it so the process terminates cleanly.
+ if (this._currentClaudeProcess) {
+ this._maybeEndClaudeStdin(this._currentClaudeProcess);
+ }
}
/**
@@ -2188,9 +2251,14 @@ class ClaudeChatProvider {
}
};
+ console.error(`[perm] sending askUserQuestion response reqId=${requestId} -> pid=${this._currentClaudeProcess.pid}`);
const responseJson = JSON.stringify(response) + '\n';
this._currentClaudeProcess.stdin.write(responseJson);
+ // If 'result' already arrived while this prompt was open, the stdin close
+ // was deferred — close it now that the last pending request is answered.
+ this._maybeEndClaudeStdin(this._currentClaudeProcess);
+
// Update the saved conversation message to reflect answered status
const savedMsg = this._currentConversation.find(
m => m.messageType === 'askUserQuestion' && m.data?.id === requestId
@@ -3167,6 +3235,7 @@ class ClaudeChatProvider {
private async _killClaudeProcess(): Promise {
const processToKill = this._currentClaudeProcess;
const pid = processToKill?.pid;
+ console.error(`[perm] killClaudeProcess pid=${pid} current=${this._currentClaudeProcess?.pid}`);
// 1. Abort via controller (clean API)
this._abortController?.abort();
From 8fcef51d4e3f1f7e6517c706e3df9b0654ecf321 Mon Sep 17 00:00:00 2001
From: Jonas Kunert
Date: Wed, 22 Jul 2026 15:03:24 +0200
Subject: [PATCH 02/12] feat: compact chat UI mode (fork-issue-18)
New setting claudeCodeChat.ui.compactMode (default off): reduces
paddings, margins, font sizes and line heights of chat messages,
tool blocks and code blocks for a denser layout. Nothing is hidden
or restructured - density only; the normal layout is untouched.
- Reuses the existing settingsData transport (_sendCurrentSettings)
to push the flag into the webview, which toggles a body-level
compact-mode class; all overrides live in one scoped CSS block.
- onDidChangeConfiguration listener extended so changing the setting
in the native VS Code settings UI applies live without a reload.
Also ships temporary file-based [perm] diagnostics for fork-issue-15: console.error
of an installed extension is not persisted anywhere readable, so _permLog()
mirrors all permission-channel events (stdin close/error, stdin.end stack,
can_use_tool stdin state, process close) to os.tmpdir()/claude-code-chat-perm.log.
To be removed once fork-issue-15 is root-caused.
Co-Authored-By: Claude Fable 5
---
package.json | 5 ++++
src/extension.ts | 58 +++++++++++++++++++++++++++++++++++++++-------
src/script.ts | 2 ++
src/ui-styles.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 117 insertions(+), 8 deletions(-)
diff --git a/package.json b/package.json
index 6bcdf89..b53dd69 100644
--- a/package.json
+++ b/package.json
@@ -208,6 +208,11 @@
"type": "boolean",
"default": false,
"description": "Enable the local router to convert OpenAI format to Anthropic format. Required for providers that use OpenAI-compatible APIs."
+ },
+ "claudeCodeChat.ui.compactMode": {
+ "type": "boolean",
+ "default": false,
+ "description": "Reduce paddings, margins and font sizes for a denser chat layout."
}
}
}
diff --git a/src/extension.ts b/src/extension.ts
index d8e4779..d97b97b 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -3,6 +3,7 @@ import * as cp from 'child_process';
import * as util from 'util';
import * as path from 'path';
import * as os from 'os';
+import * as fs from 'fs';
import getHtml from './ui';
import { startRouter, stopRouter, setModelConfig, setBaseUrl } from './router';
import { fetchAndResolveModels } from './model-updater';
@@ -16,6 +17,11 @@ let OPENCREDITS_PUBLISHABLE_KEY = 'oc_pk_c43da4f9a9484ae484ad29bc97cc354f';
const exec = util.promisify(cp.exec);
+// File target for [perm] diagnostics (#15): console.error of an installed
+// extension is only visible in the DevTools console, which makes field
+// debugging of the stdio permission channel impossible — mirror it to a file.
+const PERM_LOG_FILE = path.join(os.tmpdir(), 'claude-code-chat-perm.log');
+
// Storage for diff content (used by DiffContentProvider)
const diffContentStore = new Map();
@@ -57,6 +63,9 @@ export function activate(context: vscode.ExtensionContext) {
if (event.affectsConfiguration('claudeCodeChat.wsl')) {
provider.newSessionOnConfigChange();
}
+ if (event.affectsConfiguration('claudeCodeChat.ui.compactMode')) {
+ provider.refreshSettingsOnConfigChange();
+ }
});
// Create status bar item
@@ -1095,7 +1104,16 @@ class ClaudeChatProvider {
// New process = new turn: no 'result' seen yet, so the deferred stdin
// close stays armed until this turn actually completes.
this._resultSeen = false;
- console.error(`[perm] spawned claude pid=${claudeProcess.pid} session=${this._currentSessionId ?? '(new)'}`);
+ this._permLog(`spawned claude pid=${claudeProcess.pid} session=${this._currentSessionId ?? '(new)'}`);
+
+ // stdin lifecycle tracing (#15): record every way the control channel can
+ // die, so a field "Stream closed" can be attributed to a concrete event.
+ claudeProcess.stdin?.on('close', () => {
+ this._permLog(`stdin CLOSE event pid=${claudeProcess.pid} current=${this._currentClaudeProcess?.pid ?? 'none'}`);
+ });
+ claudeProcess.stdin?.on('error', (err) => {
+ this._permLog(`stdin ERROR event pid=${claudeProcess.pid}: ${err.message}`);
+ });
// Send the message to Claude's stdin as JSON (stream-json input format)
// Don't end stdin yet - we need to keep it open for permission responses
@@ -1234,7 +1252,7 @@ class ClaudeChatProvider {
// window for a trailing can_use_tool).
if (jsonData.type === 'result') {
this._resultSeen = true;
- console.error(`[perm] result subtype=${jsonData.subtype} pending=${this._pendingPermissionRequests.size} pid=${claudeProcess.pid}`);
+ this._permLog(`result subtype=${jsonData.subtype} pending=${this._pendingPermissionRequests.size} pid=${claudeProcess.pid}`);
setTimeout(() => this._maybeEndClaudeStdin(claudeProcess), 500);
}
@@ -1683,6 +1701,11 @@ class ClaudeChatProvider {
});
}
+ public refreshSettingsOnConfigChange() {
+ // Push current settings (e.g. ui.compactMode) to the webview without a reload
+ this._sendCurrentSettings();
+ }
+
public newSessionOnConfigChange() {
// Start a new session due to configuration change
this._newSession();
@@ -1994,6 +2017,21 @@ class ClaudeChatProvider {
}
}
+ /**
+ * [perm] diagnostics (#15): mirror to console AND a temp file, because the
+ * console of an installed extension host is not persisted anywhere readable.
+ * Logging must never break the extension — swallow all fs errors.
+ */
+ private _permLog(msg: string): void {
+ const line = `${new Date().toISOString()} [perm] ${msg}`;
+ console.error(line);
+ try {
+ fs.appendFileSync(PERM_LOG_FILE, line + '\n');
+ } catch {
+ // ignore — diagnostics only
+ }
+ }
+
/**
* End the Claude process stdin once the turn is complete (result seen) and no
* permission round-trip is still pending. Ending stdin closes the stdio control
@@ -2011,10 +2049,12 @@ class ClaudeChatProvider {
return;
}
if (this._pendingPermissionRequests.size > 0) {
- console.error(`[perm] stdin.end deferred: ${this._pendingPermissionRequests.size} pending pid=${claudeProcess.pid}`);
+ this._permLog(`stdin.end deferred: ${this._pendingPermissionRequests.size} pending pid=${claudeProcess.pid}`);
return;
}
- console.error(`[perm] stdin.end (turn done, no pending) pid=${claudeProcess.pid}`);
+ // Log the call origin: 'result'-timer vs. answered-request path — this is
+ // the one place that legitimately closes the control channel (#15).
+ this._permLog(`stdin.end (turn done, no pending) pid=${claudeProcess.pid} stack=${new Error().stack?.split('\n').slice(2, 5).join(' | ')}`);
claudeProcess.stdin.end();
}
@@ -2031,7 +2071,8 @@ class ClaudeChatProvider {
return;
}
- console.error(`[perm] can_use_tool received ts=${new Date().toISOString()} tool=${request.tool_name} reqId=${requestId} resultSeen=${this._resultSeen} pid=${this._currentClaudeProcess?.pid}`);
+ const curStdin = this._currentClaudeProcess?.stdin;
+ this._permLog(`can_use_tool received tool=${request.tool_name} reqId=${requestId} resultSeen=${this._resultSeen} pid=${this._currentClaudeProcess?.pid} stdin.destroyed=${curStdin?.destroyed} stdin.writableEnded=${curStdin?.writableEnded}`);
const toolName = request.tool_name || 'Unknown Tool';
const input = request.input || {};
@@ -2110,7 +2151,7 @@ class ClaudeChatProvider {
console.error('Cannot send permission response: stdin not available');
return;
}
- console.error(`[perm] sending response reqId=${requestId} approved=${approved} -> pid=${this._currentClaudeProcess.pid}`);
+ this._permLog(`sending response reqId=${requestId} approved=${approved} -> pid=${this._currentClaudeProcess.pid} stdin.writableEnded=${this._currentClaudeProcess.stdin?.writableEnded}`);
let response: any;
if (approved) {
@@ -2251,7 +2292,7 @@ class ClaudeChatProvider {
}
};
- console.error(`[perm] sending askUserQuestion response reqId=${requestId} -> pid=${this._currentClaudeProcess.pid}`);
+ this._permLog(`sending askUserQuestion response reqId=${requestId} -> pid=${this._currentClaudeProcess.pid} stdin.writableEnded=${this._currentClaudeProcess.stdin?.writableEnded}`);
const responseJson = JSON.stringify(response) + '\n';
this._currentClaudeProcess.stdin.write(responseJson);
@@ -3235,7 +3276,7 @@ class ClaudeChatProvider {
private async _killClaudeProcess(): Promise {
const processToKill = this._currentClaudeProcess;
const pid = processToKill?.pid;
- console.error(`[perm] killClaudeProcess pid=${pid} current=${this._currentClaudeProcess?.pid}`);
+ this._permLog(`killClaudeProcess pid=${pid} current=${this._currentClaudeProcess?.pid}`);
// 1. Abort via controller (clean API)
this._abortController?.abort();
@@ -3469,6 +3510,7 @@ class ClaudeChatProvider {
'executable.path': config.get('executable.path', ''),
'environment.variables': config.get>('environment.variables', {}),
'environment.disabled': config.get('environment.disabled', false),
+ 'ui.compactMode': config.get('ui.compactMode', false),
'isOpenCredits': this._isOpenCredits()
};
diff --git a/src/script.ts b/src/script.ts
index 4c949e2..820f988 100644
--- a/src/script.ts
+++ b/src/script.ts
@@ -5159,6 +5159,8 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
});
} else if (message.type === 'settingsData') {
// Update UI with current settings
+ document.body.classList.toggle('compact-mode', !!message.data['ui.compactMode']);
+
const thinkingIntensity = message.data['thinking.intensity'] || 'think';
const intensityValues = ['think', 'think-hard', 'think-harder', 'ultrathink'];
const sliderValue = intensityValues.indexOf(thinkingIntensity);
diff --git a/src/ui-styles.ts b/src/ui-styles.ts
index 76bd766..bad8267 100644
--- a/src/ui-styles.ts
+++ b/src/ui-styles.ts
@@ -5049,6 +5049,66 @@ const styles = `
color: #10b981 !important;
}
+ /* Compact mode (#18) */
+ body.compact-mode .messages {
+ padding: 5px;
+ font-size: calc(var(--vscode-editor-font-size) - 1px);
+ line-height: 1.3;
+ }
+
+ body.compact-mode .message {
+ margin-bottom: 5px;
+ padding: 4px;
+ }
+
+ body.compact-mode .message-header {
+ margin-bottom: 4px;
+ padding-bottom: 3px;
+ }
+
+ body.compact-mode .message-content {
+ padding-left: 3px;
+ }
+
+ body.compact-mode .message p {
+ margin: 0.3em 0;
+ line-height: 1.4;
+ }
+
+ body.compact-mode .message li {
+ margin: 0.15em 0;
+ }
+
+ body.compact-mode .tool-header {
+ margin-bottom: 6px;
+ padding-bottom: 4px;
+ }
+
+ body.compact-mode .tool-input {
+ padding: 3px;
+ }
+
+ body.compact-mode .message-content pre.code-block {
+ padding: 6px;
+ margin: 4px 0;
+ }
+
+ body.compact-mode .code-block-container {
+ margin: 4px 0;
+ }
+
+ body.compact-mode .code-block-header {
+ padding: 2px 3px;
+ }
+
+ body.compact-mode .diff-header {
+ padding: 3px 6px;
+ }
+
+ body.compact-mode .diff-line {
+ padding: 1px 6px;
+ }
+
`
export default styles
\ No newline at end of file
From 5aa58e41b9ad4efc01c9077e0a646017d84a3ddd Mon Sep 17 00:00:00 2001
From: Jonas Kunert
Date: Wed, 22 Jul 2026 16:01:05 +0200
Subject: [PATCH 03/12] feat: expose new settings in the in-app settings modal
(fork-issue-29)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds three checkboxes to the webview settings modal (gear icon) so the
recently introduced options are discoverable in-app, not only in the
native VS Code settings UI:
- notifications.completionPopup (default on)
- notifications.completionSound (default off)
- ui.compactMode (default off)
New 'Notifications' and 'Appearance' sections follow the existing
settings-group markup; values ride the existing settingsData /
updateSettings transport (_sendCurrentSettings now also carries the two
notification keys; _updateSettings is generic already). compactMode keeps
its single effect path (config change -> settingsData -> body class).
Also updates the _maybeEndClaudeStdin doc comment with the fork-issue-15 perm-log
findings; behavior unchanged — the reviewed close logic stays until the
lifecycle rework lands.
Co-Authored-By: Claude Fable 5
---
src/extension.ts | 20 +++++++++++---------
src/script.ts | 11 ++++++++++-
src/ui.ts | 20 ++++++++++++++++++++
3 files changed, 41 insertions(+), 10 deletions(-)
diff --git a/src/extension.ts b/src/extension.ts
index d97b97b..5dc050f 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -2033,13 +2033,15 @@ class ClaudeChatProvider {
}
/**
- * End the Claude process stdin once the turn is complete (result seen) and no
- * permission round-trip is still pending. Ending stdin closes the stdio control
- * channel, so doing it while a can_use_tool is in flight makes the CLI abort the
- * request with "Stream closed". Safe to call repeatedly (no-op until conditions
- * hold); called both after 'result' (with a grace delay) and after each
- * permission response, so a deferred close still fires once the last prompt is
- * answered — the process then exits cleanly (no zombie, no stuck "working").
+ * #15: end stdin only once a result arrived AND no permission request is
+ * pending. Known limitation (perm-log evidence 2026-07-22): background
+ * subagents can request permissions AFTER the turn's result, which this
+ * close still kills — but simply suppressing the close is worse: the CLI
+ * runs in persistent stream-json mode and the whole turn lifecycle
+ * (#16 queue flush, #17 notify, _currentClaudeProcess reset) hangs on the
+ * process 'close' event, so never ending stdin risks a frozen chat. The
+ * real fix is a lifecycle rework (keep channel open, detach on next user
+ * message); until that lands, this stays the reviewed 2f1ae0d behavior.
*/
private _maybeEndClaudeStdin(claudeProcess: cp.ChildProcess): void {
if (!claudeProcess.stdin || claudeProcess.stdin.destroyed) {
@@ -2052,8 +2054,6 @@ class ClaudeChatProvider {
this._permLog(`stdin.end deferred: ${this._pendingPermissionRequests.size} pending pid=${claudeProcess.pid}`);
return;
}
- // Log the call origin: 'result'-timer vs. answered-request path — this is
- // the one place that legitimately closes the control channel (#15).
this._permLog(`stdin.end (turn done, no pending) pid=${claudeProcess.pid} stack=${new Error().stack?.split('\n').slice(2, 5).join(' | ')}`);
claudeProcess.stdin.end();
}
@@ -3511,6 +3511,8 @@ class ClaudeChatProvider {
'environment.variables': config.get>('environment.variables', {}),
'environment.disabled': config.get('environment.disabled', false),
'ui.compactMode': config.get('ui.compactMode', false),
+ 'notifications.completionPopup': config.get('notifications.completionPopup', true),
+ 'notifications.completionSound': config.get('notifications.completionSound', false),
'isOpenCredits': this._isOpenCredits()
};
diff --git a/src/script.ts b/src/script.ts
index 820f988..360967c 100644
--- a/src/script.ts
+++ b/src/script.ts
@@ -4863,6 +4863,9 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
const yoloMode = document.getElementById('yolo-mode').checked;
const executablePath = document.getElementById('executable-path').value;
const useRouter = document.getElementById('use-router')?.checked || false;
+ const compactMode = document.getElementById('compact-mode').checked;
+ const completionPopup = document.getElementById('completion-popup').checked;
+ const completionSound = document.getElementById('completion-sound').checked;
// Collect environment variables from key-value UI
const envVariables = getEnvVariablesFromUI();
@@ -4902,7 +4905,10 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
'permissions.yoloMode': yoloMode,
'executable.path': executablePath,
'environment.variables': envVariables,
- 'router.enabled': useRouter
+ 'router.enabled': useRouter,
+ 'ui.compactMode': compactMode,
+ 'notifications.completionPopup': completionPopup,
+ 'notifications.completionSound': completionSound
}
});
}
@@ -5160,6 +5166,9 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
} else if (message.type === 'settingsData') {
// Update UI with current settings
document.body.classList.toggle('compact-mode', !!message.data['ui.compactMode']);
+ document.getElementById('compact-mode').checked = !!message.data['ui.compactMode'];
+ document.getElementById('completion-popup').checked = message.data['notifications.completionPopup'] !== false;
+ document.getElementById('completion-sound').checked = !!message.data['notifications.completionSound'];
const thinkingIntensity = message.data['thinking.intensity'] || 'think';
const intensityValues = ['think', 'think-hard', 'think-harder', 'ultrathink'];
diff --git a/src/ui.ts b/src/ui.ts
index 4ffcab7..ea2a94b 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -414,6 +414,26 @@ const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https
+
Notifications
+
+
+
+
+
+
+
+
+
+
+
+
Appearance
+
+
+
+
+
+
+
From f4c89a36487b798fbd0ca913218236045c6e386b Mon Sep 17 00:00:00 2001
From: Jonas Kunert
Date: Wed, 22 Jul 2026 20:15:42 +0200
Subject: [PATCH 04/12] feat: modes popup with permission mode and effort
selection (fork-issue-31)
Replace the Plan and Ultrathink input toggles with a single Modes popup
matching the standard Claude Code UI: four permission modes (Manual,
Edit automatically, Plan, Auto) with descriptions and a checkmark on
the active one, plus an effort slider (Low/Medium/High/Extra high/Max)
and a shift+tab cycle hint.
- mode and effort are provider state persisted in workspaceState
('claude.selectedMode'/'claude.selectedEffort', same pattern as the
model selection), pushed to the webview on init (modeSelected/
effortSelected) and applied on every process spawn: --permission-mode
for acceptEdits/plan/auto (Manual sends no flag, byte-identical to
the previous default spawn) and --effort once explicitly chosen
(no flag before first interaction, CLI default stays in charge),
- selection therefore now also applies to queued messages, slash
commands and plan-file sends, and survives panel reloads,
- the ULTRATHINK prompt-prefix block is removed; --effort is the
native replacement (the thinking.intensity setting and its modal
stay dormant, cleanup is a follow-up),
- _sendMessageToClaude loses the per-message planMode/thinkingMode
parameters; the message queue carries only id/message/images,
- popup follows the existing connect-menu pattern; effort slider
labels get scoped flex rules so five labels fit the popup width,
- shift+tab in the input cycles through the four modes.
Co-Authored-By: Claude Fable 5
---
src/extension.ts | 76 ++++++++++++++++------------
src/script.ts | 128 +++++++++++++++++++++++++++--------------------
src/ui-styles.ts | 76 ++++++++++++++++++++++++++++
src/ui.ts | 51 ++++++++++++++++++-
4 files changed, 244 insertions(+), 87 deletions(-)
diff --git a/src/extension.ts b/src/extension.ts
index 5dc050f..90a827d 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -206,6 +206,8 @@ class ClaudeChatProvider {
private _isWslProcess: boolean = false;
private _wslDistro: string = 'Ubuntu';
private _selectedModel: string = 'default'; // Default model
+ private _selectedMode: string = 'manual';
+ private _selectedEffort: string | undefined = undefined;
private _isProcessing: boolean | undefined;
// Set once a 'result' message was seen for the current process. Gates the
// deferred stdin close so we never tear down the stdio control channel while
@@ -228,6 +230,10 @@ class ClaudeChatProvider {
// Load saved model preference
this._selectedModel = this._context.workspaceState.get('claude.selectedModel', 'default');
+ // Load saved mode/effort preference
+ this._selectedMode = this._context.workspaceState.get('claude.selectedMode', 'manual');
+ this._selectedEffort = this._context.workspaceState.get('claude.selectedEffort', undefined);
+
// Load cached subscription type (will be refreshed on first message)
this._subscriptionType = this._context.globalState.get('claude.subscriptionType');
@@ -406,6 +412,16 @@ class ClaudeChatProvider {
model: this._selectedModel
});
+ // Send current mode/effort to webview
+ this._postMessage({
+ type: 'modeSelected',
+ mode: this._selectedMode
+ });
+ this._postMessage({
+ type: 'effortSelected',
+ effort: this._selectedEffort
+ });
+
// Send cached subscription type to webview (will be refreshed on first message)
if (this._subscriptionType) {
this._postMessage({
@@ -439,7 +455,7 @@ class ClaudeChatProvider {
private async _handleWebviewMessage(message: any) {
switch (message.type) {
case 'sendMessage':
- this._sendMessageToClaude(message.text, message.planMode, message.thinkingMode, message.images);
+ this._sendMessageToClaude(message.text, message.images);
return;
case 'newSession':
this._newSession();
@@ -483,6 +499,12 @@ class ClaudeChatProvider {
case 'selectModel':
this._setSelectedModel(message.model, message.tierModels);
return;
+ case 'setMode':
+ this._setSelectedMode(message.mode);
+ return;
+ case 'setEffort':
+ this._setSelectedEffort(message.effort);
+ return;
case 'openModelTerminal':
this._openModelTerminal();
return;
@@ -872,7 +894,7 @@ class ClaudeChatProvider {
}
}
- private async _sendMessageToClaude(message: string, planMode?: boolean, thinkingMode?: boolean, images?: string[]) {
+ private async _sendMessageToClaude(message: string, images?: string[]) {
// Re-entrancy guard: a Claude process is already running for this session.
// Spawning a second overlapping process (same --resume session) closes the
// first one's stdio control channel and fights over the session lock, which
@@ -891,33 +913,7 @@ class ClaudeChatProvider {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : process.cwd();
- // Get thinking intensity setting
- const configThink = vscode.workspace.getConfiguration('claudeCodeChat');
- const thinkingIntensity = configThink.get('thinking.intensity', 'think');
-
- // Prepend thinking mode instructions if enabled
let actualMessage = message;
- if (thinkingMode) {
- let thinkingPrompt = '';
- const thinkingMesssage = ' THROUGH THIS STEP BY STEP: \n'
- switch (thinkingIntensity) {
- case 'think':
- thinkingPrompt = 'THINK';
- break;
- case 'think-hard':
- thinkingPrompt = 'THINK HARD';
- break;
- case 'think-harder':
- thinkingPrompt = 'THINK HARDER';
- break;
- case 'ultrathink':
- thinkingPrompt = 'ULTRATHINK';
- break;
- default:
- thinkingPrompt = 'THINK';
- }
- actualMessage = thinkingPrompt + thinkingMesssage + actualMessage;
- }
this._isProcessing = true;
@@ -982,9 +978,13 @@ class ClaudeChatProvider {
}
}
- // Add plan mode if enabled
- if (planMode) {
- args.push('--permission-mode', 'plan');
+ // Add permission mode / effort based on the selected mode (manual = no flag,
+ // i.e. byte-identical to the previous default spawn) (#31)
+ if (this._selectedMode && this._selectedMode !== 'manual') {
+ args.push('--permission-mode', this._selectedMode);
+ }
+ if (this._selectedEffort) {
+ args.push('--effort', this._selectedEffort);
}
// Add model selection for Claude models only (opus, sonnet)
@@ -3592,6 +3592,20 @@ class ClaudeChatProvider {
}
}
+ private async _setSelectedMode(mode: string): Promise {
+ this._selectedMode = mode;
+
+ // Store the mode preference in workspace state
+ this._context.workspaceState.update('claude.selectedMode', mode);
+ }
+
+ private async _setSelectedEffort(effort: string | undefined): Promise {
+ this._selectedEffort = effort;
+
+ // Store the effort preference in workspace state
+ this._context.workspaceState.update('claude.selectedEffort', effort);
+ }
+
private async _setSelectedModel(model: string, tierModels?: { sonnet: string; opus: string; haiku: string }): Promise {
// Valid Claude models
const validClaudeModels = ['opus', 'sonnet', 'default'];
diff --git a/src/script.ts b/src/script.ts
index 360967c..ee2b4cc 100644
--- a/src/script.ts
+++ b/src/script.ts
@@ -76,8 +76,8 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
let isProcessRunning = false;
let filteredFiles = [];
let selectedFileIndex = -1;
- let planModeEnabled = false;
- let thinkingModeEnabled = false;
+ let currentMode = 'manual';
+ let currentEffort = null;
let isWindows = false;
let lastPendingEditIndex = -1; // Track the last Edit/MultiEdit/Write toolUse without result
let lastPendingEditData = null; // Store diff data for the pending edit { filePath, oldContent, newContent }
@@ -937,9 +937,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
if (text || attachedImages.length > 0) {
const msg = {
type: 'sendMessage',
- text: text,
- planMode: planModeEnabled,
- thinkingMode: thinkingModeEnabled
+ text: text
};
if (attachedImages.length > 0) {
msg.images = attachedImages.map(img => img.filePath);
@@ -952,43 +950,6 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
}
}
- function togglePlanMode() {
- planModeEnabled = !planModeEnabled;
- const switchElement = document.getElementById('planModeSwitch');
- if (planModeEnabled) {
- switchElement.classList.add('active');
- } else {
- switchElement.classList.remove('active');
- }
- }
-
- function toggleThinkingMode() {
- thinkingModeEnabled = !thinkingModeEnabled;
- sendStats('Thinking mode toggled', { enabled: thinkingModeEnabled });
-
- var switchElement = document.getElementById('thinkingModeSwitch');
- var toggleLabel = document.getElementById('thinkingModeLabel');
- var thinkBtn = document.getElementById('thinkToggleBtn');
- if (thinkingModeEnabled) {
- if (switchElement) switchElement.classList.add('active');
- if (thinkBtn) thinkBtn.classList.add('active');
- if (toggleLabel) toggleLabel.textContent = 'Ultrathink Mode';
- // Set ultrathink intensity directly
- vscode.postMessage({
- type: 'updateSettings',
- settings: { 'thinking.intensity': 'ultrathink' }
- });
- vscode.postMessage({
- type: 'showInfoMessage',
- message: 'Ultrathink enabled \u2014 deep reasoning for complex tasks.'
- });
- } else {
- if (switchElement) switchElement.classList.remove('active');
- if (thinkBtn) thinkBtn.classList.remove('active');
- if (toggleLabel) toggleLabel.textContent = 'Thinking Mode';
- }
- }
-
function toggleConnectMenu() {
var menu = document.getElementById('connectMenu');
if (!menu) return;
@@ -1007,21 +968,66 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
}
});
- function cyclePlanMode() {
- planModeEnabled = !planModeEnabled;
- sendStats('Plan mode toggled', { enabled: planModeEnabled });
- var switchElement = document.getElementById('planModeSwitch');
- var toggleBtn = document.getElementById('planToggleBtn');
- if (planModeEnabled) {
- if (switchElement) switchElement.classList.add('active');
- if (toggleBtn) toggleBtn.classList.add('active');
+ var modeLabels = {
+ manual: 'Manual',
+ acceptEdits: 'Edit automatically',
+ plan: 'Plan',
+ auto: 'Auto'
+ };
+ var modeOrder = ['manual', 'acceptEdits', 'plan', 'auto'];
+ var effortLevels = ['low', 'medium', 'high', 'xhigh', 'max'];
+ var effortLevelLabels = ['Low', 'Medium', 'High', 'Extra high', 'Max'];
+
+ function toggleModesPopup() {
+ var popup = document.getElementById('modesPopup');
+ if (!popup) return;
+ popup.style.display = popup.style.display === 'none' ? 'block' : 'none';
+ }
+
+ function hideModesPopup() {
+ var popup = document.getElementById('modesPopup');
+ if (popup) popup.style.display = 'none';
+ }
+
+ // Close modes popup when clicking outside
+ document.addEventListener('click', function(e) {
+ if (!e.target.closest('.modes-dropdown-wrapper')) {
+ hideModesPopup();
+ }
+ });
+
+ function selectMode(mode, silent) {
+ currentMode = mode;
+ document.querySelectorAll('.mode-option').forEach(function(opt) {
+ opt.classList.toggle('active', opt.getAttribute('data-mode') === mode);
+ });
+ var label = document.getElementById('modesBtnLabel');
+ if (label) label.textContent = modeLabels[mode] || 'Manual';
+ if (!silent) {
+ hideModesPopup();
+ sendStats('Mode selected', { mode: mode });
+ vscode.postMessage({ type: 'setMode', mode: mode });
vscode.postMessage({
type: 'showInfoMessage',
- message: 'Plan mode enabled \u2014 Claude will plan before making changes.'
+ message: 'Mode switched to: ' + (modeLabels[mode] || mode)
});
- } else {
- if (switchElement) switchElement.classList.remove('active');
- if (toggleBtn) toggleBtn.classList.remove('active');
+ }
+ }
+
+ function setEffort(idx, silent) {
+ var index = parseInt(idx, 10);
+ if (isNaN(index) || index < 0 || index >= effortLevels.length) return;
+ currentEffort = effortLevels[index];
+ var slider = document.getElementById('effortSlider');
+ if (slider) slider.value = index;
+ document.querySelectorAll('.modes-effort-section .slider-label').forEach(function(lbl, i) {
+ lbl.classList.toggle('active', i === index);
+ });
+ var label = document.getElementById('effortLabel');
+ if (label) label.textContent = 'Effort (' + effortLevelLabels[index] + ')';
+ if (!silent) {
+ sendStats('Effort selected', { effort: currentEffort });
+ vscode.postMessage({ type: 'setEffort', effort: currentEffort });
}
}
@@ -1241,6 +1247,11 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
}
}, 50);
}, 0);
+ } else if (e.key === 'Tab' && e.shiftKey) {
+ e.preventDefault();
+ var currentIndex = modeOrder.indexOf(currentMode);
+ var nextMode = modeOrder[(currentIndex + 1) % modeOrder.length];
+ selectMode(nextMode);
}
});
@@ -3861,6 +3872,15 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
currentModel = message.model;
selectModel(message.model, true);
break;
+ case 'modeSelected':
+ selectMode(message.mode, true);
+ break;
+ case 'effortSelected':
+ if (message.effort) {
+ var effortIndex = effortLevels.indexOf(message.effort);
+ if (effortIndex !== -1) setEffort(effortIndex, true);
+ }
+ break;
case 'terminalOpened':
// Display notification about checking the terminal
addMessage(message.data, 'system');
diff --git a/src/ui-styles.ts b/src/ui-styles.ts
index bad8267..6a828e0 100644
--- a/src/ui-styles.ts
+++ b/src/ui-styles.ts
@@ -2177,6 +2177,82 @@ const styles = `
flex-shrink: 0;
}
+ .modes-dropdown-wrapper {
+ position: relative;
+ }
+
+ .modes-popup {
+ min-width: 320px;
+ }
+
+ .mode-option {
+ display: flex;
+ flex-direction: column;
+ padding: 8px 14px;
+ cursor: pointer;
+ transition: background-color 0.1s ease;
+ }
+
+ .mode-option:hover {
+ background-color: var(--vscode-list-hoverBackground);
+ }
+
+ .mode-option-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ font-size: 13px;
+ color: var(--vscode-foreground);
+ }
+
+ .mode-option-check {
+ visibility: hidden;
+ }
+
+ .mode-option.active .mode-option-check {
+ visibility: visible;
+ }
+
+ .mode-option-desc {
+ font-size: 11px;
+ color: var(--vscode-descriptionForeground);
+ margin-top: 2px;
+ }
+
+ .modes-effort-section {
+ padding: 10px 14px 6px;
+ margin-top: 4px;
+ border-top: 1px solid var(--vscode-menu-border);
+ font-size: 12px;
+ color: var(--vscode-descriptionForeground);
+ }
+
+ .modes-effort-section .slider-labels {
+ padding: 0;
+ }
+
+ .modes-effort-section .slider-label {
+ width: auto;
+ flex: 1 1 0;
+ min-width: 0;
+ }
+
+ .modes-effort-section .slider-label:first-child {
+ margin-left: 0;
+ }
+
+ .modes-effort-section .slider-label:last-child {
+ margin-right: 0;
+ }
+
+ .modes-popup-footer {
+ padding: 6px 14px 2px;
+ font-size: 11px;
+ color: var(--vscode-descriptionForeground);
+ opacity: 0.7;
+ }
+
.slash-btn,
.at-btn {
background-color: transparent;
diff --git a/src/ui.ts b/src/ui.ts
index ea2a94b..27acc2c 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -104,8 +104,55 @@ const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https
-
-
+
+
+
+
Modes
+
+
+ Manual
+ ✓
+
+
Claude will ask for approval before making each edit
+
+
+
+ Edit automatically
+ ✓
+
+
Claude will edit files without asking for approval
+
+
+
+ Plan
+ ✓
+
+
Claude will explore the code and present a plan before editing
+
+
+
+ Auto
+ ✓
+
+
Claude will approve actions that pass a safety check and pause for anything risky
+
+
+ Effort
+
+
+
Low
+
Medium
+
High
+
Extra high
+
Max
+
+
+
+
+
From 4c4174488f5c28f7941935c9083a4e17cc6177f4 Mon Sep 17 00:00:00 2001
From: Jonas Kunert
Date: Fri, 24 Jul 2026 15:09:52 +0200
Subject: [PATCH 05/12] feat: add compact button with summarize-and-restart
flow (fork-issue-36)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The headless CLI does not interpret /compact as a command (verified on
the target system: the text is passed verbatim to the model), so a real
compaction is emulated: the toolbar button sends a summarize turn to
the running session (full context, no user echo, no backup commit),
captures the result as a seed, and arms a forced fresh session — the
next user message starts without --resume, prefixed by the summary.
A failed summarize (e.g. an API 400 after context overflow) still arms
the fresh session, just without a seed, giving a one-click way out of
an otherwise dead conversation that would re-resume into the same
overflow forever. A saved separator message marks the boundary in the
chat and on reload; the conversation filename is pinned so the fresh
session keeps writing the same history file. The slash modal's
/compact entry now uses the same flow instead of the dead verbatim
send, and the context indicator highlights the button from 85% usage.
Co-Authored-By: Claude Fable 5
---
src/extension.ts | 211 +++++++++++++++++++++++++++++++++++++++--------
src/script.ts | 21 ++++-
src/ui-styles.ts | 6 ++
src/ui.ts | 1 +
4 files changed, 204 insertions(+), 35 deletions(-)
diff --git a/src/extension.ts b/src/extension.ts
index 90a827d..836a7b7 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -15,6 +15,17 @@ let OPENCREDITS_API_URL = 'https://ccc.api.opencredits.ai';
let OPENCREDITS_WEB_URL = 'https://ccc.opencredits.ai';
let OPENCREDITS_PUBLISHABLE_KEY = 'oc_pk_c43da4f9a9484ae484ad29bc97cc354f';
+// Manual compact (#36): the headless CLI has no real /compact, so the compact button
+// asks the running session for a handoff summary instead, then starts the next turn
+// as a fresh session seeded with that summary (see _startCompact/_finishCompact).
+const COMPACT_PROMPT = 'Write a concise handoff summary of this conversation so a fresh session can continue seamlessly, then stop. Include, as compact markdown: (1) the overall task/goal; (2) key decisions, constraints and assumptions; (3) relevant or changed files and paths, each with its role in one line; (4) what is done vs. still open, as concrete next steps; (5) any gotchas or non-obvious context. Do not call any tools or make changes — output only the summary.';
+
+// Prefixes the first user message of a fresh, post-compact session with the previous
+// session's handoff summary so the new (unseen) session has the same context.
+function buildCompactSeedMessage(summary: string, message: string): string {
+ return 'Context summary from the previous (compacted) session — treat as established background, do not re-summarize:\n\n' + summary + '\n\n---\n\nContinuing. My next message:\n' + message;
+}
+
const exec = util.promisify(cp.exec);
// File target for [perm] diagnostics (#15): console.error of an installed
@@ -214,6 +225,16 @@ class ClaudeChatProvider {
// a permission round-trip is still pending (would surface as "Stream closed").
private _resultSeen: boolean = false;
private _draftMessage: string = '';
+ // Manual compact (#36): true while the summarize turn started by _startCompact()
+ // is in flight. _pendingCompactSummary holds its result once seen, consumed as the
+ // seed for the next fresh session. _forceFreshSession is a one-shot override that
+ // skips --resume on the very next _sendMessageToClaude() call. _pinnedConversationFilename
+ // keeps saving to the same conversation file across the CLI-session swap, since the
+ // new session gets its own session_id (which the filename would otherwise follow).
+ private _compactInProgress = false;
+ private _pendingCompactSummary: string | undefined;
+ private _forceFreshSession = false;
+ private _pinnedConversationFilename: string | undefined;
constructor(
private readonly _extensionUri: vscode.Uri,
@@ -460,6 +481,9 @@ class ClaudeChatProvider {
case 'newSession':
this._newSession();
return;
+ case 'startCompact':
+ this._startCompact();
+ return;
case 'restoreCommit':
this._restoreToCommit(message.commitSha);
return;
@@ -894,7 +918,7 @@ class ClaudeChatProvider {
}
}
- private async _sendMessageToClaude(message: string, images?: string[]) {
+ private async _sendMessageToClaude(message: string, images?: string[], opts?: { compact?: boolean }) {
// Re-entrancy guard: a Claude process is already running for this session.
// Spawning a second overlapping process (same --resume session) closes the
// first one's stdio control channel and fights over the session lock, which
@@ -915,16 +939,30 @@ class ClaudeChatProvider {
let actualMessage = message;
+ // Manual compact (#36): seed the first real message of a fresh, post-compact
+ // session with the previous session's handoff summary. Consumed once — later
+ // messages in the same (new) session go through unmodified.
+ if (!opts?.compact && this._pendingCompactSummary) {
+ actualMessage = buildCompactSeedMessage(this._pendingCompactSummary, message);
+ this._pendingCompactSummary = undefined;
+ }
+
this._isProcessing = true;
// Clear draft message since we're sending it
this._draftMessage = '';
- // Show original user input in chat and save to conversation (without mode prefixes)
- this._sendAndSaveMessage({
- type: 'userInput',
- data: message
- });
+ if (opts?.compact) {
+ // Summarize turn for the compact button (#36): no user-visible echo — show
+ // the compacting indicator instead.
+ this._postMessage({ type: 'compacting', data: { isCompacting: true } });
+ } else {
+ // Show original user input in chat and save to conversation (without mode prefixes)
+ this._sendAndSaveMessage({
+ type: 'userInput',
+ data: message
+ });
+ }
// Set processing state to true
this._postMessage({
@@ -932,12 +970,15 @@ class ClaudeChatProvider {
data: { isProcessing: true }
});
- // Create backup commit before Claude makes changes
- try {
- await this._createBackupCommit(message);
- }
- catch (e) {
- console.error("error", e);
+ // Create backup commit before Claude makes changes (skipped for the internal
+ // compact summarize turn — #36)
+ if (!opts?.compact) {
+ try {
+ await this._createBackupCommit(message);
+ }
+ catch (e) {
+ console.error("error", e);
+ }
}
// Show loading indicator
@@ -994,10 +1035,15 @@ class ClaudeChatProvider {
args.push('--model', this._selectedModel);
}
- // Add session resume if we have a current session
- if (this._currentSessionId) {
+ // Add session resume if we have a current session. Skipped once right after a
+ // compact (#36): _forceFreshSession forces a session-less spawn so the CLI
+ // starts clean instead of resuming the (now summarized-away) old session.
+ if (this._currentSessionId && !this._forceFreshSession) {
args.push('--resume', this._currentSessionId);
}
+ if (this._forceFreshSession) {
+ this._forceFreshSession = false;
+ }
const wslEnabled = config.get('wsl.enabled', false);
const wslDistro = config.get('wsl.distro', 'Ubuntu');
@@ -1273,6 +1319,11 @@ class ClaudeChatProvider {
claudeProcess.on('close', (code) => {
+ // Manual compact (#36): captured before any of the branches below run, so
+ // _finishCompact() below always sees whether THIS turn was the summarize
+ // turn, regardless of exit code.
+ const wasCompact = this._compactInProgress;
+
if (!this._currentClaudeProcess) {
return;
}
@@ -1304,14 +1355,21 @@ class ClaudeChatProvider {
type: 'showInstallModal',
installAttempted: !!this._context.globalState.get('installAttempted')
});
- } else {
- // Error with output
+ } else if (!wasCompact) {
+ // Error with output. Suppressed for the compact summarize turn (#36) —
+ // _finishCompact's own compactSeparator message explains the failure.
this._sendAndSaveMessage({
type: 'error',
data: errorOutput.trim()
});
}
}
+
+ // Manual compact (#36): resolve the pending compaction (seed captured or not)
+ // before the queue drains, regardless of exit code.
+ if (wasCompact) {
+ this._finishCompact(code === 0);
+ }
});
claudeProcess.on('error', (error) => {
@@ -1603,6 +1661,13 @@ class ClaudeChatProvider {
this._isProcessing = false;
+ // Manual compact (#36): capture the summarize turn's own result text
+ // as the seed for the next (fresh) session. Just capture it here —
+ // _finishCompact (driven by the close handler) does the state transition.
+ if (this._compactInProgress && typeof jsonData.result === 'string') {
+ this._pendingCompactSummary = jsonData.result.trim() || undefined;
+ }
+
// Capture session ID from final result
if (jsonData.session_id) {
@@ -1684,6 +1749,13 @@ class ClaudeChatProvider {
// Clear current session
this._currentSessionId = undefined;
+ // Manual compact (#36): clear any in-flight/pending compaction state so the new
+ // session starts clean.
+ this._compactInProgress = false;
+ this._pendingCompactSummary = undefined;
+ this._forceFreshSession = false;
+ this._pinnedConversationFilename = undefined;
+
// Clear commits and conversation
this._commits = [];
this._currentConversation = [];
@@ -3098,29 +3170,41 @@ class ClaudeChatProvider {
void this._saveCurrentConversation();
}
+ // Derives the conversation's JSON filename from its first user message and start
+ // time. Shared by the normal save path and _finishCompact's pinning (#36), which
+ // snapshots it before a compact-triggered session swap changes _currentSessionId
+ // out from under it.
+ private _deriveConversationFilename(): string {
+ const firstUserMessage = this._currentConversation.find(m => m.messageType === 'userInput');
+ const firstMessage = firstUserMessage ? firstUserMessage.data : 'conversation';
+ const startTime = this._conversationStartTime || new Date().toISOString();
+
+ // Clean and truncate first message for filename
+ const cleanMessage = firstMessage
+ .replace(/[^a-zA-Z0-9\s]/g, '') // Remove special chars
+ .replace(/\s+/g, '-') // Replace spaces with dashes
+ .substring(0, 50) // Limit length
+ .toLowerCase();
+
+ const datePrefix = startTime.substring(0, 16).replace('T', '_').replace(/:/g, '-');
+ return `${datePrefix}_${cleanMessage}.json`;
+ }
+
private async _saveCurrentConversation(): Promise {
if (!this._conversationsPath || this._currentConversation.length === 0) { return; }
if (!this._currentSessionId) { return; }
try {
- // Create filename from first user message and timestamp
- const firstUserMessage = this._currentConversation.find(m => m.messageType === 'userInput');
- const firstMessage = firstUserMessage ? firstUserMessage.data : 'conversation';
- const startTime = this._conversationStartTime || new Date().toISOString();
- const sessionId = this._currentSessionId || 'unknown';
-
- // Clean and truncate first message for filename
- const cleanMessage = firstMessage
- .replace(/[^a-zA-Z0-9\s]/g, '') // Remove special chars
- .replace(/\s+/g, '-') // Replace spaces with dashes
- .substring(0, 50) // Limit length
- .toLowerCase();
-
- const datePrefix = startTime.substring(0, 16).replace('T', '_').replace(/:/g, '-');
- const filename = `${datePrefix}_${cleanMessage}.json`;
+ // Filename is normally re-derived every save; pinned once a compact (#36)
+ // has swapped in a new session, so the conversation keeps saving to the same
+ // file instead of splitting when _currentSessionId changes underneath it.
+ let filename = this._deriveConversationFilename();
+ if (this._pinnedConversationFilename) {
+ filename = this._pinnedConversationFilename;
+ }
const conversationData: ConversationData = {
- sessionId: sessionId,
+ sessionId: this._currentSessionId || 'unknown',
startTime: this._conversationStartTime,
endTime: new Date().toISOString(),
messageCount: this._currentConversation.length,
@@ -3278,6 +3362,15 @@ class ClaudeChatProvider {
const pid = processToKill?.pid;
this._permLog(`killClaudeProcess pid=${pid} current=${this._currentClaudeProcess?.pid}`);
+ // Manual compact (#36): a kill always ends any in-flight summarize turn. The
+ // seed must go too — after a completed compaction it is consumed before the
+ // next spawn, so the only state where it can still be set here is a stop
+ // mid-summarize (result already parsed, close not yet fired). Leaving it
+ // would prefix the summary onto a resumed, still-full session.
+ this._compactInProgress = false;
+ this._pendingCompactSummary = undefined;
+ this._forceFreshSession = false;
+
// 1. Abort via controller (clean API)
this._abortController?.abort();
this._abortController = undefined;
@@ -3935,9 +4028,10 @@ class ClaudeChatProvider {
}
private _executeSlashCommand(command: string): void {
- // Handle /compact in chat instead of spawning a terminal
+ // Handle /compact via the summarize-and-restart flow (#36) instead of sending a
+ // literal "/compact" to the CLI — the headless CLI has no real /compact.
if (command === 'compact') {
- this._sendMessageToClaude(`/${command}`);
+ this._startCompact();
return;
}
@@ -3970,6 +4064,55 @@ class ClaudeChatProvider {
});
}
+ // Manual compact (#36): kicks off the summarize turn on the current (full-context)
+ // session. The actual state transition happens in _finishCompact, driven by the
+ // close handler once that turn's process exits.
+ private _startCompact(): void {
+ if (this._isProcessing || this._currentClaudeProcess) {
+ vscode.window.showInformationMessage('Finish the current turn before compacting.');
+ return;
+ }
+ if (!this._currentSessionId) {
+ vscode.window.showInformationMessage('No active conversation to compact.');
+ return;
+ }
+ if (this._pendingCompactSummary || this._forceFreshSession) {
+ vscode.window.showInformationMessage('A compaction is already pending — send a message to continue.');
+ return;
+ }
+ this._compactInProgress = true;
+ this._sendMessageToClaude(COMPACT_PROMPT, undefined, { compact: true });
+ }
+
+ // Manual compact (#36): called once the summarize turn's process has exited
+ // (success or not). Pins the conversation filename and arms a forced-fresh-session
+ // for the next _sendMessageToClaude() call regardless of outcome — a failed
+ // summarize (e.g. a context-limit error) still needs a guaranteed way out of a
+ // dead/over-full session, just without a seed.
+ private _finishCompact(success: boolean): void {
+ this._compactInProgress = false;
+ const ok = success && !!this._pendingCompactSummary;
+ if (!ok) {
+ this._pendingCompactSummary = undefined;
+ }
+ this._pinnedConversationFilename = this._deriveConversationFilename();
+ this._forceFreshSession = true;
+
+ // Reset token counters exactly like the native compact_boundary handler does
+ // (~1855-1857) — the next session starts with an empty context window.
+ this._totalTokensInput = 0;
+ this._totalTokensOutput = 0;
+ this._postMessage({
+ type: 'updateTokens',
+ data: {
+ totalTokensInput: 0,
+ totalTokensOutput: 0
+ }
+ });
+
+ this._sendAndSaveMessage({ type: 'compactSeparator', data: { ok } });
+ }
+
private _sendPlatformInfo() {
const platform = process.platform;
const dismissed = this._context.globalState.get('wslAlertDismissed', false);
diff --git a/src/script.ts b/src/script.ts
index ee2b4cc..9ea1495 100644
--- a/src/script.ts
+++ b/src/script.ts
@@ -3484,13 +3484,17 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
function stopRequest() {
sendStats('Stop request');
-
+
vscode.postMessage({
type: 'stopRequest'
});
hideStopButton();
}
+ function startCompact() {
+ vscode.postMessage({ type: 'startCompact' });
+ }
+
// Disable/enable buttons during processing
function disableButtons() {
const sendBtn = document.getElementById('sendBtn');
@@ -3778,6 +3782,21 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
}
break;
+ case 'compactSeparator':
+ // Manual compact (#36): the backend already reset its own token
+ // counters; mirror that here so the status bar doesn't linger at
+ // the pre-compact value.
+ totalTokensInput = 0;
+ totalTokensOutput = 0;
+ updateStatusWithTotals();
+
+ if (message.data.ok) {
+ addMessage('──── 📦 Context compacted — your next message starts a fresh, lean session (seeded by the summary above) ────', 'system');
+ } else {
+ addMessage('──── ⚠️ Compact could not summarize (context-limit error). Your next message starts a fresh session WITHOUT summary; earlier messages remain above. ────', 'system');
+ }
+ break;
+
case 'compactBoundary':
// Reset token counts since conversation was compacted
totalTokensInput = 0;
diff --git a/src/ui-styles.ts b/src/ui-styles.ts
index 6a828e0..9a05d8d 100644
--- a/src/ui-styles.ts
+++ b/src/ui-styles.ts
@@ -2271,6 +2271,12 @@ const styles = `
background-color: var(--vscode-list-hoverBackground);
}
+ /* Compact suggested (#36) once context usage crosses the same 85%+ range the
+ Ctx indicator starts warning at — a subtle nudge, no animation. */
+ .slash-btn.compact-suggested {
+ box-shadow: 0 0 0 1px var(--vscode-inputValidation-warningBorder);
+ }
+
.image-btn {
background-color: transparent;
color: var(--vscode-foreground);
diff --git a/src/ui.ts b/src/ui.ts
index 27acc2c..124be5d 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -155,6 +155,7 @@ const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https
+
From b68d262bfa15ccb7e9cfad73dc6e7f3574b5dd76 Mon Sep 17 00:00:00 2001
From: Jonas Kunert
Date: Sat, 25 Jul 2026 09:52:36 +0200
Subject: [PATCH 07/12] fix: keep the CLI session tied to the conversation on
screen (fork-issue-41)
Three confirmed paths let an old session bleed into the visible chat:
loading a conversation from history never adopted its sessionId, so the
next turn resumed whichever session was active before and the following
save poisoned the file on disk with the foreign id - the load path now
adopts the stored id when its CLI transcript still exists (fresh start
otherwise) and drops pin/checkpoints only when switching files. Stdout
of a superseded process had no stale guard (close did), so a late
result line could reinstate a killed session - the handler now ignores
non-current processes. The compact pin (fork-issue-36) lived only in RAM and a
reload split the conversation in two files - it now rides along in the
persisted panel state. The spawn log records the effective
resume/fresh decision for future field diagnosis.
Co-Authored-By: Claude Fable 5
---
src/extension.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 57 insertions(+), 3 deletions(-)
diff --git a/src/extension.ts b/src/extension.ts
index 423f607..a361e98 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -189,6 +189,10 @@ class ClaudeChatProvider {
private _accountInfoFetchedThisSession: boolean = false; // Track if we fetched account info this session
private _pendingModelAfterPayment: string | null = null;
private _currentSessionId: string | undefined;
+ // Last filename this provider loaded/saved to — used by loadConversation() to
+ // tell "reloading the same conversation" from "switching to a different one"
+ // (#41), so the #36 pin/checkpoints are only dropped on an actual switch.
+ private _lastSavedFilename: string | undefined;
private _backupRepoPath: string | undefined;
private _commits: Array<{ id: string, sha: string, message: string, timestamp: string }> = [];
private _conversationsPath: string | undefined;
@@ -1045,8 +1049,13 @@ class ClaudeChatProvider {
// Add session resume if we have a current session. Skipped once right after a
// compact (#36): _forceFreshSession forces a session-less spawn so the CLI
// starts clean instead of resuming the (now summarized-away) old session.
- if (this._currentSessionId && !this._forceFreshSession) {
- args.push('--resume', this._currentSessionId);
+ // Snapshotted before being consumed below so the spawn log (#41) can report
+ // what this spawn actually did — _forceFreshSession is false again and
+ // _currentSessionId still holds the old id by the time the log line runs.
+ const forcedFresh = this._forceFreshSession;
+ const resumeSessionId = (this._currentSessionId && !forcedFresh) ? this._currentSessionId : undefined;
+ if (resumeSessionId) {
+ args.push('--resume', resumeSessionId);
}
if (this._forceFreshSession) {
this._forceFreshSession = false;
@@ -1157,7 +1166,7 @@ class ClaudeChatProvider {
// New process = new turn: no 'result' seen yet, so the deferred stdin
// close stays armed until this turn actually completes.
this._resultSeen = false;
- this._permLog(`spawned claude pid=${claudeProcess.pid} session=${this._currentSessionId ?? '(new)'}`);
+ this._permLog(`spawned claude pid=${claudeProcess.pid} session=${resumeSessionId ?? '(new)'} forceFresh=${forcedFresh}`);
// stdin lifecycle tracing (#15): record every way the control channel can
// die, so a field "Stream closed" can be attributed to a concrete event.
@@ -1271,6 +1280,11 @@ class ClaudeChatProvider {
if (claudeProcess.stdout) {
claudeProcess.stdout.on('data', (data) => {
+ // Stale-guard (#41): a killed process's reference is cleared before its
+ // stdio actually tears down, so late data from a superseded process must
+ // not mutate state (e.g. _currentSessionId) for whichever process is
+ // current now.
+ if (claudeProcess !== this._currentClaudeProcess) { return; }
rawOutput += data.toString();
// Process JSON stream line by line
@@ -3761,6 +3775,46 @@ class ClaudeChatProvider {
this._totalTokensInput = conversationData.totalTokens?.input || 0;
this._totalTokensOutput = conversationData.totalTokens?.output || 0;
+ // Resume this conversation's own CLI session instead of leaving _currentSessionId
+ // pointing at whatever conversation was active before this one was opened (#41) —
+ // otherwise the next turn resumes the wrong session, and the following save
+ // overwrites it with this conversation's messages. Only trusted if its transcript
+ // file still exists (case-insensitive slug dirs, same check _resumeCliSession uses
+ // above), so a stale/deleted session doesn't hard-fail --resume; left alone when
+ // unverifiable (e.g. WSL, where _getCliProjectsDirs() can't see the WSL filesystem).
+ let resumedSessionId: string | undefined = conversationData.sessionId || undefined;
+ if (resumedSessionId) {
+ const dirs = await this._getCliProjectsDirs();
+ if (dirs.length > 0) {
+ let stillExists = false;
+ for (const dir of dirs) {
+ const p = path.join(dir, resumedSessionId + '.jsonl');
+ const resolvedDir = path.resolve(dir) + path.sep;
+ if (!path.resolve(p).startsWith(resolvedDir)) { continue; }
+ try {
+ await fs.promises.stat(p);
+ stillExists = true;
+ break;
+ } catch {
+ continue;
+ }
+ }
+ if (!stillExists) { resumedSessionId = undefined; }
+ }
+ }
+ this._currentSessionId = resumedSessionId;
+ // A #36 pin only belongs to the conversation it was created for — drop it
+ // (and the checkpoint SHAs) when switching to a different saved conversation,
+ // keep it when re-loading the same one.
+ if (this._lastSavedFilename !== filename) {
+ this._pinnedConversationFilename = undefined;
+ // Same conditional: re-loading the open conversation keeps its checkpoint
+ // SHAs restorable; only switching conversations drops them (#41).
+ this._commits = [];
+ }
+ this._lastSavedFilename = filename;
+ this._requestCount = 0;
+
// Clear UI messages first, then send all messages to recreate the conversation
setTimeout(() => {
// Clear existing messages
From e6b89aab8bdd49277614ea20e4f6d2139073635a Mon Sep 17 00:00:00 2001
From: Jonas Kunert
Date: Sat, 25 Jul 2026 09:00:10 +0200
Subject: [PATCH 08/12] fix: keep the default keybinding out of the terminal
and document rebinding (fork-issue-45)
Ctrl+Shift+C collided with terminal copy; the binding now carries
when !terminalFocus and the README explains how to rebind or disable it.
Co-Authored-By: Claude Fable 5
(cherry picked from commit a4e41f5ad9a45412c20198fd1c304d601a37fbf0)
---
README.md | 2 ++
package.json | 3 ++-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 7466283..b24b668 100644
--- a/README.md
+++ b/README.md
@@ -228,6 +228,8 @@ If you want to revert these changes, just click "Restore Checkpoint" to go back
| `@` | Open file picker |
| `/` | Open slash commands modal |
+The default `Ctrl+Shift+C` (`Cmd+Shift+C` on Mac) doesn't fire while a terminal has focus, so it no longer clashes with the terminal's "Copy Selection" shortcut. To rebind or disable it, open **Preferences: Open Keyboard Shortcuts** (`Ctrl+K Ctrl+S`) and search for `Claude Code Chat: Open Claude Code Chat`.
+
### WSL Configuration (Windows Users)
If you're using Claude Code through WSL (Windows Subsystem for Linux), you can configure the extension to use WSL:
diff --git a/package.json b/package.json
index b53dd69..5b3024b 100644
--- a/package.json
+++ b/package.json
@@ -66,7 +66,8 @@
{
"command": "claude-code-chat.openChat",
"key": "ctrl+shift+c",
- "mac": "cmd+shift+c"
+ "mac": "cmd+shift+c",
+ "when": "!terminalFocus"
}
],
"menus": {
From 1a0c80315ef98d98b5d69f374a07feabb5ee9c0f Mon Sep 17 00:00:00 2001
From: Jonas Kunert
Date: Sat, 25 Jul 2026 09:09:04 +0200
Subject: [PATCH 09/12] feat: export conversations as JSON from the history
list (fork-issue-43)
Adds a download button next to the delete button on each history entry.
The host validates the filename exactly like the delete path, reads the
stored JSON untouched and writes it wherever the save dialog points
(Downloads, then workspace, then home as the default location).
Co-Authored-By: Claude Fable 5
(cherry picked from commit d970e21dbcf9f866222a97a8c7b6c4a341d2c1d0)
---
src/extension.ts | 42 ++++++++++++++++++++++++++++++++++++++++++
src/script.ts | 7 +++++++
src/ui-styles.ts | 17 +++++++++++++++++
3 files changed, 66 insertions(+)
diff --git a/src/extension.ts b/src/extension.ts
index a361e98..2cb1473 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -510,6 +510,9 @@ class ClaudeChatProvider {
case 'resumeCliSession':
this._resumeCliSession(message.sessionId);
return;
+ case 'exportConversation':
+ this._exportConversation(message.filename);
+ return;
case 'stopRequest':
this._stopClaudeProcess();
return;
@@ -3524,6 +3527,45 @@ class ClaudeChatProvider {
});
}
+ // Downloads if it exists, otherwise the current workspace folder, otherwise home.
+ private async _getExportDefaultDir(): Promise {
+ const downloadsDir = path.join(os.homedir(), 'Downloads');
+ try {
+ const stat = await vscode.workspace.fs.stat(vscode.Uri.file(downloadsDir));
+ if ((stat.type & vscode.FileType.Directory) !== 0) {
+ return downloadsDir;
+ }
+ } catch {
+ // Downloads directory doesn't exist, fall through to workspace/home
+ }
+ return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
+ }
+
+ private async _exportConversation(filename: string): Promise {
+ if (path.basename(filename) !== filename || !this._conversationIndex.some(entry => entry.filename === filename)) {
+ return;
+ }
+ if (!this._conversationsPath) { return; }
+
+ try {
+ const filePath = path.join(this._conversationsPath, filename);
+ const content = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath));
+
+ const defaultDir = await this._getExportDefaultDir();
+ const saveUri = await vscode.window.showSaveDialog({
+ defaultUri: vscode.Uri.file(path.join(defaultDir, filename)),
+ filters: { 'JSON': ['json'] }
+ });
+ if (!saveUri) { return; }
+
+ await vscode.workspace.fs.writeFile(saveUri, content);
+ vscode.window.showInformationMessage(`Conversation exported to ${saveUri.fsPath}`);
+ } catch (error: any) {
+ console.error('Failed to export conversation:', error.message);
+ vscode.window.showErrorMessage(`Failed to export conversation: ${error.message}`);
+ }
+ }
+
private async _sendWorkspaceFiles(searchTerm?: string): Promise {
try {
// Always get all files and filter on the backend for better search results
diff --git a/src/script.ts b/src/script.ts
index 1659e52..bbb3526 100644
--- a/src/script.ts
+++ b/src/script.ts
@@ -4624,6 +4624,12 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
toggleConversationHistory();
}
+ function exportConversation(filename) {
+ vscode.postMessage({
+ type: 'exportConversation',
+ filename: filename
+ });
+ }
// File picker functions
function showFilePicker() {
@@ -4814,6 +4820,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
item.innerHTML = \`
\`;
diff --git a/src/ui-styles.ts b/src/ui-styles.ts
index 7d6313d..d10b579 100644
--- a/src/ui-styles.ts
+++ b/src/ui-styles.ts
@@ -3975,6 +3975,23 @@ const styles = `
margin-bottom: 4px;
}
+ .conversation-export-btn {
+ flex-shrink: 0;
+ border: none;
+ background: transparent;
+ color: var(--vscode-descriptionForeground);
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 12px;
+ line-height: 1;
+ }
+
+ .conversation-export-btn:hover {
+ color: var(--vscode-foreground);
+ background-color: var(--vscode-list-hoverBackground);
+ }
+
.conversation-meta {
font-size: 12px;
color: var(--vscode-descriptionForeground);
From 9baf54d27cf6aa1bff8c17ef086f8817b62a895f Mon Sep 17 00:00:00 2001
From: Jonas Kunert
Date: Sat, 25 Jul 2026 09:18:42 +0200
Subject: [PATCH 10/12] fix: copy the original markdown instead of rendered DOM
text (fork-issue-46)
The message copy button read innerText, so ordered lists lost or
renumbered their markers (parseSimpleMarkdown strips the literal digits
and interrupted lists restart at 1). addMessage now records the raw
markdown in a WeakMap and the copy handler prefers it, falling back to
the old DOM read when no raw text exists. Side effect worth noting:
messages with code fences now copy as markdown source including the
fence lines, instead of flattened rendered text.
Co-Authored-By: Claude Fable 5
(cherry picked from commit 1b406605a13f3162c623aeb0b4bfa387800321f5)
---
src/script.ts | 32 ++++++++++++++++++++++++++------
1 file changed, 26 insertions(+), 6 deletions(-)
diff --git a/src/script.ts b/src/script.ts
index bbb3526..c6e70b8 100644
--- a/src/script.ts
+++ b/src/script.ts
@@ -83,6 +83,14 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
let lastPendingEditData = null; // Store diff data for the pending edit { filePath, oldContent, newContent }
let attachedImages = []; // Array of { filePath, previewUri }
+ // #46 (upstream #98): raw text handed to parseSimpleMarkdown for each
+ // rendered claude/user message, keyed by that message's root div. The
+ // copy button (copyMessageContent) reads from here instead of the
+ // rendered DOM, so Markdown render artifacts — e.g. /
letting
+ // the browser regenerate list numbers, which can drop/duplicate the
+ // original "1. 2. 3." digits — never leak into the clipboard.
+ const messageRawText = new WeakMap();
+
// Open diff using stored data (no file read needed)
function openDiffEditor() {
if (lastPendingEditData) {
@@ -115,7 +123,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
}
}
- function addMessage(content, type = 'claude') {
+ function addMessage(content, type = 'claude', rawText) {
const messagesDiv = document.getElementById('messages');
const shouldScroll = shouldAutoScroll(messagesDiv);
@@ -192,6 +200,11 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
messagesDiv.appendChild(messageDiv);
moveProcessingIndicatorToLast();
scrollToBottomIfNeeded(messagesDiv, shouldScroll);
+
+ // #46: remember the raw source text for the copy button, when given.
+ if (rawText !== undefined) {
+ messageRawText.set(messageDiv, rawText);
+ }
}
@@ -3510,9 +3523,16 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
function copyMessageContent(messageDiv) {
const contentDiv = messageDiv.querySelector('.message-content');
if (contentDiv) {
- // Get text content, preserving line breaks
- const text = contentDiv.innerText || contentDiv.textContent;
-
+ // #46 (upstream #98): prefer the raw source text the message was
+ // rendered from over the rendered DOM. contentDiv.innerText re-derives
+ // list numbering etc. from the live /
markup, which can
+ // mismatch or duplicate the original Markdown digits. Falls back to
+ // the old DOM-text behavior when no raw text was recorded (e.g.
+ // system/tool/error messages, which never go through
+ // parseSimpleMarkdown in the first place).
+ const rawText = messageRawText.get(messageDiv);
+ const text = rawText !== undefined ? rawText : (contentDiv.innerText || contentDiv.textContent);
+
// Copy to clipboard
navigator.clipboard.writeText(text).then(() => {
// Show brief feedback
@@ -3602,14 +3622,14 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
displayData = displayData.replace(usageLimitMatch[0], \`Claude AI usage limit reached: \${readableDate}\`);
}
- addMessage(parseSimpleMarkdown(displayData), 'claude');
+ addMessage(parseSimpleMarkdown(displayData), 'claude', displayData);
}
updateStatusWithTotals();
break;
case 'userInput':
if (message.data.trim()) {
- addMessage(parseSimpleMarkdown(message.data), 'user');
+ addMessage(parseSimpleMarkdown(message.data), 'user', message.data);
}
break;
From 23a3019afcf2add5e72184fc6741629db1472323 Mon Sep 17 00:00:00 2001
From: Jonas Kunert
Date: Tue, 28 Jul 2026 17:30:23 +0200
Subject: [PATCH 11/12] fix: drop unregistered notification settings not
covered by this batch
completionPopup/completionSound were read/written by fork-issue-29 but their
package.json configuration entries come from the fork-issue-17 notification
feature, which is intentionally not part of this batch. Since VS Code
rejects config.update() for unregistered keys, every settings save
would throw mid-loop and silently drop all other settings changes.
Remove the two orphaned checkboxes and their read/write wiring instead
of registering the keys (that would pull fork-issue-17 scope in unannounced).
---
src/extension.ts | 2 --
src/script.ts | 8 +-------
src/ui.ts | 12 ------------
3 files changed, 1 insertion(+), 21 deletions(-)
diff --git a/src/extension.ts b/src/extension.ts
index 2cb1473..1b1e597 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -3968,8 +3968,6 @@ class ClaudeChatProvider {
'environment.variables': config.get>('environment.variables', {}),
'environment.disabled': config.get('environment.disabled', false),
'ui.compactMode': config.get('ui.compactMode', false),
- 'notifications.completionPopup': config.get('notifications.completionPopup', true),
- 'notifications.completionSound': config.get('notifications.completionSound', false),
'isOpenCredits': this._isOpenCredits()
};
diff --git a/src/script.ts b/src/script.ts
index c6e70b8..ca2431d 100644
--- a/src/script.ts
+++ b/src/script.ts
@@ -4978,8 +4978,6 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
const executablePath = document.getElementById('executable-path').value;
const useRouter = document.getElementById('use-router')?.checked || false;
const compactMode = document.getElementById('compact-mode').checked;
- const completionPopup = document.getElementById('completion-popup').checked;
- const completionSound = document.getElementById('completion-sound').checked;
// Collect environment variables from key-value UI
const envVariables = getEnvVariablesFromUI();
@@ -5020,9 +5018,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
'executable.path': executablePath,
'environment.variables': envVariables,
'router.enabled': useRouter,
- 'ui.compactMode': compactMode,
- 'notifications.completionPopup': completionPopup,
- 'notifications.completionSound': completionSound
+ 'ui.compactMode': compactMode
}
});
}
@@ -5281,8 +5277,6 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
// Update UI with current settings
document.body.classList.toggle('compact-mode', !!message.data['ui.compactMode']);
document.getElementById('compact-mode').checked = !!message.data['ui.compactMode'];
- document.getElementById('completion-popup').checked = message.data['notifications.completionPopup'] !== false;
- document.getElementById('completion-sound').checked = !!message.data['notifications.completionSound'];
const thinkingIntensity = message.data['thinking.intensity'] || 'think';
const intensityValues = ['think', 'think-hard', 'think-harder', 'ultrathink'];
diff --git a/src/ui.ts b/src/ui.ts
index 5ef9dfb..9c469ca 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -466,18 +466,6 @@ const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https
-
Notifications
-
-
-
-
-
-
-
-
-
-
-
Appearance
From 3f7c742fcb20d422e476c9f06da93caefe66f296 Mon Sep 17 00:00:00 2001
From: Jonas Kunert
Date: Tue, 28 Jul 2026 19:50:18 +0200
Subject: [PATCH 12/12] chore: qualify internal issue references in code
comments
Bare #NN comment references pointed at our private tracker, not this
repo's issues; requalify as fork-issue-NN so GitHub doesn't auto-link
them to an unrelated issue here. Genuine "upstream #98" references are
left untouched.
---
src/extension.ts | 70 ++++++++++++++++++++++++------------------------
src/script.ts | 8 +++---
src/ui-styles.ts | 4 +--
3 files changed, 41 insertions(+), 41 deletions(-)
diff --git a/src/extension.ts b/src/extension.ts
index 1b1e597..46fe988 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -15,7 +15,7 @@ let OPENCREDITS_API_URL = 'https://ccc.api.opencredits.ai';
let OPENCREDITS_WEB_URL = 'https://ccc.opencredits.ai';
let OPENCREDITS_PUBLISHABLE_KEY = 'oc_pk_c43da4f9a9484ae484ad29bc97cc354f';
-// Manual compact (#36): the headless CLI has no real /compact, so the compact button
+// Manual compact (fork-issue-36): the headless CLI has no real /compact, so the compact button
// asks the running session for a handoff summary instead, then starts the next turn
// as a fresh session seeded with that summary (see _startCompact/_finishCompact).
const COMPACT_PROMPT = 'Write a concise handoff summary of this conversation so a fresh session can continue seamlessly, then stop. Include, as compact markdown: (1) the overall task/goal; (2) key decisions, constraints and assumptions; (3) relevant or changed files and paths, each with its role in one line; (4) what is done vs. still open, as concrete next steps; (5) any gotchas or non-obvious context. Do not call any tools or make changes — output only the summary.';
@@ -28,7 +28,7 @@ function buildCompactSeedMessage(summary: string, message: string): string {
const exec = util.promisify(cp.exec);
-// File target for [perm] diagnostics (#15): console.error of an installed
+// File target for [perm] diagnostics (fork-issue-15): console.error of an installed
// extension is only visible in the DevTools console, which makes field
// debugging of the stdio permission channel impossible — mirror it to a file.
const PERM_LOG_FILE = path.join(os.tmpdir(), 'claude-code-chat-perm.log');
@@ -191,7 +191,7 @@ class ClaudeChatProvider {
private _currentSessionId: string | undefined;
// Last filename this provider loaded/saved to — used by loadConversation() to
// tell "reloading the same conversation" from "switching to a different one"
- // (#41), so the #36 pin/checkpoints are only dropped on an actual switch.
+ // (fork-issue-41), so the fork-issue-36 pin/checkpoints are only dropped on an actual switch.
private _lastSavedFilename: string | undefined;
private _backupRepoPath: string | undefined;
private _commits: Array<{ id: string, sha: string, message: string, timestamp: string }> = [];
@@ -229,7 +229,7 @@ class ClaudeChatProvider {
// a permission round-trip is still pending (would surface as "Stream closed").
private _resultSeen: boolean = false;
private _draftMessage: string = '';
- // Manual compact (#36): true while the summarize turn started by _startCompact()
+ // Manual compact (fork-issue-36): true while the summarize turn started by _startCompact()
// is in flight. _pendingCompactSummary holds its result once seen, consumed as the
// seed for the next fresh session. _forceFreshSession is a one-shot override that
// skips --resume on the very next _sendMessageToClaude() call. _pinnedConversationFilename
@@ -239,7 +239,7 @@ class ClaudeChatProvider {
private _pendingCompactSummary: string | undefined;
private _forceFreshSession = false;
private _pinnedConversationFilename: string | undefined;
- // CLI-session resume (#37): guards against a double-click interleaving two
+ // CLI-session resume (fork-issue-37): guards against a double-click interleaving two
// preview loads once the first call yields at an await.
private _cliResumeInProgress = false;
@@ -953,7 +953,7 @@ class ClaudeChatProvider {
let actualMessage = message;
- // Manual compact (#36): seed the first real message of a fresh, post-compact
+ // Manual compact (fork-issue-36): seed the first real message of a fresh, post-compact
// session with the previous session's handoff summary. Consumed once — later
// messages in the same (new) session go through unmodified.
if (!opts?.compact && this._pendingCompactSummary) {
@@ -967,7 +967,7 @@ class ClaudeChatProvider {
this._draftMessage = '';
if (opts?.compact) {
- // Summarize turn for the compact button (#36): no user-visible echo — show
+ // Summarize turn for the compact button (fork-issue-36): no user-visible echo — show
// the compacting indicator instead.
this._postMessage({ type: 'compacting', data: { isCompacting: true } });
} else {
@@ -985,7 +985,7 @@ class ClaudeChatProvider {
});
// Create backup commit before Claude makes changes (skipped for the internal
- // compact summarize turn — #36)
+ // compact summarize turn — fork-issue-36)
if (!opts?.compact) {
try {
await this._createBackupCommit(message);
@@ -1034,7 +1034,7 @@ class ClaudeChatProvider {
}
// Add permission mode / effort based on the selected mode (manual = no flag,
- // i.e. byte-identical to the previous default spawn) (#31)
+ // i.e. byte-identical to the previous default spawn) (fork-issue-31)
if (this._selectedMode && this._selectedMode !== 'manual') {
args.push('--permission-mode', this._selectedMode);
}
@@ -1050,9 +1050,9 @@ class ClaudeChatProvider {
}
// Add session resume if we have a current session. Skipped once right after a
- // compact (#36): _forceFreshSession forces a session-less spawn so the CLI
+ // compact (fork-issue-36): _forceFreshSession forces a session-less spawn so the CLI
// starts clean instead of resuming the (now summarized-away) old session.
- // Snapshotted before being consumed below so the spawn log (#41) can report
+ // Snapshotted before being consumed below so the spawn log (fork-issue-41) can report
// what this spawn actually did — _forceFreshSession is false again and
// _currentSessionId still holds the old id by the time the log line runs.
const forcedFresh = this._forceFreshSession;
@@ -1171,7 +1171,7 @@ class ClaudeChatProvider {
this._resultSeen = false;
this._permLog(`spawned claude pid=${claudeProcess.pid} session=${resumeSessionId ?? '(new)'} forceFresh=${forcedFresh}`);
- // stdin lifecycle tracing (#15): record every way the control channel can
+ // stdin lifecycle tracing (fork-issue-15): record every way the control channel can
// die, so a field "Stream closed" can be attributed to a concrete event.
claudeProcess.stdin?.on('close', () => {
this._permLog(`stdin CLOSE event pid=${claudeProcess.pid} current=${this._currentClaudeProcess?.pid ?? 'none'}`);
@@ -1283,7 +1283,7 @@ class ClaudeChatProvider {
if (claudeProcess.stdout) {
claudeProcess.stdout.on('data', (data) => {
- // Stale-guard (#41): a killed process's reference is cleared before its
+ // Stale-guard (fork-issue-41): a killed process's reference is cleared before its
// stdio actually tears down, so late data from a superseded process must
// not mutate state (e.g. _currentSessionId) for whichever process is
// current now.
@@ -1343,7 +1343,7 @@ class ClaudeChatProvider {
claudeProcess.on('close', (code) => {
- // Manual compact (#36): captured before any of the branches below run, so
+ // Manual compact (fork-issue-36): captured before any of the branches below run, so
// _finishCompact() below always sees whether THIS turn was the summarize
// turn, regardless of exit code.
const wasCompact = this._compactInProgress;
@@ -1380,7 +1380,7 @@ class ClaudeChatProvider {
installAttempted: !!this._context.globalState.get('installAttempted')
});
} else if (!wasCompact) {
- // Error with output. Suppressed for the compact summarize turn (#36) —
+ // Error with output. Suppressed for the compact summarize turn (fork-issue-36) —
// _finishCompact's own compactSeparator message explains the failure.
this._sendAndSaveMessage({
type: 'error',
@@ -1389,7 +1389,7 @@ class ClaudeChatProvider {
}
}
- // Manual compact (#36): resolve the pending compaction (seed captured or not)
+ // Manual compact (fork-issue-36): resolve the pending compaction (seed captured or not)
// before the queue drains, regardless of exit code.
if (wasCompact) {
this._finishCompact(code === 0);
@@ -1685,7 +1685,7 @@ class ClaudeChatProvider {
this._isProcessing = false;
- // Manual compact (#36): capture the summarize turn's own result text
+ // Manual compact (fork-issue-36): capture the summarize turn's own result text
// as the seed for the next (fresh) session. Just capture it here —
// _finishCompact (driven by the close handler) does the state transition.
if (this._compactInProgress && typeof jsonData.result === 'string') {
@@ -1773,7 +1773,7 @@ class ClaudeChatProvider {
// Clear current session
this._currentSessionId = undefined;
- // Manual compact (#36): clear any in-flight/pending compaction state so the new
+ // Manual compact (fork-issue-36): clear any in-flight/pending compaction state so the new
// session starts clean.
this._compactInProgress = false;
this._pendingCompactSummary = undefined;
@@ -2114,7 +2114,7 @@ class ClaudeChatProvider {
}
/**
- * [perm] diagnostics (#15): mirror to console AND a temp file, because the
+ * [perm] diagnostics (fork-issue-15): mirror to console AND a temp file, because the
* console of an installed extension host is not persisted anywhere readable.
* Logging must never break the extension — swallow all fs errors.
*/
@@ -2129,12 +2129,12 @@ class ClaudeChatProvider {
}
/**
- * #15: end stdin only once a result arrived AND no permission request is
+ * fork-issue-15: end stdin only once a result arrived AND no permission request is
* pending. Known limitation (perm-log evidence 2026-07-22): background
* subagents can request permissions AFTER the turn's result, which this
* close still kills — but simply suppressing the close is worse: the CLI
* runs in persistent stream-json mode and the whole turn lifecycle
- * (#16 queue flush, #17 notify, _currentClaudeProcess reset) hangs on the
+ * (fork-issue-16 queue flush, fork-issue-17 notify, _currentClaudeProcess reset) hangs on the
* process 'close' event, so never ending stdin risks a frozen chat. The
* real fix is a lifecycle rework (keep channel open, detach on next user
* message); until that lands, this stays the reviewed 2f1ae0d behavior.
@@ -3195,7 +3195,7 @@ class ClaudeChatProvider {
}
// Derives the conversation's JSON filename from its first user message and start
- // time. Shared by the normal save path and _finishCompact's pinning (#36), which
+ // time. Shared by the normal save path and _finishCompact's pinning (fork-issue-36), which
// snapshots it before a compact-triggered session swap changes _currentSessionId
// out from under it.
private _deriveConversationFilename(): string {
@@ -3219,7 +3219,7 @@ class ClaudeChatProvider {
if (!this._currentSessionId) { return; }
try {
- // Filename is normally re-derived every save; pinned once a compact (#36)
+ // Filename is normally re-derived every save; pinned once a compact (fork-issue-36)
// has swapped in a new session, so the conversation keeps saving to the same
// file instead of splitting when _currentSessionId changes underneath it.
let filename = this._deriveConversationFilename();
@@ -3269,7 +3269,7 @@ class ClaudeChatProvider {
// Resolve the on-disk directory holding this workspace's CLI session transcripts
// (~/.claude/projects//*.jsonl), so the "CLI Sessions" list can surface
// conversations started directly from a `claude` terminal instead of this
- // extension (#37). Skipped for WSL workspaces — those transcripts live inside the
+ // extension (fork-issue-37). Skipped for WSL workspaces — those transcripts live inside the
// WSL filesystem, not under the Windows home directory this runs against.
private async _getCliProjectsDirs(): Promise {
const config = vscode.workspace.getConfiguration('claudeCodeChat');
@@ -3292,7 +3292,7 @@ class ClaudeChatProvider {
}
}
- // Send the "CLI Sessions" list for the History panel (#37): sessions found on disk
+ // Send the "CLI Sessions" list for the History panel (fork-issue-37): sessions found on disk
// for this workspace that aren't already tracked in this extension's own
// _conversationIndex (or the currently active session). Read-only and best-effort
// throughout — the JSONL format is CLI-internal and unstable, so any failure here
@@ -3401,7 +3401,7 @@ class ClaudeChatProvider {
}
// Best-effort preview of the last ~20 user/assistant messages in a CLI session,
- // for display only when resuming one (#37) — read from just the last 512KB of the
+ // for display only when resuming one (fork-issue-37) — read from just the last 512KB of the
// file so a long-running CLI session doesn't require loading its full transcript.
private async _readCliSessionPreview(filePath: string): Promise> {
const collected: Array<{ role: 'user' | 'assistant', text: string }> = [];
@@ -3450,7 +3450,7 @@ class ClaudeChatProvider {
}
// Resume a CLI session (~/.claude/projects//.jsonl) picked from the
- // "CLI Sessions" list (#37). Mirrors the state reset in _newSession(), minus the
+ // "CLI Sessions" list (fork-issue-37). Mirrors the state reset in _newSession(), minus the
// process kill — there's nothing to kill, since a CLI session was never spawned
// by this extension. Only sets _currentSessionId so the next real message resumes
// it via the unchanged --resume send path; the preview posted below is display-only
@@ -3480,7 +3480,7 @@ class ClaudeChatProvider {
this._totalTokensOutput = 0;
this._requestCount = 0;
- // Manual compact (#36): clear any in-flight/pending compaction state so the
+ // Manual compact (fork-issue-36): clear any in-flight/pending compaction state so the
// resumed session starts clean.
this._commits = [];
this._compactInProgress = false;
@@ -3686,7 +3686,7 @@ class ClaudeChatProvider {
const pid = processToKill?.pid;
this._permLog(`killClaudeProcess pid=${pid} current=${this._currentClaudeProcess?.pid}`);
- // Manual compact (#36): a kill always ends any in-flight summarize turn. The
+ // Manual compact (fork-issue-36): a kill always ends any in-flight summarize turn. The
// seed must go too — after a completed compaction it is consumed before the
// next spawn, so the only state where it can still be set here is a stop
// mid-summarize (result already parsed, close not yet fired). Leaving it
@@ -3818,7 +3818,7 @@ class ClaudeChatProvider {
this._totalTokensOutput = conversationData.totalTokens?.output || 0;
// Resume this conversation's own CLI session instead of leaving _currentSessionId
- // pointing at whatever conversation was active before this one was opened (#41) —
+ // pointing at whatever conversation was active before this one was opened (fork-issue-41) —
// otherwise the next turn resumes the wrong session, and the following save
// overwrites it with this conversation's messages. Only trusted if its transcript
// file still exists (case-insensitive slug dirs, same check _resumeCliSession uses
@@ -3845,13 +3845,13 @@ class ClaudeChatProvider {
}
}
this._currentSessionId = resumedSessionId;
- // A #36 pin only belongs to the conversation it was created for — drop it
+ // A fork-issue-36 pin only belongs to the conversation it was created for — drop it
// (and the checkpoint SHAs) when switching to a different saved conversation,
// keep it when re-loading the same one.
if (this._lastSavedFilename !== filename) {
this._pinnedConversationFilename = undefined;
// Same conditional: re-loading the open conversation keeps its checkpoint
- // SHAs restorable; only switching conversations drops them (#41).
+ // SHAs restorable; only switching conversations drops them (fork-issue-41).
this._commits = [];
}
this._lastSavedFilename = filename;
@@ -4390,7 +4390,7 @@ class ClaudeChatProvider {
}
private _executeSlashCommand(command: string): void {
- // Handle /compact via the summarize-and-restart flow (#36) instead of sending a
+ // Handle /compact via the summarize-and-restart flow (fork-issue-36) instead of sending a
// literal "/compact" to the CLI — the headless CLI has no real /compact.
if (command === 'compact') {
this._startCompact();
@@ -4426,7 +4426,7 @@ class ClaudeChatProvider {
});
}
- // Manual compact (#36): kicks off the summarize turn on the current (full-context)
+ // Manual compact (fork-issue-36): kicks off the summarize turn on the current (full-context)
// session. The actual state transition happens in _finishCompact, driven by the
// close handler once that turn's process exits.
private _startCompact(): void {
@@ -4446,7 +4446,7 @@ class ClaudeChatProvider {
this._sendMessageToClaude(COMPACT_PROMPT, undefined, { compact: true });
}
- // Manual compact (#36): called once the summarize turn's process has exited
+ // Manual compact (fork-issue-36): called once the summarize turn's process has exited
// (success or not). Pins the conversation filename and arms a forced-fresh-session
// for the next _sendMessageToClaude() call regardless of outcome — a failed
// summarize (e.g. a context-limit error) still needs a guaranteed way out of a
diff --git a/src/script.ts b/src/script.ts
index ca2431d..0cd5677 100644
--- a/src/script.ts
+++ b/src/script.ts
@@ -83,7 +83,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
let lastPendingEditData = null; // Store diff data for the pending edit { filePath, oldContent, newContent }
let attachedImages = []; // Array of { filePath, previewUri }
- // #46 (upstream #98): raw text handed to parseSimpleMarkdown for each
+ // fork-issue-46 (upstream #98): raw text handed to parseSimpleMarkdown for each
// rendered claude/user message, keyed by that message's root div. The
// copy button (copyMessageContent) reads from here instead of the
// rendered DOM, so Markdown render artifacts — e.g. /
letting
@@ -201,7 +201,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
moveProcessingIndicatorToLast();
scrollToBottomIfNeeded(messagesDiv, shouldScroll);
- // #46: remember the raw source text for the copy button, when given.
+ // fork-issue-46: remember the raw source text for the copy button, when given.
if (rawText !== undefined) {
messageRawText.set(messageDiv, rawText);
}
@@ -3523,7 +3523,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
function copyMessageContent(messageDiv) {
const contentDiv = messageDiv.querySelector('.message-content');
if (contentDiv) {
- // #46 (upstream #98): prefer the raw source text the message was
+ // fork-issue-46 (upstream #98): prefer the raw source text the message was
// rendered from over the rendered DOM. contentDiv.innerText re-derives
// list numbering etc. from the live /
markup, which can
// mismatch or duplicate the original Markdown digits. Falls back to
@@ -3803,7 +3803,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt
break;
case 'compactSeparator':
- // Manual compact (#36): the backend already reset its own token
+ // Manual compact (fork-issue-36): the backend already reset its own token
// counters; mirror that here so the status bar doesn't linger at
// the pre-compact value.
totalTokensInput = 0;
diff --git a/src/ui-styles.ts b/src/ui-styles.ts
index d10b579..64866f2 100644
--- a/src/ui-styles.ts
+++ b/src/ui-styles.ts
@@ -2271,7 +2271,7 @@ const styles = `
background-color: var(--vscode-list-hoverBackground);
}
- /* Compact suggested (#36) once context usage crosses the same 85%+ range the
+ /* Compact suggested (fork-issue-36) once context usage crosses the same 85%+ range the
Ctx indicator starts warning at — a subtle nudge, no animation. */
.slash-btn.compact-suggested {
box-shadow: 0 0 0 1px var(--vscode-inputValidation-warningBorder);
@@ -5151,7 +5151,7 @@ const styles = `
color: #10b981 !important;
}
- /* Compact mode (#18) */
+ /* Compact mode (fork-issue-18) */
body.compact-mode .messages {
padding: 5px;
font-size: calc(var(--vscode-editor-font-size) - 1px);