diff --git a/README.md b/README.md index 7466283..d4e1df4 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Ditch the command line and experience Claude Code like never before. This extens ### 📝 **Inline Diff Viewer** - **Full Diff Display** - See complete file changes directly in Edit, MultiEdit, and Write messages -- **Open in VS Code Diff** - One-click button to open VS Code's native side-by-side diff editor +- **Open in VS Code Diff** - One-click button opens a real VS Code diff comparing the checkpoint from before the current turn against the live, editable file; it stays available after the edit completes and after reloading a saved conversation, and can auto-open after every successful edit (configurable in settings) - **Smart Truncation** - Long diffs are truncated with an expand button for better readability - **Syntax Highlighting** - Proper code highlighting in diff views - **Visual Change Indicators** - Clear green/red highlighting for additions and deletions diff --git a/package.json b/package.json index 6bcdf89..c8d7033 100644 --- a/package.json +++ b/package.json @@ -194,6 +194,12 @@ "default": "", "description": "Custom path to the Claude Code executable. Leave empty to use the default 'claude' command." }, + "claudeCodeChat.advanced.maxOutputTokens": { + "type": "number", + "default": 0, + "minimum": 0, + "description": "Maximum number of tokens Claude may generate in a single response (sets the CLAUDE_CODE_MAX_OUTPUT_TOKENS environment variable for the CLI). Increase this if you hit a \"response exceeded the output token maximum\" error. 0 = use the CLI default." + }, "claudeCodeChat.environment.variables": { "type": "object", "default": {}, @@ -208,6 +214,23 @@ "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.diff.autoOpen": { + "type": "boolean", + "default": true, + "description": "Automatically open a VS Code diff view after Claude successfully edits or creates a file, comparing it against the checkpoint from before the current turn." + }, + "claudeCodeChat.ui.fontFamily": { + "type": "string", + "default": "", + "description": "Custom font family for the chat message area and input field. Leave empty to use the editor's default font." + }, + "claudeCodeChat.ui.fontSize": { + "type": "number", + "default": 0, + "minimum": 0, + "maximum": 72, + "description": "Custom font size (px, 6-72) for the chat message area and input field. Leave at 0 to use the editor's default font size." } } } @@ -221,7 +244,10 @@ "test": "vscode-test", "test:downloader": "npm run compile && mocha --ui tdd \"out/test/downloader*.test.js\" --reporter spec --timeout 360000", "test:downloader:unit": "npm run compile && mocha --ui tdd out/test/downloader.test.js --reporter spec", - "test:models": "npm run compile && mocha --ui tdd out/test/model-updater.test.js --reporter spec" + "test:models": "npm run compile && mocha --ui tdd out/test/model-updater.test.js --reporter spec", + "test:diff-utils": "npm run compile && mocha --ui tdd out/test/diff-utils.test.js --reporter spec", + "test:restore-commit-utils": "npm run compile && mocha --ui tdd out/test/restore-commit-utils.test.js --reporter spec", + "test:settings-batch": "npm run compile && mocha --ui tdd out/test/settings-batch.test.js --reporter spec" }, "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/src/diff-utils.ts b/src/diff-utils.ts new file mode 100644 index 0000000..b23dfd5 --- /dev/null +++ b/src/diff-utils.ts @@ -0,0 +1,116 @@ +// Pure helpers for the fork-issue-38 turn-diff feature (real vscode.diff view comparing the +// pre-turn checkpoint against the live file). No vscode import, so these run under +// plain mocha like model-updater -- extension.ts owns all the side effects (git exec, +// workspace lookup, vscode.Uri/vscode.diff) and just feeds paths/buffers through +// these functions. + +// Maps a WSL-reported path (e.g. /mnt/c/Users/example/foo.ts, as seen in tool_use +// rawInput.file_path when claudeCodeChat.wsl.enabled is on) back to the real Windows +// path VS Code and git need. Only rewrites an actual /mnt//... path; anything +// else (already a Windows path, or a Linux path outside /mnt) is returned unchanged. +export function mapWslPathToWindows(filePath: string): string { + const match = filePath.match(/^\/mnt\/([a-zA-Z])\/(.*)$/); + if (!match) { + return filePath; + } + const drive = match[1].toUpperCase(); + const rest = match[2].replace(/\//g, '\\'); + return `${drive}:\\${rest}`; +} + +// Resolves an absolute file path to a path relative to the workspace root, using '/' +// separators so the result can be passed straight to `git show :` +// (git's tree-ish path syntax always uses '/', regardless of OS). Comparison is +// case-insensitive on Windows, where the filesystem is case-insensitive but tool +// input paths and the workspace folder path aren't guaranteed to agree on casing. +// Returns undefined when filePath isn't inside workspaceRoot ("not mappable"), which +// also covers filePath being the workspace root itself (a +// directory has no checkpointed blob to diff against, so treat it the same as +// "outside the workspace" instead of handing callers a '' relPath). +export function toWorkspaceRelativePath(filePath: string, workspaceRoot: string): string | undefined { + const normalize = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, ''); + const normFile = normalize(filePath); + const normRoot = normalize(workspaceRoot); + if (!normFile || !normRoot) { + return undefined; + } + + const caseInsensitive = process.platform === 'win32'; + const fileKey = caseInsensitive ? normFile.toLowerCase() : normFile; + const rootKey = caseInsensitive ? normRoot.toLowerCase() : normRoot; + + if (fileKey === rootKey) { + return undefined; + } + if (fileKey.startsWith(rootKey + '/')) { + return normFile.slice(normRoot.length + 1); + } + return undefined; +} + +// Binary heuristic used to keep obviously-binary content out of the diff virtual +// document: a NUL byte within the first 8 KB, the same window common tools (git, +// grep) use. Not exact binary detection -- just a "should we even try to diff this" +// guard before handing content to a text-based diff view. +const BINARY_CHECK_WINDOW_BYTES = 8192; + +export function isBinaryContent(content: Buffer): boolean { + const len = Math.min(content.length, BINARY_CHECK_WINDOW_BYTES); + for (let i = 0; i < len; i++) { + if (content[i] === 0) { + return true; + } + } + return false; +} + +// Scheme of the existing read-only diff content provider (registered once in +// extension.ts, reused here instead of adding a second provider). +export const TURN_DIFF_URI_SCHEME = 'claude-diff'; + +export interface TurnDiffUriParts { + scheme: string; + path: string; + query: string; +} + +// Builds the (scheme, path, query) a stable baseline URI is made of: deterministic +// per (sha, relPath), so vscode.Uri.from(parts) always produces the identical URI +// for the same turn+file and VS Code dedupes the tab instead of stacking a new one +// per click. relPath keeps its real extension in `path` (git's basename) so the +// virtual document still gets the right syntax highlighting; `sha` goes in the query +// so two turns diffing the same file don't collide on the same URI/cache entry. +export function buildTurnDiffUriParts(sha: string, relPath: string): TurnDiffUriParts { + return { + scheme: TURN_DIFF_URI_SCHEME, + path: '/' + relPath.replace(/^\/+/, ''), + query: `sha=${sha}` + }; +} + +// Inverse of buildTurnDiffUriParts: recovers (sha, relPath) from +// a claude-diff URI's own (path, query), so DiffContentProvider can resolve a cache +// miss -- a tab restored via "Reopen Closed Editor" or a VS Code restart, after the +// in-memory diffContentStore is gone -- without needing any other state. vscode.Uri +// hands back path/query already decoded, matching what buildTurnDiffUriParts wrote, +// so this is a plain string split, not URI-decoding. Returns undefined when query +// doesn't look like a URI this feature built (defensive; shouldn't happen for a URI +// on the claude-diff scheme). +export function parseTurnDiffUriParts(parts: { path: string; query: string }): { sha: string; relPath: string } | undefined { + const match = parts.query.match(/^sha=(.*)$/); + if (!match) { + return undefined; + } + return { + sha: match[1], + relPath: parts.path.replace(/^\/+/, '') + }; +} + +// Cache key for the content-provider's Map, derived identically on the writer (host, +// right before vscode.diff) and reader (provideTextDocumentContent) side so a +// (sha, relPath) pair never collides with a different turn's baseline for the same +// file (see buildTurnDiffUriParts). +export function turnDiffCacheKey(parts: { path: string; query: string }): string { + return `${parts.path}?${parts.query}`; +} diff --git a/src/extension.ts b/src/extension.ts index 8fa37fb..627dd72 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,30 +3,110 @@ 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'; import recommendedModels from './recommended-models.json'; import { downloadClaude, detectPlatform, DownloaderError } from './claudeDownloader'; +import { mapWslPathToWindows, toWorkspaceRelativePath, isBinaryContent, buildTurnDiffUriParts, parseTurnDiffUriParts, turnDiffCacheKey } from './diff-utils'; +import { isValidCommitSha, findRehydratedCommitInfo } from './restore-commit-utils'; +import { applySettingsBatch } from './settings-batch'; // OpenCredits environment configuration let OPENCREDITS_API_URL = 'https://ccc.api.opencredits.ai'; let OPENCREDITS_WEB_URL = 'https://ccc.opencredits.ai'; let OPENCREDITS_PUBLISHABLE_KEY = 'oc_pk_c43da4f9a9484ae484ad29bc97cc354f'; -const exec = util.promisify(cp.exec); +// Undocumented endpoint for session-usage / weekly-limit percentages (fork-issue-35). The +// server responds 429 to requests without a recognized User-Agent, hence pinning +// one that matches a real claude-code CLI release. +const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'; +const USAGE_USER_AGENT = 'claude-code/2.1.218'; + +// Base URL substrings that identify a known first-party endpoint (OpenCredits/router) +const KNOWN_ENDPOINT_MARKERS = ['opencredits.ai', 'localhost:8787']; -// Storage for diff content (used by DiffContentProvider) +const exec = util.promisify(cp.exec); +// Used only for the fork-issue-38 turn-diff `git show` call: relPath is derived from a tool's +// file_path (Claude-controlled), so it goes through execFile's argv array instead of +// exec's shell string -- no shell means embedded quotes/metacharacters in a path can't +// break out into a second command, unlike the pre-existing exec() checkpoint calls +// below (untouched, out of scope here) which only ever see either a fixed argv, a sha +// git already produced itself, or this._backupRepoPath/workspacePath. +const execFile = util.promisify(cp.execFile); + +// 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'); + +// Storage for diff content (used by DiffContentProvider). Keyed by turnDiffCacheKey() +// (path+query) so two turns diffing the same relPath under different checkpoint SHAs +// don't collide on the same entry. Bounded: entries used to be +// removed by an onDidCloseTextDocument listener, which neither fired for every tab +// lifecycle (e.g. vscode.diff throwing after the entry was already stored) nor could +// ever help resolve a cache miss -- a tab restored via "Reopen Closed Editor" or a VS +// Code restart starts with an empty store no listener could have populated. Since +// DiffContentProvider now resolves misses itself instead (see below), there's nothing +// left that needs a close-time delete; FIFO eviction here just caps how much stale +// baseline text can pile up from an unlucky sequence of turns. +const TURN_DIFF_CACHE_MAX_ENTRIES = 32; const diffContentStore = new Map(); -// Custom TextDocumentContentProvider for read-only diff views +function cacheTurnDiffContent(key: string, content: string): void { + if (diffContentStore.size >= TURN_DIFF_CACHE_MAX_ENTRIES) { + const oldestKey = diffContentStore.keys().next().value; + if (oldestKey !== undefined) { + diffContentStore.delete(oldestKey); + } + } + diffContentStore.set(key, content); +} + +// Custom TextDocumentContentProvider for read-only diff views (fork-issue-38 turn diff: serves +// the pre-turn checkpoint content as the left/baseline side of vscode.diff). Content +// is normally already cached (written by _openTurnDiff right before vscode.diff is +// invoked), but a cache miss -- e.g. a claude-diff tab restored via "Reopen Closed +// Editor" or after a VS Code restart -- is resolved on demand +// through the injected resolver, using only the (sha, relPath) already baked into the +// URI itself (see parseTurnDiffUriParts), so the provider needs no other state. class DiffContentProvider implements vscode.TextDocumentContentProvider { - provideTextDocumentContent(uri: vscode.Uri): string { - const content = diffContentStore.get(uri.path); - return content || ''; + constructor(private readonly _resolveBaseline: (sha: string, relPath: string) => Promise) { } + + async provideTextDocumentContent(uri: vscode.Uri): Promise { + const key = turnDiffCacheKey({ path: uri.path, query: uri.query }); + const cached = diffContentStore.get(key); + if (cached !== undefined) { + return cached; + } + + const parts = parseTurnDiffUriParts({ path: uri.path, query: uri.query }); + if (!parts) { + throw new Error('Claude turn baseline unavailable for this tab'); + } + try { + const content = await this._resolveBaseline(parts.sha, parts.relPath); + cacheTurnDiffContent(key, content); + return content; + } catch { + // Reason (git error, guard, no workspace/checkpoint repo) is intentionally + // not surfaced here -- VS Code just needs an honest "this tab has no + // content" error instead of a silent empty page; _openTurnDiff's own + // fallback path (manual toast / auto permLog) is what actually explains + // failures for the live open-diff flow. + throw new Error('Claude turn baseline unavailable for this tab'); + } } } +// fork-issue-38 turn diff guards: `git show` is capped at a generous hard limit so a huge +// checkpointed file can't hang/OOM the exec call, but anything still over the much +// smaller display limit (or binary) falls back to opening the file directly instead +// of stuffing megabytes of text into a virtual document. +const TURN_DIFF_MAX_DISPLAY_BYTES = 2 * 1024 * 1024; +const TURN_DIFF_MAX_EXEC_BYTES = 16 * 1024 * 1024; + export function activate(context: vscode.ExtensionContext) { if (context.extensionMode === vscode.ExtensionMode.Development) { @@ -48,8 +128,11 @@ export function activate(context: vscode.ExtensionContext) { const webviewProvider = new ClaudeChatWebviewProvider(context.extensionUri, provider); vscode.window.registerWebviewViewProvider('claude-code-chat.chat', webviewProvider); - // Register custom content provider for read-only diff views - const diffProvider = new DiffContentProvider(); + // Register custom content provider for read-only diff views. Wired to the single + // shared ClaudeChatProvider instance's baseline resolver -- both the panel command + // and the sidebar webview use this same instance, so a claude-diff tab always + // resolves to the same backup repo regardless of which one opened it. + const diffProvider = new DiffContentProvider((sha, relPath) => provider.resolveTurnDiffBaselineForProvider(sha, relPath)); context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider('claude-diff', diffProvider)); // Listen for configuration changes @@ -164,13 +247,30 @@ class ClaudeChatProvider { private _totalCost: number = 0; private _totalTokensInput: number = 0; private _totalTokensOutput: number = 0; + // Non-cumulative holder for the most recent turn's context usage (input + + // cache read + cache creation tokens), unlike the cumulative counters above (fork-issue-27). + private _currentContextTokens: number = 0; private _requestCount: number = 0; private _subscriptionType: string | undefined; // 'pro', 'max', or undefined for API users + // Session-usage / weekly-limit snapshot from the undocumented oauth/usage endpoint + // (fork-issue-35), shown next to the fork-issue-27 context indicator. Account-wide, not session-scoped + // — deliberately not reset in _newSession()/sessionCleared. sevenDayOpus/ + // sevenDaySonnet are the per-model-tier weekly buckets (CLI schema names, not + // display labels). + private _usageLimits: { fiveHour?: { pct: number; resetsAt?: number }, week?: { pct: number; resetsAt?: number }, sevenDayOpus?: { pct: number; resetsAt?: number }, sevenDaySonnet?: { pct: number; resetsAt?: number } } | undefined = undefined; + private _usageLastFetchMs = 0; + // Fallback resetsAt for the five-hour window, learned from the CLI's own + // stream-json rate-limit events when the usage endpoint's resets_at is absent. + private _lastRateLimitResetsAt: number | undefined; private _accountInfoFetchedThisSession: boolean = false; // Track if we fetched account info this session private _pendingModelAfterPayment: string | null = null; private _currentSessionId: string | undefined; private _backupRepoPath: string | undefined; private _commits: Array<{ id: string, sha: string, message: string, timestamp: string }> = []; + // fork-issue-38 turn diff auto-open: files already auto-diffed in the current turn, so + // repeated edits to the same file don't keep reopening/refocusing the tab. Reset + // at the start of every turn in _sendMessageToClaude. + private _autoOpenedDiffFilesThisTurn: Set = new Set(); private _conversationsPath: string | undefined; // Pending permission requests from stdio control_request messages private _pendingPermissionRequests: Map>('environment.variables', {}); const baseUrl = envVars['ANTHROPIC_BASE_URL'] || ''; - return baseUrl.includes('opencredits.ai') || baseUrl.includes('localhost:8787'); + return KNOWN_ENDPOINT_MARKERS.some(marker => baseUrl.includes(marker)); } private async _setEnvsDisabled(disabled: boolean): Promise { @@ -403,6 +518,9 @@ class ClaudeChatProvider { }); } + // Send (possibly cached) session-usage / weekly-limit percentages (fork-issue-35) + void this._maybeSendUsageLimits(); + // Send platform information to webview this._sendPlatformInfo(); @@ -595,11 +713,8 @@ class ClaudeChatProvider { case 'openFile': this._openFileInEditor(message.filePath); return; - case 'openDiff': - this._openDiffEditor(message.oldContent, message.newContent, message.filePath); - return; - case 'openDiffByIndex': - this._openDiffByMessageIndex(message.messageIndex); + case 'openTurnDiff': + this._openTurnDiff(message.filePath, message.messageIndex, 'manual'); return; case 'createImageFile': this._createImageFile(message.imageData, message.imageType); @@ -670,6 +785,11 @@ class ClaudeChatProvider { case 'enableYoloMode': this._enableYoloMode(); return; + case 'openMaxOutputTokensSettings': + // fork-issue-42: deep-link into the native Settings UI, filtered on our setting. + // No value is set automatically - the user picks the limit themselves. + vscode.commands.executeCommand('workbench.action.openSettings', 'claudeCodeChat.advanced.maxOutputTokens'); + return; case 'saveInputText': this._saveInputText(message.text); return; @@ -893,6 +1013,9 @@ class ClaudeChatProvider { this._isProcessing = true; + // fork-issue-38 turn diff auto-open: fresh per-turn dedup set for this new turn. + this._autoOpenedDiffFilesThisTurn = new Set(); + // Clear draft message since we're sending it this._draftMessage = ''; @@ -979,6 +1102,7 @@ class ClaudeChatProvider { const customExecutablePath = config.get('executable.path', ''); const envsDisabled = config.get('environment.disabled', false); const customEnvVars = envsDisabled ? {} : config.get>('environment.variables', {}); + const maxOutputTokens = config.get('advanced.maxOutputTokens', 0); // Check if using OpenCredits (base URL contains opencredits.ai) const isOpenCredits = this._isOpenCredits(); @@ -999,7 +1123,10 @@ class ClaudeChatProvider { FORCE_COLOR: '0', NO_COLOR: '1', ...customEnvVars, // Apply custom environment variables (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, etc.) - CLAUDE_CODE_ENTRYPOINT: 'claude-vscode' + CLAUDE_CODE_ENTRYPOINT: 'claude-vscode', + // fork-issue-42: raise the CLI's response size cap when configured, to work around + // "response exceeded the output token maximum" errors (upstream #150) + ...(Math.floor(maxOutputTokens) > 0 ? { CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(Math.floor(maxOutputTokens)) } : {}) }; // OpenCredits: clear Anthropic-specific vars so Claude CLI uses env vars directly @@ -1033,6 +1160,9 @@ class ClaudeChatProvider { wslEnvOverrides['DISABLE_COST_WARNINGS'] = 'true'; } wslEnvOverrides['CLAUDE_CODE_ENTRYPOINT'] = 'claude-vscode'; + if (Math.floor(maxOutputTokens) > 0) { + wslEnvOverrides['CLAUDE_CODE_MAX_OUTPUT_TOKENS'] = String(Math.floor(maxOutputTokens)); + } const envExports = Object.entries(wslEnvOverrides) .map(([k, v]) => `export ${k}="${v.replace(/"/g, '\\"')}"`) .join(' && '); @@ -1343,6 +1473,7 @@ class ClaudeChatProvider { // Reset tokens since the conversation is now summarized this._totalTokensInput = 0; this._totalTokensOutput = 0; + this._currentContextTokens = 0; this._sendAndSaveMessage({ type: 'compactBoundary', @@ -1361,6 +1492,14 @@ class ClaudeChatProvider { this._totalTokensInput += jsonData.message.usage.input_tokens || 0; this._totalTokensOutput += jsonData.message.usage.output_tokens || 0; + // Non-cumulative context estimate for the current turn: input + cache + // read + cache creation tokens are what actually occupies the model's + // context window, unlike the cumulative counters above (fork-issue-27). + const ctx = (jsonData.message.usage.input_tokens || 0) + + (jsonData.message.usage.cache_read_input_tokens || 0) + + (jsonData.message.usage.cache_creation_input_tokens || 0); + this._currentContextTokens = ctx; + // Send real-time token update to webview this._sendAndSaveMessage({ type: 'updateTokens', @@ -1370,7 +1509,8 @@ class ClaudeChatProvider { currentInputTokens: jsonData.message.usage.input_tokens || 0, currentOutputTokens: jsonData.message.usage.output_tokens || 0, cacheCreationTokens: jsonData.message.usage.cache_creation_input_tokens || 0, - cacheReadTokens: jsonData.message.usage.cache_read_input_tokens || 0 + cacheReadTokens: jsonData.message.usage.cache_read_input_tokens || 0, + currentContextTokens: ctx } }); } @@ -1492,7 +1632,8 @@ class ClaudeChatProvider { const isError = content.is_error || false; // Find the last tool use to get the tool name, input, and computed startLine - const lastToolUse = this._currentConversation[this._currentConversation.length - 1] + const toolUseMessageIndex = this._currentConversation.length - 1; + const lastToolUse = this._currentConversation[toolUseMessageIndex]; const toolName = lastToolUse?.data?.toolName; const rawInput = lastToolUse?.data?.rawInput; @@ -1541,6 +1682,23 @@ class ClaudeChatProvider { } }); } + + // fork-issue-38: auto-open a turn diff after a successful Edit/MultiEdit/Write, + // once per file per turn (see _autoOpenedDiffFilesThisTurn reset in + // _sendMessageToClaude). Manual "Open Diff" clicks go through the same + // _openTurnDiff but aren't gated by the setting or this dedup set. + // trigger: 'auto' -- Claude sessions routinely edit + // files outside the workspace (scratchpad, ~/.claude memory, etc.), so + // _openTurnDiff failing here is the ordinary case, not something to + // interrupt the user with a toast/focus-stealing showTextDocument for. + if ((toolName === 'Edit' || toolName === 'MultiEdit' || toolName === 'Write') && !isError && rawInput?.file_path) { + const autoOpenDiff = vscode.workspace.getConfiguration('claudeCodeChat').get('diff.autoOpen', true); + const dedupeKey = process.platform === 'win32' ? rawInput.file_path.toLowerCase() : rawInput.file_path; + if (autoOpenDiff && !this._autoOpenedDiffFilesThisTurn.has(dedupeKey)) { + this._autoOpenedDiffFilesThisTurn.add(dedupeKey); + void this._openTurnDiff(rawInput.file_path, toolUseMessageIndex, 'auto'); + } + } } } } @@ -1612,12 +1770,34 @@ class ClaudeChatProvider { } }); + // fork-issue-35/fork-issue-54: refresh session-usage / weekly-limit percentages alongside the + // existing totals update. The finished turn just consumed usage, so the + // 5-minute throttle would show stale percentages for exactly the update + // the user is watching — bypass it, with a 30s floor so rapid-fire turns + // don't hammer the undocumented endpoint. + if (Date.now() - this._usageLastFetchMs > 30000) { + this._usageLastFetchMs = 0; + } + void this._maybeSendUsageLimits(); + // Refresh OpenCredits balance after each request if using OpenCredits if (this._isOpenCredits() || this._getOpenCreditsKey()) { this._sendOpenCreditsBalance(); } } break; + + case 'rate_limit_event': { + // fork-issue-35: learn the five-hour window's reset time from the CLI's own + // rate-limit events, as a fallback for when the usage endpoint's + // response doesn't include one for that window. + const rateLimitType = jsonData.rate_limit_info?.rateLimitType; + if (!rateLimitType || rateLimitType === 'five_hour') { + this._lastRateLimitResetsAt = jsonData.rate_limit_info?.resetsAt; + } + void this._maybeSendUsageLimits(); + break; + } } } @@ -1646,6 +1826,7 @@ class ClaudeChatProvider { this._totalCost = 0; this._totalTokensInput = 0; this._totalTokensOutput = 0; + this._currentContextTokens = 0; this._requestCount = 0; // Notify webview to clear all messages and reset session @@ -1812,7 +1993,45 @@ class ClaudeChatProvider { private async _restoreToCommit(commitSha: string): Promise { try { - const commit = this._commits.find(c => c.sha === commitSha); + // fork-issue-50: commitSha can arrive rehydrated from a loaded conversation's + // persisted JSON, not only from same-session git output -- validate + // before it can reach any git command below. + if (!isValidCommitSha(commitSha)) { + this._postMessage({ + type: 'restoreError', + data: 'Commit not found' + }); + return; + } + + let commit = this._commits.find(c => c.sha === commitSha); + + // fork-issue-50: a history load that switches conversations clears _commits but + // still replays this commit's showRestoreOption message, so its Restore + // button outlives this lookup. Confirm the sha against the shadow backup + // repo instead and rehydrate the display info from the replayed entry. + if (!commit && this._backupRepoPath) { + try { + // argv/no-shell (unlike the exec() calls below): commitSha can come + // from persisted JSON. `^{commit}` rejects a tree/blob sha that + // happens to pass the hex check -- still a single argv element. + await execFile('git', ['--git-dir', this._backupRepoPath, 'cat-file', '-e', `${commitSha}^{commit}`]); + commit = findRehydratedCommitInfo(this._currentConversation, commitSha); + } catch (error: any) { + // With the ^{commit} peel, git reports both "sha missing" and "sha + // not a commit" as exit 128 + "fatal: Not a valid object name" (not + // exit 1, which a plain, unpeeled `git cat-file -e ` would report + // for a simply-missing object), so classify on stderr like + // _resolveTurnDiffBaseline does: that text is the silent, expected + // miss; anything else (ENOENT, broken backup repo) is real + // infrastructure failure worth a log line. + const stderrText = String(error?.stderr || ''); + if (!/Not a valid object name/i.test(stderrText)) { + console.error('Failed to check commit existence in backup repo:', error.message); + } + } + } + if (!commit) { this._postMessage({ type: 'restoreError', @@ -3289,6 +3508,7 @@ class ClaudeChatProvider { this._totalCost = conversationData.totalCost || 0; this._totalTokensInput = conversationData.totalTokens?.input || 0; this._totalTokensOutput = conversationData.totalTokens?.output || 0; + this._currentContextTokens = 0; // Clear UI messages first, then send all messages to recreate the conversation setTimeout(() => { @@ -3398,8 +3618,17 @@ class ClaudeChatProvider { 'permissions.yoloMode': config.get('permissions.yoloMode', false), 'router.enabled': config.get('router.enabled', false), 'executable.path': config.get('executable.path', ''), + // Correction to the fork-issue-44/fork-issue-42 commit message: this line only adds + // the key to the plain settingsData payload _sendCurrentSettings already sends -- + // there is no claudeCodeChat.advanced entry in any onDidChangeConfiguration / + // affectsConfiguration listener (the only one, above in activate(), still filters + // on claudeCodeChat.wsl only). + 'advanced.maxOutputTokens': config.get('advanced.maxOutputTokens', 0), 'environment.variables': config.get>('environment.variables', {}), 'environment.disabled': config.get('environment.disabled', false), + 'diff.autoOpen': config.get('diff.autoOpen', true), + 'ui.fontFamily': config.get('ui.fontFamily', ''), + 'ui.fontSize': config.get('ui.fontSize', 0), 'isOpenCredits': this._isOpenCredits() }; @@ -3434,7 +3663,11 @@ class ClaudeChatProvider { const config = vscode.workspace.getConfiguration('claudeCodeChat'); try { - for (const [key, value] of Object.entries(settings)) { + // fork-issue-56: each key gets its own try/catch (inside applySettingsBatch) so one + // rejected config.update() -- e.g. a setting not yet registered right after + // a version bump -- no longer silently drops every key that comes after it + // in the same batch. + const result = await applySettingsBatch(settings, async (key, value) => { if (key === 'permissions.yoloMode') { // YOLO mode: try workspace first, fall back to global try { @@ -3446,8 +3679,9 @@ class ClaudeChatProvider { // Other settings are global (user-wide) await config.update(key, value, vscode.ConfigurationTarget.Global); } - } + }); + // fork-issue-56: must run even when some keys above failed, not just on full success. // Re-send settings so webview gets updated isOpenCredits flag, etc. this._sendCurrentSettings(); @@ -3461,6 +3695,17 @@ class ClaudeChatProvider { balance: null }); } + + if (result.failures.length > 0) { + // One error: name it with its own message. Several: list every failed + // key, but still show the first error's message -- the "why" (e.g. a + // VS Code "not a registered configuration" message) is the actionable + // part, not just which keys failed. + const failedKeys = result.failures.map(f => f.key).join(', '); + const summary = `${failedKeys}: ${result.failures[0].message}`; + console.error('Failed to update settings:', result.failures); + vscode.window.showErrorMessage(`Failed to update settings: ${summary}`); + } } catch (error: any) { console.error('Failed to update settings:', error?.message || error); vscode.window.showErrorMessage(`Failed to update settings: ${error?.message || 'Unknown error'}`); @@ -3609,6 +3854,155 @@ class ClaudeChatProvider { }); } + // Reads the CLI's OAuth access token from ~/.claude/.credentials.json for the + // undocumented usage endpoint (fork-issue-35). Read-only: never touches refreshToken, never + // logs the token, never sends it to the webview. Any failure (file missing, parse + // error) yields null. + private async _readOAuthAccessToken(): Promise { + try { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + const credentialsPath = path.join(homeDir, '.claude', '.credentials.json'); + const content = await vscode.workspace.fs.readFile(vscode.Uri.file(credentialsPath)); + const parsed = JSON.parse(new TextDecoder().decode(content)); + return parsed?.claudeAiOauth?.accessToken ?? null; + } catch { + return null; + } + } + + // Fetch session-usage / weekly-limit percentages from the undocumented oauth/usage + // endpoint (fork-issue-35). Best-effort: any failure (missing token, network error, + // unexpected response shape) yields null instead of throwing, so the caller can + // keep serving a stale cache. + private async _fetchUsageLimits(): Promise { + const token = await this._readOAuthAccessToken(); + if (!token) { + return null; + } + + try { + const response = await fetch(USAGE_URL, { + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + token, + 'anthropic-beta': 'oauth-2025-04-20', + 'User-Agent': USAGE_USER_AGENT + } + }); + + if (!response.ok) { + this._permLog(`usageLimits fetch status=${response.status} hasData=false`); + return null; + } + + const data = await response.json() as any; + + // Parses one usage window (five_hour / seven_day / seven_day_opus / + // seven_day_sonnet, or a limits[] entry, which uses `percent` instead of + // `utilization`/`used_percentage`). Drops the window entirely unless it + // has a valid numeric percentage; resets_at may be a unix-seconds number + // or an ISO string, anything else is left out. + const parseWindow = (win: any, isFiveHour: boolean): { pct: number; resetsAt?: number } | undefined => { + if (!win || typeof win !== 'object') { + return undefined; + } + const pct = win.utilization ?? win.used_percentage ?? win.percent; + if (typeof pct !== 'number' || !isFinite(pct)) { + return undefined; + } + + let resetsAt: number | undefined; + const rawResetsAt = win.resets_at; + if (typeof rawResetsAt === 'number' && isFinite(rawResetsAt)) { + resetsAt = rawResetsAt; + } else if (typeof rawResetsAt === 'string') { + const parsedMs = Date.parse(rawResetsAt); + if (!isNaN(parsedMs)) { + resetsAt = parsedMs / 1000; + } + } + if (resetsAt === undefined && isFiveHour) { + resetsAt = this._lastRateLimitResetsAt; + } + + return { pct, resetsAt }; + }; + + const result: typeof this._usageLimits = {}; + const fiveHour = parseWindow(data?.five_hour, true); + if (fiveHour) { + result.fiveHour = fiveHour; + } + const week = parseWindow(data?.seven_day, false); + if (week) { + result.week = week; + } + const sevenDayOpus = parseWindow(data?.seven_day_opus, false); + if (sevenDayOpus) { + result.sevenDayOpus = sevenDayOpus; + } + const sevenDaySonnet = parseWindow(data?.seven_day_sonnet, false); + if (sevenDaySonnet) { + result.sevenDaySonnet = sevenDaySonnet; + } + + // fork-issue-35: newer accounts return the per-model weekly windows only as + // limits[] entries (kind "weekly_scoped" with a model scope) while the + // legacy seven_day_opus/seven_day_sonnet fields stay null. Top-level + // fields win when both are present. + if (Array.isArray(data?.limits)) { + // is_active entries first, so a stale scoped window cannot shadow + // the live one if several model-scoped entries are present. + const scoped = data.limits.filter((e: any) => e && e.kind === 'weekly_scoped'); + scoped.sort((a: any, b: any) => (b?.is_active === true ? 1 : 0) - (a?.is_active === true ? 1 : 0)); + for (const entry of scoped) { + const displayName = entry.scope?.model?.display_name; + if (typeof displayName !== 'string') { continue; } + const win = parseWindow(entry, false); + if (!win) { continue; } + if (/sonnet/i.test(displayName)) { + if (!result.sevenDaySonnet) { result.sevenDaySonnet = win; } + } else if (!result.sevenDayOpus) { + result.sevenDayOpus = win; + } + } + } + + const hasData = !!(result.fiveHour || result.week || result.sevenDayOpus || result.sevenDaySonnet); + this._permLog(`usageLimits fetch status=${response.status} hasData=${hasData}`); + + return hasData ? result : null; + } catch { + return null; + } + } + + // Pushes a (possibly cached) usage-limits snapshot to the webview, throttled to at + // most one real fetch every 5 minutes (fork-issue-35). Gated on subscription type: API and + // OpenCredits users have no session/weekly limits to show. + private async _maybeSendUsageLimits(): Promise { + if (!this._subscriptionType) { + return; + } + + if (Date.now() - this._usageLastFetchMs < 300000) { + if (this._usageLimits) { + this._postMessage({ type: 'usageLimits', data: this._usageLimits }); + } + return; + } + + this._usageLastFetchMs = Date.now(); + const u = await this._fetchUsageLimits(); + if (u) { + this._usageLimits = u; + } + + if (this._usageLimits) { + this._postMessage({ type: 'usageLimits', data: this._usageLimits }); + } + } + // Update the model configuration for the local router private _updateLocalRouterModel(model: string, tierModels?: { sonnet: string; opus: string; haiku: string }): void { setModelConfig({ @@ -3877,106 +4271,194 @@ class ClaudeChatProvider { } } - private async _openDiffByMessageIndex(messageIndex: number) { - try { - const message = this._currentConversation[messageIndex]; - if (!message) { - console.error('Message not found at index:', messageIndex); - return; + // fork-issue-38 turn diff: walks _currentConversation backwards from messageIndex (inclusive) + // to the nearest showRestoreOption entry, which is the checkpoint commit made right + // before this turn's user message (_createBackupCommit runs before every turn). Works + // both live and after a history reload -- unlike _commits (fork-issue-50), _currentConversation + // is exactly what gets persisted/reloaded, so the index lines up either way. + private _findTurnBaselineSha(messageIndex: number): string | undefined { + const start = Math.min(messageIndex, this._currentConversation.length - 1); + for (let i = start; i >= 0; i--) { + const entry = this._currentConversation[i]; + if (entry?.messageType === 'showRestoreOption' && entry.data?.sha) { + return entry.data.sha; } + } + return undefined; + } - const data = message.data; - const toolName = data.toolName; - const rawInput = data.rawInput; - let filePath = rawInput?.file_path || ''; - let oldContent = ''; - let newContent = ''; + // Shared failure path for every way _openTurnDiff can come up short (no checkpoint, + // git error, file outside the workspace/not WSL-mappable, too large/binary baseline): + // never fail silently for a real user click -- tell them why there's no diff and + // open the real file instead so a click is never a dead end. `trigger` + // tells 'manual' (webview "Open Diff" button, a deliberate user + // action -- toast + focus is fine) apart from 'auto' (post tool_result auto-open, + // see the Edit/MultiEdit/Write handler above): Claude sessions routinely edit files + // outside the workspace (scratchpad, ~/.claude memory, etc.), so failing here is + // the ordinary case for auto-open, not something worth a toast/focus-stealing + // showTextDocument for -- it only gets a permLog line for field diagnostics. + private async _openTurnDiffFallback(filePath: string, trigger: 'manual' | 'auto', reason: string): Promise { + if (trigger === 'auto') { + // First line only: git error messages can be multi-line and would break the + // one-line-per-entry perm-log format. Basename only -- the perm-log file is + // unrotated plaintext, so the full path isn't worth leaking for a diagnostic line. + // reason needs the same treatment: _resolveTurnDiffBaseline wraps raw execFile + // failures, whose message starts with the full command line (absolute + // backup-repo path, workspace-relative file path included) -- collapse any + // path-looking token down to its basename before it hits the log. + const redactedReason = reason.split('\n')[0].replace(/[^\s"']*[\\/][^\s"']*/g, (token) => path.basename(token)); + this._permLog(`[turndiff] auto skip reason=${redactedReason} file=${path.basename(filePath)}`); + return; + } + vscode.window.showInformationMessage(`Claude Code Chat: ${reason}; showing the file instead.`); + try { + await vscode.window.showTextDocument(vscode.Uri.file(filePath)); + } catch (error) { + console.error('Failed to open fallback file for turn diff:', error); + } + } - if (!filePath) { - console.error('No file path found for message at index:', messageIndex); - return; + // Distinguishes a genuinely new file (nothing existed at the + // checkpoint yet) from a file that's simply gitignored in the shadow backup repo + // (_createBackupCommit's `add -A` silently skips ignored paths) -- both produce the + // identical `does not exist in ` from `git show`, but only the first should + // get an empty "new file" baseline. --git-dir/--work-tree matches the existing + // checkpoint calls (_initializeBackupRepo/_createBackupCommit above). `check-ignore + // -q` exits 0 when the path IS ignored; per git's own docs it exits 1 (an execFile + // rejection, not a bug) when it's NOT ignored, which is the common case. + private async _isPathIgnoredInBackupRepo(backupRepoPath: string, workTreePath: string, relPath: string): Promise { + try { + // cwd pinned to the work tree: git resolves the relative path against the + // process cwd's prefix inside the work tree, so an unpinned cwd would make + // anchored .gitignore entries (like /out/) match or miss depending on where + // the extension host happens to run. + await execFile('git', ['--git-dir', backupRepoPath, '--work-tree', workTreePath, 'check-ignore', '-q', '--', relPath], { cwd: workTreePath }); + return true; + } catch (error: any) { + if (error?.code === 1) { + return false; } + // Anything else (git missing, fatal error, ...): can't confirm either way, + // so let the caller fail closed instead of risking a wrong empty baseline. + throw error; + } + } - // Read current file from disk - this is the "before" state since edit hasn't been applied yet - try { - const fileUri = vscode.Uri.file(filePath); - const fileData = await vscode.workspace.fs.readFile(fileUri); - oldContent = Buffer.from(fileData).toString('utf8'); - } catch { - // File might not exist yet (for Write creating new file) - oldContent = ''; - } - - // Compute "after" state by applying the edit to current file - if (toolName === 'Edit' && rawInput?.old_string && rawInput?.new_string) { - newContent = oldContent.replace(rawInput.old_string, rawInput.new_string); - } else if (toolName === 'MultiEdit' && rawInput?.edits) { - newContent = oldContent; - for (const edit of rawInput.edits) { - if (edit.old_string && edit.new_string) { - newContent = newContent.replace(edit.old_string, edit.new_string); - } + // Reads the checkpointed blob for relPath at sha from the shadow backup repo and + // returns it as a UTF-8 string, or throws when there's nothing sane to show. Shared + // by _openTurnDiff (manual/auto "open diff", already knows workspaceFolder/sha from + // the live call) and resolveTurnDiffBaselineForProvider (a DiffContentProvider + // cache miss) so both go through the identical git-show + + // classification + guards, and BOM-stripping only has to happen in one place. + // Never returns a silently-wrong baseline -- callers each + // decide what "failure" means for their UI (fallback toast/permLog vs. a generic + // VS Code tab error). + private async _resolveTurnDiffBaseline(backupRepoPath: string, workTreePath: string, sha: string, relPath: string): Promise { + let content: Buffer; + try { + const { stdout } = await execFile( + 'git', + ['--git-dir', backupRepoPath, 'show', `${sha}:${relPath}`], + { encoding: 'buffer', maxBuffer: TURN_DIFF_MAX_EXEC_BYTES } + ); + content = stdout; + } catch (error: any) { + const stderrText = Buffer.isBuffer(error?.stderr) ? error.stderr.toString('utf8') : String(error?.stderr || error?.message || ''); + // `exists on disk, but not in ` is deliberately NOT + // treated as "new file" below. Best effort only: whether git emits that + // message (vs. plain `does not exist in`) depends on the process cwd seeing + // the on-disk file, so e.g. a case-only mismatch (Src/ vs src/) is not + // reliably caught -- but when the message does appear, an empty baseline + // would silently lie, so it must go down the failure path. + if (/does not exist in/i.test(stderrText)) { + let ignored: boolean; + try { + ignored = await this._isPathIgnoredInBackupRepo(backupRepoPath, workTreePath, relPath); + } catch (ignoreError: any) { + throw new Error(`failed to read the checkpoint (${ignoreError.message})`); } - } else if (toolName === 'Write' && rawInput?.content) { - newContent = rawInput.content; - } - - if (oldContent !== newContent) { - await this._openDiffEditor(oldContent, newContent, filePath); + if (ignored) { + throw new Error('file is not tracked by checkpoints (excluded via .gitignore)'); + } + // Genuinely new file: nothing existed at the checkpoint, so the + // baseline is empty and the whole file shows as added. + content = Buffer.alloc(0); } else { - vscode.window.showInformationMessage('No changes to show - the edit may have already been applied.'); + throw new Error(`failed to read the checkpoint (${error.message})`); } - } catch (error) { - console.error('Error opening diff by message index:', error); } + + if (content.length > TURN_DIFF_MAX_DISPLAY_BYTES || isBinaryContent(content)) { + throw new Error('file is too large or binary to diff'); + } + + // VS Code strips the BOM from the real file's text model, keep both sides + // consistent. + return content.toString('utf8').replace(/^\uFEFF/, ''); } - private async _openDiffEditor(oldContent: string, newContent: string, filePath: string) { - try { - // oldContent and newContent are now full file contents passed from the webview - const baseName = path.basename(filePath); - const timestamp = Date.now(); + // Public seam for DiffContentProvider's injected resolver (wired up in + // activate()) -- reuses the same backup-repo baseline lookup + // _openTurnDiff uses, keyed only by the (sha, relPath) already encoded in a + // claude-diff tab's own URI, so a tab restored via "Reopen Closed Editor" or a VS + // Code restart can resolve itself without any per-turn state. Errors are left for + // the caller (DiffContentProvider) to fold into its single generic tab error. + public async resolveTurnDiffBaselineForProvider(sha: string, relPath: string): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder || !this._backupRepoPath) { + throw new Error('no workspace or checkpoint repository available'); + } + return this._resolveTurnDiffBaseline(this._backupRepoPath, workspaceFolder.uri.fsPath, sha, relPath); + } - // Create unique paths for the virtual documents - const oldPath = `/${timestamp}/old/${baseName}`; - const newPath = `/${timestamp}/new/${baseName}`; + // fork-issue-38: opens a real VS Code diff -- the checkpoint from right before this turn + // (left, read-only virtual document served from the shadow backup repo via + // DiffContentProvider) against the actual file on disk (right, live/editable, so + // later edits in the same turn keep showing up in the same tab). Shared by the + // manual "Open Diff" button and the auto-open after a successful tool_result; + // `trigger` picks which of the two _openTurnDiffFallback behaves as. + private async _openTurnDiff(filePath: string, messageIndex: number, trigger: 'manual' | 'auto'): Promise { + const resolvedPath = mapWslPathToWindows(filePath); - // Store content in the global store for the content provider - diffContentStore.set(oldPath, oldContent); - diffContentStore.set(newPath, newContent); + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder || !this._backupRepoPath) { + await this._openTurnDiffFallback(resolvedPath, trigger, 'no workspace or checkpoint repository available'); + return; + } - // Create URIs with our custom scheme - const oldUri = vscode.Uri.parse(`claude-diff:${oldPath}`); - const newUri = vscode.Uri.parse(`claude-diff:${newPath}`); + const sha = this._findTurnBaselineSha(messageIndex); + if (!sha) { + await this._openTurnDiffFallback(resolvedPath, trigger, 'no checkpoint found for this turn'); + return; + } - // Ensure side-by-side diff mode is enabled - const diffConfig = vscode.workspace.getConfiguration('diffEditor'); - const wasInlineMode = diffConfig.get('renderSideBySide') === false; - if (wasInlineMode) { - await diffConfig.update('renderSideBySide', true, vscode.ConfigurationTarget.Global); - } + // toWorkspaceRelativePath also returns undefined when resolvedPath IS the + // workspace root itself -- a directory has no checkpointed + // blob to diff against, so it's handled the same as "outside the workspace". + const relPath = toWorkspaceRelativePath(resolvedPath, workspaceFolder.uri.fsPath); + if (relPath === undefined) { + await this._openTurnDiffFallback(resolvedPath, trigger, 'file is outside the workspace'); + return; + } - // Open diff editor - await vscode.commands.executeCommand('vscode.diff', oldUri, newUri, `${baseName} (Changes)`); + let content: string; + try { + content = await this._resolveTurnDiffBaseline(this._backupRepoPath, workspaceFolder.uri.fsPath, sha, relPath); + } catch (error: any) { + await this._openTurnDiffFallback(resolvedPath, trigger, error.message); + return; + } - // Clean up stored content when documents are closed - const closeListener = vscode.workspace.onDidCloseTextDocument((doc) => { - if (doc.uri.toString() === oldUri.toString()) { - diffContentStore.delete(oldPath); - } - if (doc.uri.toString() === newUri.toString()) { - diffContentStore.delete(newPath); - } - // Dispose listener when both are cleaned up - if (!diffContentStore.has(oldPath) && !diffContentStore.has(newPath)) { - closeListener.dispose(); - } - }); + try { + const uriParts = buildTurnDiffUriParts(sha, relPath); + const baselineUri = vscode.Uri.from(uriParts); + cacheTurnDiffContent(turnDiffCacheKey(uriParts), content); - this._disposables.push(closeListener); - } catch (error) { - vscode.window.showErrorMessage(`Failed to open diff editor: ${error}`); - console.error('Error opening diff editor:', error); + const rightUri = vscode.Uri.file(resolvedPath); + const title = `${path.basename(resolvedPath)} (Turn Diff)`; + await vscode.commands.executeCommand('vscode.diff', baselineUri, rightUri, title, { preserveFocus: true }); + } catch (error: any) { + await this._openTurnDiffFallback(resolvedPath, trigger, `failed to open the diff view (${error.message})`); } } diff --git a/src/restore-commit-utils.ts b/src/restore-commit-utils.ts new file mode 100644 index 0000000..5874597 --- /dev/null +++ b/src/restore-commit-utils.ts @@ -0,0 +1,47 @@ +// Pure helpers for the fork-issue-50 checkpoint-restore fix: after a history load switches +// conversations, extension.ts's in-memory _commits list is cleared (see +// _loadConversationHistory) even though the replayed showRestoreOption messages still +// show a working Restore button for a checkpoint that still exists in the shadow +// backup repo. No vscode import, so these run under plain mocha -- extension.ts owns +// all the side effects (the git cat-file -e existence check, the actual restore). + +export interface RestoreCommitInfo { + id: string; + sha: string; + message: string; + timestamp: string; +} + +// Every commit sha this extension itself ever produces comes straight from `git +// rev-parse HEAD` (trimmed), always plain lowercase hex -- 40 chars for SHA-1, 64 for +// SHA-256. commitSha can now also arrive here rehydrated from a loaded conversation's +// showRestoreOption entry, i.e. from persisted JSON on disk rather than only that +// same-session git output, so this must be checked before commitSha reaches any git +// command. 7 is the shortest abbreviation git itself would ever treat as unambiguous. +export function isValidCommitSha(sha: string): boolean { + return /^[0-9a-f]{7,64}$/i.test(sha); +} + +// Recovers a commit's display info (message/timestamp for the restore toasts) from the +// matching showRestoreOption entry replayed into _currentConversation, for a sha that +// _commits no longer knows about (fork-issue-50: cleared by a history load that switched +// conversations). Only called once the caller has independently confirmed sha still +// exists in the backup repo -- this never claims a sha exists, only recovers its +// metadata, and falls back to a minimal placeholder built from the sha itself when no +// matching entry is found (e.g. an older saved conversation from before this field +// existed). +export function findRehydratedCommitInfo( + messages: ReadonlyArray<{ messageType: string, data: any }>, + sha: string +): RestoreCommitInfo { + const entry = messages.find(m => m.messageType === 'showRestoreOption' && m.data?.sha === sha); + if (entry && typeof entry.data?.message === 'string' && typeof entry.data?.timestamp === 'string') { + return { + id: typeof entry.data.id === 'string' ? entry.data.id : `commit-${sha}`, + sha, + message: entry.data.message, + timestamp: entry.data.timestamp + }; + } + return { id: `commit-${sha}`, sha, message: sha, timestamp: new Date().toISOString() }; +} diff --git a/src/script.ts b/src/script.ts index 4c949e2..3085ab5 100644 --- a/src/script.ts +++ b/src/script.ts @@ -79,20 +79,19 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt let planModeEnabled = false; let thinkingModeEnabled = false; 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 } let attachedImages = []; // Array of { filePath, previewUri } - // Open diff using stored data (no file read needed) - function openDiffEditor() { - if (lastPendingEditData) { - vscode.postMessage({ - type: 'openDiff', - filePath: lastPendingEditData.filePath, - oldContent: lastPendingEditData.oldContent, - newContent: lastPendingEditData.newContent - }); - } + // fork-issue-38: request a real VS Code diff (checkpoint-before-turn vs. the live file) for + // one Edit/MultiEdit/Write message. filePath/messageIndex come from the clicked + // button's own dataset (see generateUnifiedDiffHTML/formatMultiEditToolDiff), not + // a shared pending-edit slot, so the button keeps working after tool_result and + // after a history reload. + function requestTurnDiff(filePath, messageIndex) { + vscode.postMessage({ + type: 'openTurnDiff', + filePath: filePath, + messageIndex: parseInt(messageIndex, 10) + }); } function shouldAutoScroll(messagesDiv) { @@ -189,6 +188,20 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt messageDiv.appendChild(yoloSuggestion); } + // Check if this is an output token limit error and offer a shortcut to + // raise CLAUDE_CODE_MAX_OUTPUT_TOKENS via the setting (fork-issue-42, upstream #150) + if ((type === 'error' || type === 'claude') && isOutputTokenLimitError(content)) { + const tokenLimitSuggestion = document.createElement('div'); + tokenLimitSuggestion.className = 'yolo-suggestion'; + tokenLimitSuggestion.innerHTML = \` +
+ 💡 Claude's response exceeded the output token limit. You can raise the limit in settings. +
+ + \`; + messageDiv.appendChild(tokenLimitSuggestion); + } + messagesDiv.appendChild(messageDiv); moveProcessingIndicatorToLast(); scrollToBottomIfNeeded(messagesDiv, shouldScroll); @@ -244,50 +257,18 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt // Format raw input with expandable content for long values // Use diff format for Edit, MultiEdit, and Write tools, regular format for others if (data.toolName === 'Edit' || data.toolName === 'MultiEdit' || data.toolName === 'Write') { - // Only show Open Diff button if we have fileContentBefore (live session, not reload) - const showButton = data.fileContentBefore !== undefined && data.messageIndex >= 0; - - // Hide any existing pending edit button before showing new one - if (showButton && lastPendingEditIndex >= 0) { - const prevContent = document.querySelector('[data-edit-message-index="' + lastPendingEditIndex + '"]'); - if (prevContent) { - const btn = prevContent.querySelector('.diff-open-btn'); - if (btn) btn.style.display = 'none'; - } - lastPendingEditData = null; - } - - if (showButton) { - lastPendingEditIndex = data.messageIndex; - contentDiv.setAttribute('data-edit-message-index', data.messageIndex); - - // Compute and store diff data for when button is clicked - const oldContent = data.fileContentBefore || ''; - let newContent = oldContent; - if (data.toolName === 'Edit' && data.rawInput.old_string && data.rawInput.new_string) { - newContent = oldContent.replace(data.rawInput.old_string, data.rawInput.new_string); - } else if (data.toolName === 'MultiEdit' && data.rawInput.edits) { - for (const edit of data.rawInput.edits) { - if (edit.old_string && edit.new_string) { - newContent = newContent.replace(edit.old_string, edit.new_string); - } - } - } else if (data.toolName === 'Write' && data.rawInput.content) { - newContent = data.rawInput.content; - } - lastPendingEditData = { - filePath: data.rawInput.file_path, - oldContent: oldContent, - newContent: newContent - }; - } + // fork-issue-38: the Open Diff button stays visible after tool_result and after a + // history reload -- it only needs a valid messageIndex (used to look up + // the pre-turn checkpoint on the host side), not the live-only, + // optimistic fileContentBefore read. + const showButton = data.messageIndex >= 0; if (data.toolName === 'Edit') { - contentDiv.innerHTML = formatEditToolDiff(data.rawInput, data.fileContentBefore, showButton, data.startLine); + contentDiv.innerHTML = formatEditToolDiff(data.rawInput, data.fileContentBefore, showButton, data.startLine, data.messageIndex); } else if (data.toolName === 'MultiEdit') { - contentDiv.innerHTML = formatMultiEditToolDiff(data.rawInput, data.fileContentBefore, showButton, data.startLines); + contentDiv.innerHTML = formatMultiEditToolDiff(data.rawInput, data.fileContentBefore, showButton, data.startLines, data.messageIndex); } else { - contentDiv.innerHTML = formatWriteToolDiff(data.rawInput, data.fileContentBefore, showButton); + contentDiv.innerHTML = formatWriteToolDiff(data.rawInput, data.fileContentBefore, showButton, data.messageIndex); } } else if (data.toolName === 'ExitPlanMode' && data.rawInput) { contentDiv.innerHTML = formatPlanOutput(data.rawInput); @@ -350,20 +331,9 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt const messagesDiv = document.getElementById('messages'); const shouldScroll = shouldAutoScroll(messagesDiv); - // When result comes in for Edit/MultiEdit/Write, hide the Open Diff button on the request - // since the edit has now been applied (no longer pending) - if (lastPendingEditIndex >= 0) { - // Find and hide the button on the corresponding toolUse - const toolUseContent = document.querySelector('[data-edit-message-index="' + lastPendingEditIndex + '"]'); - if (toolUseContent) { - const btn = toolUseContent.querySelector('.diff-open-btn'); - if (btn) { - btn.style.display = 'none'; - } - } - lastPendingEditIndex = -1; - lastPendingEditData = null; - } + // fork-issue-38: the Open Diff button on the request no longer gets hidden when its + // result arrives -- it stays available (and auto-open, if enabled, has + // already opened/updated the same turn diff by the time this runs). // For Read and TodoWrite tools, just hide loading state (no result message needed) if ((data.toolName === 'Read' || data.toolName === 'TodoWrite') && !data.isError) { @@ -606,7 +576,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt // Generate unified diff HTML with line numbers // showButton controls whether to show the "Open Diff" button - function generateUnifiedDiffHTML(oldString, newString, filePath, startLine = 1, showButton = false) { + function generateUnifiedDiffHTML(oldString, newString, filePath, startLine = 1, showButton = false, messageIndex = -1) { const oldLines = oldString.split('\\n'); const newLines = newString.split('\\n'); const diff = computeLineDiff(oldLines, newLines); @@ -709,7 +679,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt html += '
'; html += 'Summary: ' + summary + ''; if (showButton) { - html += ''; } @@ -719,7 +689,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt return html; } - function formatEditToolDiff(input, fileContentBefore, showButton = false, providedStartLine = null) { + function formatEditToolDiff(input, fileContentBefore, showButton = false, providedStartLine = null, messageIndex = -1) { if (!input || typeof input !== 'object') { return formatToolInputUI(input); } @@ -740,10 +710,10 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt } } - return generateUnifiedDiffHTML(input.old_string, input.new_string, input.file_path, startLine, showButton); + return generateUnifiedDiffHTML(input.old_string, input.new_string, input.file_path, startLine, showButton, messageIndex); } - function formatMultiEditToolDiff(input, fileContentBefore, showButton = false, providedStartLines = null) { + function formatMultiEditToolDiff(input, fileContentBefore, showButton = false, providedStartLines = null, messageIndex = -1) { if (!input || typeof input !== 'object') { return formatToolInputUI(input); } @@ -808,7 +778,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt html += '
'; html += 'Summary: ' + input.edits.length + ' edit' + (input.edits.length > 1 ? 's' : '') + ''; if (showButton) { - html += ''; } @@ -817,7 +787,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt return html; } - function formatWriteToolDiff(input, fileContentBefore, showButton = false) { + function formatWriteToolDiff(input, fileContentBefore, showButton = false, messageIndex = -1) { if (!input || typeof input !== 'object') { return formatToolInputUI(input); } @@ -831,7 +801,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt const fullFileBefore = fileContentBefore || ''; // Show full content as added lines (new file or replacement) - return generateUnifiedDiffHTML(fullFileBefore, input.content, input.file_path, 1, showButton); + return generateUnifiedDiffHTML(fullFileBefore, input.content, input.file_path, 1, showButton, messageIndex); } function escapeHtml(text) { @@ -1029,6 +999,8 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt let totalCost = 0; let totalTokensInput = 0; let totalTokensOutput = 0; + let currentContextTokens = 0; + let latestUsage = null; let requestCount = 0; let isProcessing = false; let requestStartTime = null; @@ -1062,6 +1034,78 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt vscode.postMessage({ type: 'viewUsage', usageType: usageType }); } + // Approximate context-window size per model, used to turn currentContextTokens + // into a percentage for the status bar (fork-issue-27). Best-effort approximation, not the + // model's authoritative limit — router models use context_length from the + // recommended-models catalog. 'default' and unknown models fall back to a + // conservative 200K (underestimating only warns early). + function getContextWindow(model) { + const nativeWindows = { opus: 200000, sonnet: 200000, 'default': 200000 }; + if (nativeWindows[model]) { + return nativeWindows[model]; + } + const recommended = (window.__recommendedModels || []).find(function(m) { return m.id === model; }); + return (recommended && recommended.context_length) || 200000; + } + + // Builds the "Ctx 12,345 / ~200K (62%)" status-bar fragment, with a warning/ + // critical class once usage crosses 80%/95% (fork-issue-27). Empty string when there's no + // context reading yet, so the status line looks exactly like before in that case. + function getContextIndicatorHtml() { + if (!currentContextTokens || currentContextTokens <= 0) { + return ''; + } + const win = getContextWindow(currentModel); + const pct = win > 0 ? Math.round((currentContextTokens / win) * 100) : 0; + const ctxClass = pct >= 95 ? ' class="ctx-crit"' : pct >= 80 ? ' class="ctx-warn"' : ''; + const winStr = win >= 1000000 ? \`\${Math.round(win / 1000000)}M\` : \`\${Math.round(win / 1000)}K\`; + return \` • Ctx \${currentContextTokens.toLocaleString()} / ~\${winStr} (\${pct}%)\`; + } + + // Builds the "5h 42% · Week 18% · Opus 30%" status-bar fragment (fork-issue-35), same + // structure/escaping as the fork-issue-27 Ctx indicator above. Opus/Sonnet are the + // per-model weekly buckets (seven_day_opus/seven_day_sonnet); each renders + // only when the account's usage data actually includes it. Empty string when + // there's no usage data yet. + function getUsageIndicatorHtml() { + if (!latestUsage) return ''; + const fiveHour = latestUsage.fiveHour; + const week = latestUsage.week; + const sevenDayOpus = latestUsage.sevenDayOpus; + const sevenDaySonnet = latestUsage.sevenDaySonnet; + if (!fiveHour && !week && !sevenDayOpus && !sevenDaySonnet) return ''; + + const fiveHourPct = fiveHour ? Math.round(fiveHour.pct) : undefined; + const weekPct = week ? Math.round(week.pct) : undefined; + const sevenDayOpusPct = sevenDayOpus ? Math.round(sevenDayOpus.pct) : undefined; + const sevenDaySonnetPct = sevenDaySonnet ? Math.round(sevenDaySonnet.pct) : undefined; + const maxPct = Math.max(fiveHourPct || 0, weekPct || 0, sevenDayOpusPct || 0, sevenDaySonnetPct || 0); + const usageClass = maxPct >= 95 ? ' class="ctx-crit"' : maxPct >= 80 ? ' class="ctx-warn"' : ''; + + const titleParts = []; + if (fiveHour && fiveHour.resetsAt) { + titleParts.push(\`5h resets \${new Date(fiveHour.resetsAt * 1000).toLocaleTimeString()}\`); + } + if (week && week.resetsAt) { + titleParts.push(\`Week resets \${new Date(week.resetsAt * 1000).toLocaleString()}\`); + } + if (sevenDayOpus && sevenDayOpus.resetsAt) { + titleParts.push(\`Opus resets \${new Date(sevenDayOpus.resetsAt * 1000).toLocaleString()}\`); + } + if (sevenDaySonnet && sevenDaySonnet.resetsAt) { + titleParts.push(\`Sonnet resets \${new Date(sevenDaySonnet.resetsAt * 1000).toLocaleString()}\`); + } + const titleAttr = titleParts.length ? \` title="\${titleParts.join(' · ')}"\` : ''; + + const fiveHourStr = fiveHour ? \`5h \${fiveHourPct}%\` : ''; + const weekStr = week ? \`Week \${weekPct}%\` : ''; + const sevenDayOpusStr = sevenDayOpus ? \`Opus \${sevenDayOpusPct}%\` : ''; + const sevenDaySonnetStr = sevenDaySonnet ? \`Sonnet \${sevenDaySonnetPct}%\` : ''; + const text = [fiveHourStr, weekStr, sevenDayOpusStr, sevenDaySonnetStr].filter(Boolean).join(' · '); + + return \` • \${text}\`; + } + function updateStatusWithTotals() { if (isProcessing) { // While processing, show elapsed time (and tokens for non-OpenCredits users) @@ -1076,13 +1120,11 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt // OpenCredits users: don't show tokens, just elapsed time statusText = \`Processing\${elapsedStr ? \` • \${elapsedStr}\` : ''}\`; } else { - // Regular users: show tokens and elapsed time - const totalTokens = totalTokensInput + totalTokensOutput; - const tokensStr = totalTokens > 0 ? - \`\${totalTokens.toLocaleString()} tokens\` : '0 tokens'; - statusText = \`Processing • \${tokensStr}\${elapsedStr ? \` • \${elapsedStr}\` : ''}\`; + // Regular users: show context usage and elapsed time (fork-issue-27 — the + // context indicator replaced the old cumulative token sum here) + statusText = \`Processing\${getContextIndicatorHtml()}\${getUsageIndicatorHtml()}\${elapsedStr ? \` • \${elapsedStr}\` : ''}\`; } - updateStatus(statusText, 'processing'); + updateStatusHtml(statusText, 'processing'); } else { // When ready, show full info let usageStr; @@ -1113,12 +1155,10 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt const requestStr = requestCount > 0 ? \`\${requestCount} requests\` : ''; statusText = \`Ready\${requestStr ? \` • \${requestStr}\` : ''} • \${usageStr}\`; } else { - // Regular users: show tokens, requests, and usage - const totalTokens = totalTokensInput + totalTokensOutput; - const tokensStr = totalTokens > 0 ? - \`\${totalTokens.toLocaleString()} tokens\` : '0 tokens'; + // Regular users: show context usage, requests, and usage (fork-issue-27 — the + // context indicator replaced the old cumulative token sum here) const requestStr = requestCount > 0 ? \`\${requestCount} requests\` : ''; - statusText = \`Ready • \${tokensStr}\${requestStr ? \` • \${requestStr}\` : ''} • \${usageStr}\`; + statusText = \`Ready\${getContextIndicatorHtml()}\${getUsageIndicatorHtml()}\${requestStr ? \` • \${requestStr}\` : ''} • \${usageStr}\`; } updateStatusHtml(statusText, 'ready'); } @@ -1408,6 +1448,13 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt ); } + function isOutputTokenLimitError(content) { + // Require the "API Error:" prefix so this only fires on the actual CLI + // error text, not on ordinary conversation that happens to mention the + // output token maximum (e.g. the user asking about this very feature). + return /API Error:.*output token maximum/i.test(content); + } + function enableYoloMode() { sendStats('YOLO mode enabled'); @@ -1427,6 +1474,14 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt } } + function openMaxOutputTokensSettings() { + sendStats('Output token limit settings opened'); + + vscode.postMessage({ + type: 'openMaxOutputTokensSettings' + }); + } + function hideMCPModal() { document.getElementById('mcpModal').style.display = 'none'; hideAddServerForm(); @@ -3685,7 +3740,8 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt // Update token totals in real-time totalTokensInput = message.data.totalTokensInput || 0; totalTokensOutput = message.data.totalTokensOutput || 0; - + currentContextTokens = message.data.currentContextTokens || currentContextTokens; + // Update status bar immediately updateStatusWithTotals(); @@ -3730,6 +3786,12 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt updateStatusWithTotals(); break; + case 'usageLimits': + // Store session-usage / weekly-limit snapshot (fork-issue-35) and refresh the status bar + latestUsage = message.data || null; + updateStatusWithTotals(); + break; + case 'modelSwitching': // Model is being switched (router restarting) currentModel = message.model; @@ -3757,6 +3819,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt totalCost = 0; totalTokensInput = 0; totalTokensOutput = 0; + currentContextTokens = 0; requestCount = 0; updateStatusWithTotals(); break; @@ -3771,6 +3834,7 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt // Reset token counts since conversation was compacted totalTokensInput = 0; totalTokensOutput = 0; + currentContextTokens = 0; updateStatusWithTotals(); const preTokens = message.data.preTokens ? message.data.preTokens.toLocaleString() : 'unknown'; @@ -4862,7 +4926,27 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt const wslClaudePath = document.getElementById('wsl-claude-path').value; const yoloMode = document.getElementById('yolo-mode').checked; const executablePath = document.getElementById('executable-path').value; + // fork-issue-42/fork-issue-44 settings modal follow-up: keep in sync with the manifest bounds + // (advanced.maxOutputTokens >= 0, ui.fontSize 0 or 6-72). + const maxOutputTokensEl = document.getElementById('max-output-tokens'); + let maxOutputTokens = parseInt(maxOutputTokensEl.value, 10); + if (!Number.isFinite(maxOutputTokens) || maxOutputTokens < 0) { + maxOutputTokens = 0; + } const useRouter = document.getElementById('use-router')?.checked || false; + // fork-issue-38: auto-open a turn diff after a successful Edit/MultiEdit/Write + const diffAutoOpen = document.getElementById('diff-auto-open').checked; + const chatFontFamilyEl = document.getElementById('chat-font-family'); + const chatFontFamily = chatFontFamilyEl.value; + const chatFontSizeEl = document.getElementById('chat-font-size'); + let chatFontSize = parseInt(chatFontSizeEl.value, 10); + if (!Number.isFinite(chatFontSize) || chatFontSize < 0) { + chatFontSize = 0; + } else if (chatFontSize > 0 && chatFontSize < 6) { + chatFontSize = 6; + } else if (chatFontSize > 72) { + chatFontSize = 72; + } // Collect environment variables from key-value UI const envVariables = getEnvVariablesFromUI(); @@ -4892,18 +4976,35 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt has_custom_envs: Object.keys(envVariables).length > 0, has_custom_executable: !!executablePath }); + const settingsToSend = { + 'wsl.enabled': wslEnabled, + 'wsl.distro': wslDistro || 'Ubuntu', + 'wsl.nodePath': wslNodePath, + 'wsl.claudePath': wslClaudePath || '/usr/local/bin/claude', + 'permissions.yoloMode': yoloMode, + 'executable.path': executablePath, + 'environment.variables': envVariables, + 'router.enabled': useRouter, + 'diff.autoOpen': diffAutoOpen + }; + // The settings modal's settingsData roundtrip is what fills these three fields in; + // callers that trigger updateSettings() without ever opening the modal (e.g. + // enableYoloMode() from the permission-error banner) find them at their untouched '' + // default, so only send them once they actually hold a value -- otherwise + // parseInt('') -> NaN gets clamped to 0 above and would silently zero out a real + // advanced.maxOutputTokens/ui.fontSize setting. + if (maxOutputTokensEl.value !== '') { + settingsToSend['advanced.maxOutputTokens'] = maxOutputTokens; + } + if (chatFontFamilyEl.value !== '') { + settingsToSend['ui.fontFamily'] = chatFontFamily; + } + if (chatFontSizeEl.value !== '') { + settingsToSend['ui.fontSize'] = chatFontSize; + } vscode.postMessage({ type: 'updateSettings', - settings: { - 'wsl.enabled': wslEnabled, - 'wsl.distro': wslDistro || 'Ubuntu', - 'wsl.nodePath': wslNodePath, - 'wsl.claudePath': wslClaudePath || '/usr/local/bin/claude', - 'permissions.yoloMode': yoloMode, - 'executable.path': executablePath, - 'environment.variables': envVariables, - 'router.enabled': useRouter - } + settings: settingsToSend }); } @@ -5159,6 +5260,32 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt }); } else if (message.type === 'settingsData') { // Update UI with current settings + // fork-issue-38: auto-open a turn diff after a successful Edit/MultiEdit/Write + document.getElementById('diff-auto-open').checked = message.data['diff.autoOpen'] !== false; + // Custom chat font (fork-issue-44): applied via CSS custom properties only (never + // string-interpolated into CSS/HTML) so an arbitrary fontFamily value + // can't inject markup or styles. Empty/0 removes the property so the + // var() fallback in ui-styles.ts restores the editor default. + const chatFontFamily = message.data['ui.fontFamily']; + if (chatFontFamily && String(chatFontFamily).trim()) { + document.documentElement.style.setProperty('--chat-font-family', chatFontFamily); + } else { + document.documentElement.style.removeProperty('--chat-font-family'); + } + const chatFontSize = Number(message.data['ui.fontSize']) || 0; + const clampedChatFontSize = chatFontSize > 0 ? Math.min(72, Math.max(6, chatFontSize)) : 0; + if (clampedChatFontSize > 0) { + document.documentElement.style.setProperty('--chat-font-size', clampedChatFontSize + 'px'); + } else { + document.documentElement.style.removeProperty('--chat-font-size'); + } + // fork-issue-44 settings modal: reflect the persisted values in the Appearance fields + // (clamped, so the field always shows the size that is actually applied) + document.getElementById('chat-font-family').value = chatFontFamily || ''; + document.getElementById('chat-font-size').value = clampedChatFontSize; + // Re-measure the input's inline height for the new font size, otherwise + // it keeps the old (possibly too small) height until the next keystroke. + adjustTextareaHeight(); const thinkingIntensity = message.data['thinking.intensity'] || 'think'; const intensityValues = ['think', 'think-hard', 'think-harder', 'ultrathink']; const sliderValue = intensityValues.indexOf(thinkingIntensity); @@ -5193,6 +5320,8 @@ const getScript = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'htt // Update Customize Claude Command settings document.getElementById('executable-path').value = message.data['executable.path'] || ''; + + document.getElementById('max-output-tokens').value = message.data['advanced.maxOutputTokens'] || 0; renderEnvVariables(message.data['environment.variables'] || {}); // Detect OpenCredits and envs disabled state diff --git a/src/settings-batch.ts b/src/settings-batch.ts new file mode 100644 index 0000000..ae13b00 --- /dev/null +++ b/src/settings-batch.ts @@ -0,0 +1,66 @@ +// Pure batch-update helper for the fork-issue-56 fix: extension.ts's _updateSettings used to run +// the whole settings batch from the webview through a single loop wrapped in one +// try/catch -- if config.update() threw for one key (e.g. a setting not yet registered +// right after a version bump), the loop broke and every subsequent key in the same +// batch silently never got saved. In the order the webview's updateSettings() message +// sends them, a rejection on e.g. advanced.maxOutputTokens would have silently dropped +// every key after it in the same batch (environment.variables, router.enabled, +// diff.autoOpen, ui.fontFamily, ui.fontSize) without any indication in the UI. This +// module owns only the per-key try/catch + result collection; extension.ts still owns +// every side effect (the actual +// vscode.workspace config.update() call, permissions.yoloMode's workspace-then-global +// fallback, the summary error message) via the injected updateSetting +// callback -- no vscode import here, so this runs under plain mocha, same pattern as +// restore-commit-utils. Run with `npm run test:settings-batch`. + +export interface SettingUpdateFailure { + key: string; + message: string; +} + +export interface SettingsBatchResult { + applied: string[]; + failures: SettingUpdateFailure[]; +} + +// Turns whatever a rejected updateSetting() call threw into a plain string, the same way +// the pre-fork-issue-56 code's 'err=' + (error?.message || error) string-concatenation did (Error +// instances and message-bearing objects use .message; anything else -- a thrown string, +// undefined, a plain object -- coerces the same way String() / template-literal +// interpolation would), so a caller like extension.ts's _updateSettings never has to +// guard against a missing .message itself. +function toErrorMessage(error: unknown): string { + if (typeof error === 'object' && error !== null && 'message' in error) { + const message = (error as { message: unknown }).message; + if (typeof message === 'string' && message) { + return message; + } + } + if (typeof error === 'string') { + return error; + } + return String(error); +} + +// Applies every [key, value] pair in settings via updateSetting, one at a time, each in +// its own try/catch -- unlike the pre-fork-issue-56 single try/catch around the whole loop, a +// rejection for one key never stops the remaining keys from being attempted. Keys are +// attempted in the same order Object.entries(settings) always yields (insertion order +// for string keys), so applied/failures each preserve that order internally. +export async function applySettingsBatch( + settings: { [key: string]: any }, + updateSetting: (key: string, value: any) => Promise +): Promise { + const applied: string[] = []; + const failures: SettingUpdateFailure[] = []; + for (const [key, value] of Object.entries(settings)) { + try { + await updateSetting(key, value); + applied.push(key); + } catch (error) { + const message = toErrorMessage(error); + failures.push({ key, message }); + } + } + return { applied, failures }; +} diff --git a/src/test/diff-utils.test.ts b/src/test/diff-utils.test.ts new file mode 100644 index 0000000..d857714 --- /dev/null +++ b/src/test/diff-utils.test.ts @@ -0,0 +1,129 @@ +// Unit tests for the fork-issue-38 turn-diff helpers (WSL path mapping, workspace-relative +// path resolution, binary detection, baseline URI construction). All pure (no +// vscode, no network, no filesystem access), so these run under plain mocha against +// the compiled out/ output -- same pattern as the model-updater unit tests. Run with +// `npm run test:diff-utils`. + +import * as assert from 'assert'; +import { + mapWslPathToWindows, + toWorkspaceRelativePath, + isBinaryContent, + buildTurnDiffUriParts, + parseTurnDiffUriParts, + turnDiffCacheKey, + TURN_DIFF_URI_SCHEME +} from '../diff-utils'; + +suite('diff-utils: mapWslPathToWindows', () => { + + test('maps /mnt/c/... to C:\\...', () => { + assert.strictEqual(mapWslPathToWindows('/mnt/c/Users/example/foo.ts'), 'C:\\Users\\example\\foo.ts'); + }); + + test('maps other drive letters too (e.g. /mnt/d)', () => { + assert.strictEqual(mapWslPathToWindows('/mnt/d/projects/bar.ts'), 'D:\\projects\\bar.ts'); + }); + + test('is case-insensitive on the drive letter and normalizes it to uppercase', () => { + assert.strictEqual(mapWslPathToWindows('/mnt/C/Users/example/foo.ts'), 'C:\\Users\\example\\foo.ts'); + }); + + test('leaves an already-Windows path unchanged', () => { + assert.strictEqual(mapWslPathToWindows('C:\\Users\\example\\foo.ts'), 'C:\\Users\\example\\foo.ts'); + }); + + test('leaves a non-/mnt Linux path unchanged (not a WSL-mapped drive)', () => { + assert.strictEqual(mapWslPathToWindows('/home/user/foo.ts'), '/home/user/foo.ts'); + }); +}); + +suite('diff-utils: toWorkspaceRelativePath', () => { + + test('resolves an exact match under the workspace root', () => { + assert.strictEqual(toWorkspaceRelativePath('C:\\proj\\src\\a.ts', 'C:\\proj'), 'src/a.ts'); + }); + + test('is case-insensitive (Windows paths)', () => { + assert.strictEqual(toWorkspaceRelativePath('c:\\PROJ\\src\\a.ts', 'C:\\proj'), 'src/a.ts'); + }); + + test('normalizes mixed \\ and / separators', () => { + assert.strictEqual(toWorkspaceRelativePath('C:/proj\\src/a.ts', 'C:\\proj'), 'src/a.ts'); + }); + + test('returns undefined for a file outside the workspace', () => { + assert.strictEqual(toWorkspaceRelativePath('C:\\other\\a.ts', 'C:\\proj'), undefined); + }); + + test('returns undefined when the path is the workspace root itself', () => { + assert.strictEqual(toWorkspaceRelativePath('C:\\proj', 'C:\\proj'), undefined); + }); +}); + +suite('diff-utils: isBinaryContent', () => { + + test('plain text is not binary', () => { + assert.strictEqual(isBinaryContent(Buffer.from('hello world\nline two\n', 'utf8')), false); + }); + + test('a NUL byte within the first 8 KB is detected as binary', () => { + const buf = Buffer.concat([Buffer.from('abc'), Buffer.from([0]), Buffer.from('def')]); + assert.strictEqual(isBinaryContent(buf), true); + }); + + test('a NUL byte after the first 8 KB is not detected (window limit)', () => { + const buf = Buffer.concat([Buffer.alloc(8200, 'a'), Buffer.from([0])]); + assert.strictEqual(isBinaryContent(buf), false); + }); + + test('an empty buffer is not binary', () => { + assert.strictEqual(isBinaryContent(Buffer.alloc(0)), false); + }); +}); + +suite('diff-utils: buildTurnDiffUriParts / turnDiffCacheKey', () => { + + test('is deterministic for the same (sha, relPath)', () => { + const a = buildTurnDiffUriParts('abc123', 'src/a.ts'); + const b = buildTurnDiffUriParts('abc123', 'src/a.ts'); + assert.deepStrictEqual(a, b); + assert.strictEqual(turnDiffCacheKey(a), turnDiffCacheKey(b)); + }); + + test('differs by sha for the same relPath', () => { + const a = buildTurnDiffUriParts('abc123', 'src/a.ts'); + const b = buildTurnDiffUriParts('def456', 'src/a.ts'); + assert.notStrictEqual(turnDiffCacheKey(a), turnDiffCacheKey(b)); + }); + + test('differs by relPath for the same sha', () => { + const a = buildTurnDiffUriParts('abc123', 'src/a.ts'); + const b = buildTurnDiffUriParts('abc123', 'src/b.ts'); + assert.notStrictEqual(turnDiffCacheKey(a), turnDiffCacheKey(b)); + }); + + test('uses the existing claude-diff scheme and keeps the real basename in the path', () => { + const parts = buildTurnDiffUriParts('abc123', 'src/a.ts'); + assert.strictEqual(parts.scheme, TURN_DIFF_URI_SCHEME); + assert.strictEqual(parts.scheme, 'claude-diff'); + assert.strictEqual(parts.path, '/src/a.ts'); + }); +}); + +suite('diff-utils: parseTurnDiffUriParts', () => { + + test('is the exact inverse of buildTurnDiffUriParts', () => { + const built = buildTurnDiffUriParts('abc123', 'src/a.ts'); + assert.deepStrictEqual(parseTurnDiffUriParts(built), { sha: 'abc123', relPath: 'src/a.ts' }); + }); + + test('round-trips a nested relPath', () => { + const built = buildTurnDiffUriParts('def456', 'src/sub/dir/file.tsx'); + assert.deepStrictEqual(parseTurnDiffUriParts(built), { sha: 'def456', relPath: 'src/sub/dir/file.tsx' }); + }); + + test('returns undefined when query has no sha= prefix (not a URI this feature built)', () => { + assert.strictEqual(parseTurnDiffUriParts({ path: '/src/a.ts', query: 'other=x' }), undefined); + }); +}); diff --git a/src/test/restore-commit-utils.test.ts b/src/test/restore-commit-utils.test.ts new file mode 100644 index 0000000..d47d0a6 --- /dev/null +++ b/src/test/restore-commit-utils.test.ts @@ -0,0 +1,112 @@ +// Unit tests for the fork-issue-50 checkpoint-restore fix (isValidCommitSha, +// findRehydratedCommitInfo). Pure (no vscode, no network, no filesystem access), so +// these run under plain mocha against the compiled out/ output -- same pattern as +// diff-utils/model-updater. The actual "does this commit still exist" +// check (git cat-file -e against the shadow backup repo) stays in extension.ts, +// untested here, same as diff-utils' git-show baseline read. Run with +// `npm run test:restore-commit-utils`. + +import * as assert from 'assert'; +import { isValidCommitSha, findRehydratedCommitInfo } from '../restore-commit-utils'; + +suite('restore-commit-utils: isValidCommitSha', () => { + + test('a full 40-char SHA-1 (as produced by `git rev-parse HEAD`) is valid', () => { + assert.strictEqual(isValidCommitSha('a'.repeat(40)), true); + }); + + test('a full 64-char SHA-256 is valid', () => { + assert.strictEqual(isValidCommitSha('a'.repeat(64)), true); + }); + + test('is case-insensitive (uppercase hex is valid)', () => { + assert.strictEqual(isValidCommitSha('ABCDEF0123456789abcdef0123456789abcdef01'), true); + }); + + test('a 7-char abbreviation is valid (shortest git itself would treat as unambiguous)', () => { + assert.strictEqual(isValidCommitSha('abcdef1'), true); + }); + + test('shorter than 7 chars is rejected', () => { + assert.strictEqual(isValidCommitSha('abcde'), false); + }); + + test('longer than 64 chars is rejected', () => { + assert.strictEqual(isValidCommitSha('a'.repeat(65)), false); + }); + + test('empty string is rejected', () => { + assert.strictEqual(isValidCommitSha(''), false); + }); + + test('a shell-metacharacter injection attempt is rejected', () => { + assert.strictEqual(isValidCommitSha('deadbeef; rm -rf /'), false); + }); + + test('a sha with a trailing quote (attribute-breakout style) is rejected', () => { + assert.strictEqual(isValidCommitSha('deadbeef"'), false); + }); +}); + +suite('restore-commit-utils: findRehydratedCommitInfo', () => { + + const sha = 'deadbeef00112233445566778899aabbccddeeff'; + + test('rehydrates message/timestamp from the matching showRestoreOption entry', () => { + const messages = [ + { messageType: 'userInput', data: 'hi' }, + { messageType: 'showRestoreOption', data: { id: 'commit-1', sha, message: 'Before: fix bug', timestamp: '2026-07-27T10:00:00.000Z' } } + ]; + assert.deepStrictEqual(findRehydratedCommitInfo(messages, sha), { + id: 'commit-1', + sha, + message: 'Before: fix bug', + timestamp: '2026-07-27T10:00:00.000Z' + }); + }); + + test('ignores a showRestoreOption entry for a different sha', () => { + const messages = [ + { messageType: 'showRestoreOption', data: { id: 'commit-1', sha: 'other'.padEnd(40, '0'), message: 'Before: other', timestamp: '2026-07-27T10:00:00.000Z' } } + ]; + const result = findRehydratedCommitInfo(messages, sha); + assert.strictEqual(result.message, sha, 'must fall back to the placeholder, not the other entry'); + }); + + test('ignores a non-showRestoreOption message whose data coincidentally has a matching sha field', () => { + const messages = [ + { messageType: 'toolResult', data: { sha, message: 'not a checkpoint' } } + ]; + const result = findRehydratedCommitInfo(messages, sha); + assert.strictEqual(result.message, sha, 'must fall back to the placeholder, not the unrelated entry'); + }); + + test('falls back to id `commit-` when the matched entry has no id field', () => { + const messages = [ + { messageType: 'showRestoreOption', data: { sha, message: 'Before: fix bug', timestamp: '2026-07-27T10:00:00.000Z' } } + ]; + const result = findRehydratedCommitInfo(messages, sha); + assert.strictEqual(result.id, `commit-${sha}`); + assert.strictEqual(result.message, 'Before: fix bug'); + }); + + test('falls back to a sha-based placeholder when the matched entry has a non-string message (malformed data)', () => { + const messages = [ + { messageType: 'showRestoreOption', data: { sha, message: 42, timestamp: '2026-07-27T10:00:00.000Z' } } + ]; + const result = findRehydratedCommitInfo(messages, sha); + assert.strictEqual(result.message, sha); + assert.strictEqual(result.id, `commit-${sha}`); + }); + + test('falls back to a sha-based placeholder with a valid timestamp when no entry matches at all', () => { + const result = findRehydratedCommitInfo([], sha); + assert.deepStrictEqual(result, { + id: `commit-${sha}`, + sha, + message: sha, + timestamp: result.timestamp + }); + assert.notStrictEqual(new Date(result.timestamp).toString(), 'Invalid Date'); + }); +}); diff --git a/src/test/settings-batch.test.ts b/src/test/settings-batch.test.ts new file mode 100644 index 0000000..6a7ea3b --- /dev/null +++ b/src/test/settings-batch.test.ts @@ -0,0 +1,122 @@ +// Unit tests for the fork-issue-56 settings-batch fix (applySettingsBatch). Pure (no vscode, no +// network, no filesystem access), so these run under plain mocha against the compiled +// out/ output -- same pattern as restore-commit-utils. +// The first two suites are the actual regression coverage for the bug: a key that +// throws must not abort the keys after it, unlike the pre-fork-issue-56 single try/catch loop +// (extension.ts's old _updateSettings, which broke out of the whole batch on the first +// config.update() rejection and only ever recorded that one failure). Run with +// `npm run test:settings-batch`. + +import * as assert from 'assert'; +import { applySettingsBatch } from '../settings-batch'; + +suite('settings-batch: applySettingsBatch (all keys succeed)', () => { + + test('every key is applied in order, with an empty failures list', async () => { + const seen: Array<[string, any]> = []; + const result = await applySettingsBatch( + { 'advanced.maxOutputTokens': 5000, 'ui.fontSize': 14, 'wsl.distro': 'Ubuntu' }, + async (key, value) => { seen.push([key, value]); } + ); + assert.deepStrictEqual(result.applied, ['advanced.maxOutputTokens', 'ui.fontSize', 'wsl.distro']); + assert.deepStrictEqual(result.failures, []); + assert.deepStrictEqual(seen, [['advanced.maxOutputTokens', 5000], ['ui.fontSize', 14], ['wsl.distro', 'Ubuntu']], + 'updateSetting must still be called with the original key/value pairs'); + }); +}); + +suite('settings-batch: applySettingsBatch (fork-issue-56 -- a failing key must not abort the rest)', () => { + + test('a key that throws is recorded as a failure, and every key after it is still applied', async () => { + const result = await applySettingsBatch( + { 'advanced.maxOutputTokens': 5000, 'ui.fontFamily': 'monospace', 'ui.fontSize': 14, 'diff.autoOpen': true }, + async (key) => { + if (key === 'advanced.maxOutputTokens') { + throw new Error('config not registered'); + } + } + ); + assert.deepStrictEqual(result.applied, ['ui.fontFamily', 'ui.fontSize', 'diff.autoOpen'], + 'keys after the failing one must still be applied, not silently dropped (the fork-issue-56 scenario)'); + assert.deepStrictEqual(result.failures, [{ key: 'advanced.maxOutputTokens', message: 'config not registered' }]); + }); + + test('a failing key in the middle of the batch still lets both earlier and later keys succeed', async () => { + const result = await applySettingsBatch( + { first: 1, second: 2, third: 3 }, + async (key) => { + if (key === 'second') { + throw new Error('boom'); + } + } + ); + assert.deepStrictEqual(result.applied, ['first', 'third']); + assert.deepStrictEqual(result.failures, [{ key: 'second', message: 'boom' }]); + }); +}); + +suite('settings-batch: applySettingsBatch (multiple failing keys)', () => { + + test('all failures are collected, in the order they were attempted, applied keys unaffected', async () => { + const result = await applySettingsBatch( + { a: 1, b: 2, c: 3, d: 4 }, + async (key) => { + if (key === 'a' || key === 'c') { + throw new Error(`bad key ${key}`); + } + } + ); + assert.deepStrictEqual(result.applied, ['b', 'd']); + assert.deepStrictEqual(result.failures, [ + { key: 'a', message: 'bad key a' }, + { key: 'c', message: 'bad key c' } + ]); + }); +}); + +suite('settings-batch: applySettingsBatch (empty batch)', () => { + + test('an empty settings object resolves with empty applied/failures and never calls updateSetting', async () => { + let calls = 0; + const result = await applySettingsBatch({}, async () => { calls++; }); + assert.deepStrictEqual(result, { applied: [], failures: [] }); + assert.strictEqual(calls, 0); + }); +}); + +suite('settings-batch: applySettingsBatch (error normalization)', () => { + + test('an Error instance uses its .message', async () => { + const result = await applySettingsBatch({ k: 1 }, async () => { throw new Error('boom'); }); + assert.strictEqual(result.failures[0].message, 'boom'); + }); + + test('a thrown plain string is used as the message as-is', async () => { + // Promise.reject(...), not a "throw" statement, so a rejection with a + // non-Error value (deliberate here, to exercise toErrorMessage's + // non-Error branch) doesn't trip the no-throw-literal lint rule. + const result = await applySettingsBatch({ k: 1 }, () => Promise.reject('plain string error')); + assert.strictEqual(result.failures[0].message, 'plain string error'); + }); + + test('a thrown undefined is converted to the text "undefined", never left as an actual undefined value', async () => { + const result = await applySettingsBatch({ k: 1 }, () => Promise.reject(undefined)); + assert.strictEqual(result.failures[0].message, 'undefined'); + assert.strictEqual(typeof result.failures[0].message, 'string'); + }); + + test('a rejected plain object without a .message property still yields a string, not a throw', async () => { + const result = await applySettingsBatch({ k: 1 }, () => Promise.reject({ code: 'EFAIL' })); + assert.strictEqual(typeof result.failures[0].message, 'string'); + }); +}); + +suite('settings-batch: applySettingsBatch (malformed input -- the outer safety-net catch in extension.ts)', () => { + + test("a nullish settings object rejects instead of resolving silently -- the only way out of this module into a caller's outer try/catch (extension.ts's _updateSettings, which logs and shows an error message)", async () => { + await assert.rejects( + () => applySettingsBatch(undefined as any, async () => { /* never reached */ }), + /Cannot convert undefined or null to object/ + ); + }); +}); diff --git a/src/ui-styles.ts b/src/ui-styles.ts index 76bd766..a986a31 100644 --- a/src/ui-styles.ts +++ b/src/ui-styles.ts @@ -945,8 +945,8 @@ const styles = ` flex: 1; padding: 10px; overflow-y: auto; - font-family: var(--vscode-editor-font-family); - font-size: var(--vscode-editor-font-size); + font-family: var(--chat-font-family, var(--vscode-editor-font-family)); + font-size: var(--chat-font-size, var(--vscode-editor-font-size)); line-height: 1.4; } @@ -960,7 +960,7 @@ const styles = ` border: 1px solid rgba(64, 165, 255, 0.2); border-radius: 8px; color: var(--vscode-editor-foreground); - font-family: var(--vscode-editor-font-family); + font-family: var(--chat-font-family, var(--vscode-editor-font-family)); position: relative; overflow: hidden; } @@ -1039,7 +1039,7 @@ const styles = ` border: 1px solid rgba(28, 192, 140, 0.2); border-radius: 8px; color: var(--vscode-editor-foreground); - font-family: var(--vscode-editor-font-family); + font-family: var(--chat-font-family, var(--vscode-editor-font-family)); white-space: pre-wrap; position: relative; overflow: hidden; @@ -1059,7 +1059,7 @@ const styles = ` border: 1px solid rgba(186, 85, 211, 0.2); border-radius: 8px; color: var(--vscode-editor-foreground); - font-family: var(--vscode-editor-font-family); + font-family: var(--chat-font-family, var(--vscode-editor-font-family)); font-style: italic; opacity: 0.9; position: relative; @@ -1947,7 +1947,8 @@ const styles = ` border: none; padding: 12px; outline: none; - font-family: var(--vscode-editor-font-family); + font-family: var(--chat-font-family, var(--vscode-editor-font-family)); + font-size: var(--chat-font-size, inherit); min-height: 68px; line-height: 1.4; overflow-y: hidden; @@ -3682,6 +3683,15 @@ const styles = ` transform: translateY(0); } + .status-text .ctx-warn { + color: var(--vscode-editorWarning-foreground); + } + + .status-text .ctx-crit { + color: var(--vscode-editorError-foreground); + font-weight: 600; + } + .status-text .usage-icon { width: 12px; height: 12px; diff --git a/src/ui.ts b/src/ui.ts index 4ffcab7..7b7c023 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -388,6 +388,14 @@ const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https

+
+ + +

+ Maximum number of tokens Claude may generate in a single response (sets CLAUDE_CODE_MAX_OUTPUT_TOKENS). Increase this if you hit a "response exceeded the output token maximum" error. 0 = use the CLI default. +

+
+
@@ -414,6 +422,32 @@ const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https

+

Diff View

+
+
+ + +
+
+ +

Appearance

+
+
+ + +

+ Custom font family for the chat message area and input field. Leave empty to use the editor's default font. +

+
+
+ + +

+ Custom font size (px, 6-72) for the chat message area and input field. Leave at 0 to use the editor's default font size. +

+
+
+