diff --git a/package.json b/package.json index 40e7787..a8ce4aa 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "format:check": "prettier --check .", "typecheck": "tsc -b", "ci": "pnpm typecheck && pnpm lint && pnpm test && pnpm build", - "release": "node scripts/release.mjs" + "release": "node scripts/release.mjs", + "prepare": "husky" }, "devDependencies": { "@commitlint/cli": "^20.5.0", diff --git a/packages/cli/src/app.tsx b/packages/cli/src/app.tsx index 3b43355..a54fe35 100644 --- a/packages/cli/src/app.tsx +++ b/packages/cli/src/app.tsx @@ -6,8 +6,6 @@ // cursor positioning — which together eliminate the CJK/IME jitter the // original Ink exhibits on long-running chat UIs. Nothing in our codebase // changes: the fork is API-compatible with `ink`. -import React from 'react' - import { render } from 'ink' import type { AgentOptions, LanguageModel, LoadedSession } from '@x-code-cli/core' diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts deleted file mode 100644 index b10ca8d..0000000 --- a/packages/cli/src/config/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -// @x-code-cli/cli — CLI-level config helpers -import { getAvailableProviders, resolveModelId } from '@x-code-cli/core' - -export interface CliOptions { - model?: string - trust: boolean - print: boolean - maxTurns: number - prompt?: string -} - -/** Resolve all configuration from CLI args + env */ -export function resolveCliConfig(args: CliOptions) { - const modelId = resolveModelId(args.model) - const availableProviders = getAvailableProviders() - - return { - modelId, - availableProviders, - needsSetup: availableProviders.length === 0, - trustMode: args.trust, - printMode: args.print, - maxTurns: args.maxTurns, - } -} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8d33c12..5a23ccd 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -22,6 +22,8 @@ import { import type { AgentOptions, LoadedSession } from '@x-code-cli/core' import { getCleanupFn, getSessionExitInfo, startApp } from './app.js' +import { detectShell, formatPersistCommand } from './shell.js' +import type { ShellType } from './shell.js' import { setSyntaxTheme } from './ui/syntax-highlight.js' import { getThemeColors, parseThemeName, setTheme } from './ui/theme.js' import { VERSION } from './version.js' @@ -396,28 +398,19 @@ function printNoApiKeyMessage() { ) const shell = detectShell() + const restartHint: Record = { + powershell: '# restart PowerShell, then run:', + cmd: ':: restart CMD, then run:', + zsh: '', + bash: '', + fish: '', + sh: '', + } console.error(`\nDetected shell: ${chalk.bold(shell)}`) console.error('Persist it so you do not need to set it every session:\n') - switch (shell) { - case 'powershell': - console.error(` ${code(`[Environment]::SetEnvironmentVariable('ANTHROPIC_API_KEY','sk-ant-...','User')`)}`) - console.error(` ${comment('# restart PowerShell, then run:')} ${code('xc')}`) - break - case 'cmd': - console.error(` ${code('setx ANTHROPIC_API_KEY "sk-ant-..."')}`) - console.error(` ${comment(':: restart CMD, then run:')} ${code('xc')}`) - break - case 'zsh': - console.error(` ${code(`echo 'export ANTHROPIC_API_KEY=sk-ant-...' >> ~/.zshrc && source ~/.zshrc`)}`) - break - case 'fish': - console.error(` ${code('set -Ux ANTHROPIC_API_KEY sk-ant-...')}`) - break - case 'bash': - default: - console.error(` ${code(`echo 'export ANTHROPIC_API_KEY=sk-ant-...' >> ~/.bashrc && source ~/.bashrc`)}`) - break - } + console.error(` ${code(formatPersistCommand('ANTHROPIC_API_KEY', 'sk-ant-...', shell))}`) + const hint = restartHint[shell] + if (hint) console.error(` ${comment(hint)} ${code('xc')}`) console.error(`\nAlternatively, put keys in a project-local ${chalk.bold('.env')} file (loaded from cwd upward).`) } @@ -434,42 +427,10 @@ function printNoWebSearchKeyHint(): void { console.error(` ${bold('TAVILY_API_KEY')} ${dim('1000/month — https://tavily.com')}`) console.error(` ${bold('BRAVE_API_KEY')} ${dim('2000/month — https://api.search.brave.com')}`) - let cmd: string - switch (shell) { - case 'powershell': - cmd = `[Environment]::SetEnvironmentVariable('TAVILY_API_KEY','tvly-...','User')` - break - case 'cmd': - cmd = `setx TAVILY_API_KEY "tvly-..."` - break - case 'zsh': - cmd = `echo 'export TAVILY_API_KEY=tvly-...' >> ~/.zshrc && source ~/.zshrc` - break - case 'fish': - cmd = `set -Ux TAVILY_API_KEY tvly-...` - break - case 'bash': - default: - cmd = `echo 'export TAVILY_API_KEY=tvly-...' >> ~/.bashrc && source ~/.bashrc` - break - } + const cmd = formatPersistCommand('TAVILY_API_KEY', 'tvly-...', shell) console.error(` ${dim(`(${shell})`)} ${code(cmd)}\n`) } -function detectShell(): 'powershell' | 'cmd' | 'bash' | 'zsh' | 'fish' | 'sh' { - if (process.platform === 'win32') { - // PowerShell sets PSModulePath; CMD typically doesn't (and no PSHOME). - if (process.env.PSModulePath) return 'powershell' - return 'cmd' - } - const shellPath = process.env.SHELL ?? '' - const base = shellPath.split('/').pop() ?? '' - if (base === 'zsh' || base === 'bash' || base === 'fish' || base === 'sh') return base - // macOS defaults to zsh since Catalina - if (process.platform === 'darwin') return 'zsh' - return 'bash' -} - function readStdin(): Promise { return new Promise((resolve) => { let data = '' diff --git a/packages/cli/src/shell.ts b/packages/cli/src/shell.ts new file mode 100644 index 0000000..517c730 --- /dev/null +++ b/packages/cli/src/shell.ts @@ -0,0 +1,45 @@ +// @x-code-cli/cli — Shell detection and persistence-command helpers. +// +// Shell detection is needed by multiple message-printing functions in +// index.ts. The formatPersistCommand helper extracts the copy-pasted +// switch(shell) block that appeared identically in printNoApiKeyMessage +// and printNoWebSearchKeyHint. + +export type ShellType = 'powershell' | 'cmd' | 'bash' | 'zsh' | 'fish' | 'sh' + +export function detectShell(): ShellType { + if (process.platform === 'win32') { + if (process.env.PSModulePath) return 'powershell' + return 'cmd' + } + const shellPath = process.env.SHELL ?? '' + const base = shellPath.split('/').pop() ?? '' + if (base === 'zsh' || base === 'bash' || base === 'fish' || base === 'sh') return base + if (process.platform === 'darwin') return 'zsh' + return 'bash' +} + +/** + * Return a copy-pasteable shell command that persists an environment + * variable. The returned string is the command only (no prefix, no + * newline) — callers wrap it in chalk color and surrounding prose. + * + * envVar — e.g. "ANTHROPIC_API_KEY" + * exampleValue — e.g. "sk-ant-..." + * shell — result from detectShell() + */ +export function formatPersistCommand(envVar: string, exampleValue: string, shell: ShellType): string { + switch (shell) { + case 'powershell': + return `[Environment]::SetEnvironmentVariable('${envVar}','${exampleValue}','User')` + case 'cmd': + return `setx ${envVar} "${exampleValue}"` + case 'zsh': + return `echo 'export ${envVar}=${exampleValue}' >> ~/.zshrc && source ~/.zshrc` + case 'fish': + return `set -Ux ${envVar} ${exampleValue}` + case 'bash': + default: + return `echo 'export ${envVar}=${exampleValue}' >> ~/.bashrc && source ~/.bashrc` + } +} diff --git a/packages/cli/src/ui/components/App.tsx b/packages/cli/src/ui/components/App.tsx index 6a51fa1..04b9ff1 100644 --- a/packages/cli/src/ui/components/App.tsx +++ b/packages/cli/src/ui/components/App.tsx @@ -26,6 +26,7 @@ import { buildThemePreview } from '../render-diff.js' import { setSyntaxTheme } from '../syntax-highlight.js' import { GLYPH_BULLET } from '../terminal-glyphs.js' import { DEFAULT_THEME, THEMES, type ThemeName, getTheme, getThemeColors, parseThemeName, setTheme } from '../theme.js' +import { parseBooleanArg } from '../utils.js' import { getHeaderRowCount } from './AppHeader.js' import { ChatInput } from './ChatInput.js' @@ -630,18 +631,8 @@ export function App({ // Direct-switch shortcut path. if (trimmed) { - let next: boolean - if (trimmed === 'on' || trimmed === 'true' || trimmed === '1' || trimmed === 'enable' || trimmed === 'enabled') { - next = true - } else if ( - trimmed === 'off' || - trimmed === 'false' || - trimmed === '0' || - trimmed === 'disable' || - trimmed === 'disabled' - ) { - next = false - } else { + const next = parseBooleanArg(trimmed) + if (next === null) { addCommandMessage( commandText, `Unknown value: \`${arg}\`. Use \`/thinking\`, \`/thinking on\`, or \`/thinking off\`.`, @@ -812,25 +803,13 @@ export function App({ let next: boolean if (!trimmed) { next = !current - } else if ( - trimmed === 'on' || - trimmed === 'true' || - trimmed === '1' || - trimmed === 'enable' || - trimmed === 'enabled' - ) { - next = true - } else if ( - trimmed === 'off' || - trimmed === 'false' || - trimmed === '0' || - trimmed === 'disable' || - trimmed === 'disabled' - ) { - next = false } else { - addCommandMessage(commandText, `Unknown value: \`${arg}\`. Use \`/plan\`, \`/plan on\`, or \`/plan off\`.`) - return + const parsed = parseBooleanArg(trimmed) + if (parsed === null) { + addCommandMessage(commandText, `Unknown value: \`${arg}\`. Use \`/plan\`, \`/plan on\`, or \`/plan off\`.`) + return + } + next = parsed } if (next === current) { diff --git a/packages/cli/src/ui/components/ChatInput.tsx b/packages/cli/src/ui/components/ChatInput.tsx index 18c44e6..3c3910b 100644 --- a/packages/cli/src/ui/components/ChatInput.tsx +++ b/packages/cli/src/ui/components/ChatInput.tsx @@ -57,7 +57,7 @@ import { SPINNER_FRAMES, } from '../terminal-glyphs.js' import { charWidth, sliceByWidth, visualWidth } from '../text-width.js' -import { getToolInputPreview, getToolLabel, isCollapsibleReadOnlyTool } from '../tool-display.js' +import { formatTokenCount, getToolInputPreview, getToolLabel, isCollapsibleReadOnlyTool } from '../utils.js' const PASTE_REF_MIN_LINES = 3 const PASTE_REF_MIN_CHARS = 400 @@ -349,7 +349,6 @@ function renderRowToAnsi(cells: Cell[]): string { // — keep these two tables in sync. const S_GRAY = '\x1b[38;2;136;136;136m' // promptBorder rgb(136,136,136) #888888 const S_ACCENT = '\x1b[38;2;215;119;87m' // claude rgb(215,119,87) #d77757 -const S_ACCENT_BOLD = '\x1b[38;2;215;119;87;1m' const S_ACCENT_DIM = '\x1b[38;2;153;153;153m' // inactive rgb(153,153,153) #999999 const S_SPINNER = '\x1b[38;2;147;165;255m' // claudeBlue rgb(147,165,255) #93a5ff const S_SUCCESS = '\x1b[38;2;78;186;101;1m' // success rgb(78,186,101) #4eba65 @@ -376,11 +375,9 @@ const S_BOLD = '\x1b[0m\x1b[1m' // (147,165,255) which is a DIFFERENT shade, producing a visible // color shift at the live→committed handoff. const S_BLUE_PURPLE = '\x1b[0m\x1b[38;2;177;185;249m' -const S_BLUE_PURPLE_DIM = '\x1b[0m\x1b[38;2;177;185;249;2m' const S_BLUE_PURPLE_BOLD = '\x1b[0m\x1b[38;2;177;185;249;1m' const S_WARNING = '\x1b[38;2;255;193;7m' // warning rgb(255,193,7) #ffc107 const S_WARNING_BOLD = '\x1b[38;2;255;193;7;1m' -const S_ERROR_FG = '\x1b[38;2;255;107;128m' // error rgb(255,107,128) #ff6b80 const S_ERROR_BOLD = '\x1b[38;2;255;107;128;1m' // NB: leading `\x1b[0m` matters. Plain `\x1b[2m` just adds the "dim" // attribute ON TOP of whatever foreground color is active — so meta @@ -461,7 +458,6 @@ const S_CURSOR = '\x1b[7m' * places it at the input column before ESU commits. When there is no * active anchor (disabled / dialog) ESU_HIDE explicitly hides. */ const BSU = '\x1b[?2026h' -const ESU_SHOW = '\x1b[?2026l\x1b[?25h' const ESU_HIDE = '\x1b[?2026l\x1b[?25l' // NOTE: a DECSTBM-based `buildInsertHistoryAbove` existed briefly here @@ -614,12 +610,6 @@ function formatElapsed(ms: number): string { return `${minutes}m ${secs}s` } -function formatTokens(tokens: number): string { - if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M` - if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k` - return `${tokens}` -} - // ── Component ─────────────────────────────────────────────────────────── export function ChatInput({ @@ -2153,14 +2143,10 @@ export function ChatInput({ // Top separator frame.push(textToCells(sepText, S_GRAY)) - // Input lines. `cursorAnchor` captures the frame-row index (0-based) - // and 1-based visual column where the terminal's real cursor should - // be parked at end of render. ESU is then chosen based on whether an - // anchor is set: ESU_SHOW (trailing `\x1b[?25h`) reveals the caret at - // that column so it is the one and only visible cursor; ESU_HIDE - // (trailing `\x1b[?25l`) keeps it hidden when the input is disabled - // or there is no active cursor line. - let cursorAnchor: { row: number; col: number } | null = null + // Input lines. The terminal's hardware cursor is hidden for the + // entire TUI lifetime; the visible "cursor" the user sees is just an + // inverse-video cell (S_CURSOR) drawn into the frame at the cursor + // position. So we don't compute or emit a cursor-park CSI here. for (let i = 0; i < displayLines.length; i++) { const line = displayLines[i] const prompt = i === 0 ? '> ' : ' ' @@ -2182,10 +2168,6 @@ export function ChatInput({ if (lw <= vpWidth) { cells.push(...textToCells(before, S_RESET)) - // Visual col = prompt width (2) + width of chars before cursor, - // +1 to convert to 1-based. Captured BEFORE pushing cursor cell - // so it reflects the cursor cell's starting column. - cursorAnchor = { row: frame.length, col: 2 + visualWidth(before) + 1 } cells.push({ char: cursorChar, style: S_CURSOR, width: charWidth(cursorChar) }) cells.push(...textToCells(after, S_RESET)) } else { @@ -2200,7 +2182,6 @@ export function ChatInput({ const remaining = vpWidth - visualWidth(vb) - charWidth(cursorChar) const va = sliceByWidth(line.slice(afterStart), Math.max(0, remaining)) cells.push(...textToCells(vb, S_RESET)) - cursorAnchor = { row: frame.length, col: 2 + visualWidth(vb) + 1 } cells.push({ char: cursorChar, style: S_CURSOR, width: charWidth(cursorChar) }) cells.push(...textToCells(va, S_RESET)) } @@ -2253,7 +2234,7 @@ export function ChatInput({ let rightText: string | null = null if (contextUsage && contextUsage.used > 0 && contextUsage.window > 0) { const pct = Math.round((contextUsage.used / contextUsage.window) * 100) - rightText = `${formatTokens(contextUsage.used)} / ${formatTokens(contextUsage.window)} · ${pct}%` + rightText = `${formatTokenCount(contextUsage.used)} / ${formatTokenCount(contextUsage.window)} · ${pct}%` } if (leftCells || rightText) { @@ -3098,9 +3079,7 @@ export function ChatInput({ // above. Skipping the park removes one cursor-position command per // flush — on weak terminals each such command kicks the renderer's // state machine even when the cursor itself is hidden, so dropping - // it visibly reduces residual flicker. cursorAnchor is still - // computed because lower paths (and future revival of the visible - // cursor) read it; it's just no longer emitted as a CSI H here. + // it visibly reduces residual flicker. // Flush everything as a single write: preBuf (BSU + DECSTBM scrollback // insertion + any frame-height-change scrolling) + frame diff + ESU. diff --git a/packages/cli/src/ui/hooks/use-agent.ts b/packages/cli/src/ui/hooks/use-agent.ts index 33e2d1e..6b66ead 100644 --- a/packages/cli/src/ui/hooks/use-agent.ts +++ b/packages/cli/src/ui/hooks/use-agent.ts @@ -28,7 +28,7 @@ import type { TokenUsage, } from '@x-code-cli/core' -import { isCollapsibleReadOnlyTool } from '../tool-display.js' +import { isCollapsibleReadOnlyTool } from '../utils.js' import { modelMessagesToDisplay, previewSubInput } from './use-agent-display.js' import { extractLastAssistantText, useStreamBuffer } from './use-stream-buffer.js' @@ -795,12 +795,11 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini setState((prev) => ({ ...prev, permissionMode: next })) }, []) - /** Add a system/info message (for slash command output) */ - const addInfoMessage = useCallback( - (content: string) => { + const addMessage = useCallback( + (role: 'user' | 'assistant', content: string) => { appendMessage({ id: Date.now().toString(), - role: 'assistant', + role, content, timestamp: Date.now(), }) @@ -808,18 +807,11 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini [appendMessage], ) + /** Add a system/info message (for slash command output) */ + const addInfoMessage = useCallback((content: string) => addMessage('assistant', content), [addMessage]) + /** Add a user message to the history (for echoing slash commands) */ - const addUserMessage = useCallback( - (content: string) => { - appendMessage({ - id: Date.now().toString(), - role: 'user', - content, - timestamp: Date.now(), - }) - }, - [appendMessage], - ) + const addUserMessage = useCallback((content: string) => addMessage('user', content), [addMessage]) /** Render a slash command + its short result as a Claude-style 2-line block: * > /cmd diff --git a/packages/cli/src/ui/render-diff.ts b/packages/cli/src/ui/render-diff.ts index e9bdfb3..913a871 100644 --- a/packages/cli/src/ui/render-diff.ts +++ b/packages/cli/src/ui/render-diff.ts @@ -18,9 +18,10 @@ import { Chalk } from 'chalk' import type { EditDiffHunk, EditDiffPayload } from '@x-code-cli/core' -import { type SyntaxThemeName, detectLanguage, highlightLine } from './syntax-highlight.js' +import { type SyntaxThemeName, applyColor, detectLanguage, highlightLine } from './syntax-highlight.js' import { sliceByWidth, visualWidth } from './text-width.js' import { type ThemeName, getThemeColors } from './theme.js' +import { RESULT_INDENT } from './utils.js' const c = new Chalk({ level: 3 }) @@ -58,23 +59,6 @@ function applyGutterFg(text: string, color: string): string { return text } -/** Apply a fg color spec accepting EITHER a hex (`#rrggbb`) OR a chalk - * named color (`'white'`, `'gray'`, etc.). Used by the `-` row plain- - * text branch where ANSI themes pass `'white'` (chalk's \e[37m, - * matching CC's `ansiIdx(7)`) and 24-bit themes pass `#f8f8f2` / - * `#333333`. Without the named-color path, ANSI defaultFg silently - * no-ops because `'white'.startsWith('#')` is false. */ -function applyFg(text: string, color: string): string { - if (color.startsWith('#')) return c.hex(color)(text) - const named = (c as unknown as Record string>)[color] - if (typeof named === 'function') return named(text) - return text -} - -/** Indent for diff body — matches stdout-writer's RESULT_INDENT so the - * block aligns under ` ⎿ ` (3 + ⎿ + 2 = 6 cells). */ -const RESULT_INDENT = ' ' - /** Cap an individual diff body's height. Multi-hunk patches with hundreds * of lines aren't useful in scrollback (the user would scroll past most * of it anyway); after the cap we collapse to a `… +N more lines` row. @@ -257,7 +241,7 @@ function renderHunks( // is going away" more cleanly. We DO apply defaultFg though — // it's how CC's `defaultStyle` makes the deleted text visibly // brighter than terminal default. - const plainCode = defaultFg ? applyFg(fitted, defaultFg) : fitted + const plainCode = defaultFg ? applyColor(fitted, defaultFg) : fitted const coloredGutter = applyGutterFg(gutter, themeColors.diffRemovedDecoration) let row = applyBg(coloredGutter + plainCode + padding, themeColors.diffRemoved) // ANSI mode has no bg to mark the remove row, so dim the whole diff --git a/packages/cli/src/ui/stdout-writer.ts b/packages/cli/src/ui/stdout-writer.ts index 38091fd..7e4f132 100644 --- a/packages/cli/src/ui/stdout-writer.ts +++ b/packages/cli/src/ui/stdout-writer.ts @@ -29,42 +29,21 @@ import { renderMarkdown } from './render-markdown.js' import { GLYPH_BULLET, GLYPH_ELLIPSIS, GLYPH_PROMPT_ARROW, GLYPH_RESULT_BRACKET } from './terminal-glyphs.js' import { BLUE_PURPLE, ERROR, PROMPT_BORDER, SUCCESS } from './theme.js' import { + RESULT_INDENT, + formatDuration, formatReadGroupSummary, getToolInputPreview, getToolLabel, getToolResultSummary, isCollapsibleReadOnlyTool, -} from './tool-display.js' + normalizeLineEndings, +} from './utils.js' const c = new Chalk({ level: 3 }) /** Function that writes to stdout through Ink's log-update coordination. */ export type InkWrite = (data: string) => void -const RESULT_INDENT = ' ' - -/** - * Normalize line endings to `\n`. Critical before any terminal write: - * Windows pastes / clipboard content commonly arrive with `\r\n` or bare - * `\r`, and a bare `\r` in the terminal means "move cursor to column 0 - * of the current row" — subsequent characters OVERWRITE whatever was - * previously printed on that row. Multi-line content with `\r` separators - * printed naively produces spliced/overwritten output (exactly the - * "optimizationsClaude Managed Agents is currently in beta" pattern). - */ -function normalizeLineEndings(s: string): string { - return s.replace(/\r\n?/g, '\n') -} - -function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms` - const seconds = ms / 1000 - if (seconds < 60) return `${seconds.toFixed(1)}s` - const minutes = Math.floor(seconds / 60) - const secs = Math.round(seconds % 60) - return `${minutes}m ${secs}s` -} - /** * Truncate `s` so it fits visually in `maxLen` printable cells. We use a * UTF ellipsis (…) as the truncation marker — single cell, looks diff --git a/packages/cli/src/ui/syntax-highlight.ts b/packages/cli/src/ui/syntax-highlight.ts index 961cef6..0aaba4c 100644 --- a/packages/cli/src/ui/syntax-highlight.ts +++ b/packages/cli/src/ui/syntax-highlight.ts @@ -244,14 +244,23 @@ export function parseSyntaxThemeName(input: unknown): SyntaxThemeName | null { * valid render — just less colorful. */ type Lang = 'js' | 'json' | 'html' | 'css' | 'yaml' | 'shell' | 'python' | 'go' | 'rust' | 'md' -const EXT_TO_LANG: Record = { +/** Single lookup table for both file extensions (used by detectLanguage) + * and markdown fence-language identifiers (used by detectFenceLanguage). + * Keys are lowercase. Most entries are valid in both contexts (e.g. `ts`, + * `py`, `rs`); fence-only aliases like `typescript`, `python`, `golang`, + * `rust`, `javascript`, `shell` simply produce no match for file paths + * since extensions don't use those forms. Extension-only entries like + * `mts` / `cts` similarly produce no match for fences in practice. */ +const LANG_LOOKUP: Record = { // JS / TS family — share the same tokenizer (TS-isms like `interface`, // `type`, `enum` are treated as keywords; the tokenizer is permissive // by design, false positives in raw JS are not visually offensive). ts: 'js', tsx: 'js', + typescript: 'js', js: 'js', jsx: 'js', + javascript: 'js', mjs: 'js', cjs: 'js', mts: 'js', @@ -270,17 +279,22 @@ const EXT_TO_LANG: Record = { scss: 'css', sass: 'css', less: 'css', - // Config / shell + // Config yml: 'yaml', yaml: 'yaml', toml: 'yaml', // close enough for diff coloring (key=value, strings, numbers) + // Shell sh: 'shell', bash: 'shell', zsh: 'shell', - // Other languages + shell: 'shell', + // Other py: 'python', + python: 'python', go: 'go', + golang: 'go', rs: 'rust', + rust: 'rust', md: 'md', markdown: 'md', } @@ -288,61 +302,16 @@ const EXT_TO_LANG: Record = { export function detectLanguage(filePath: string): Lang | null { const m = /\.([a-zA-Z0-9]+)$/.exec(filePath) if (!m) return null - const ext = m[1]!.toLowerCase() - return EXT_TO_LANG[ext] ?? null + return LANG_LOOKUP[m[1]!.toLowerCase()] ?? null } /** Map a markdown fence-language identifier (the bit after the opening * ``` on a fenced code block) to one of our supported `Lang` values. * Returns null when the fence had no language hint or the language * isn't covered — caller falls back to plain (un-highlighted) text. */ -const FENCE_LANG_TO_LANG: Record = { - // JS / TS family - js: 'js', - javascript: 'js', - jsx: 'js', - ts: 'js', - typescript: 'js', - tsx: 'js', - mjs: 'js', - cjs: 'js', - vue: 'js', - svelte: 'js', - // Data - json: 'json', - jsonc: 'json', - json5: 'json', - // Web - html: 'html', - htm: 'html', - xml: 'html', - css: 'css', - scss: 'css', - sass: 'css', - less: 'css', - // Config - yml: 'yaml', - yaml: 'yaml', - toml: 'yaml', - // Shell - sh: 'shell', - bash: 'shell', - zsh: 'shell', - shell: 'shell', - // Other - py: 'python', - python: 'python', - go: 'go', - golang: 'go', - rs: 'rust', - rust: 'rust', - md: 'md', - markdown: 'md', -} - export function detectFenceLanguage(fenceLang: string | undefined): Lang | null { if (!fenceLang) return null - return FENCE_LANG_TO_LANG[fenceLang.trim().toLowerCase()] ?? null + return LANG_LOOKUP[fenceLang.trim().toLowerCase()] ?? null } // ─── Tokenization ─── @@ -660,6 +629,17 @@ const KEYWORDS_SHELL = new Set([ 'source', ]) +/** Keyword classification for the simple-grammar languages: look the word + * up in the language's keyword set, optionally classify PascalCase as a + * type. JS has its own richer logic (LITERALS / GLOBALS / PascalCase) so + * it isn't in this table. */ +const KEYWORD_RULES: Partial; pascalAsType?: boolean }>> = { + python: { keywords: KEYWORDS_PYTHON, pascalAsType: true }, + go: { keywords: KEYWORDS_GO, pascalAsType: true }, + rust: { keywords: KEYWORDS_RUST, pascalAsType: true }, + shell: { keywords: KEYWORDS_SHELL }, +} + // ─── Per-language rule tables ─── // // Each rule list is tried in order at every byte position. The first @@ -832,7 +812,7 @@ const RULES_BY_LANG: Record Rule[]> = { * null spec returns the text unchanged (used by `'off'` and by the * identifier-classification fallthrough when a word doesn't match * any known keyword/type pattern). */ -function applyColor(text: string, spec: ColorSpec): string { +export function applyColor(text: string, spec: ColorSpec): string { if (spec === null) return text if (spec.startsWith('#')) return c.hex(spec)(text) // Named ANSI color — a small accessor lookup. Chalk types this as a @@ -879,23 +859,10 @@ function paint(text: string, token: Token, lang: Lang, palette: Palette, default if (/^[A-Z][a-zA-Z0-9_]*$/.test(word)) return applyColor(text, palette.type) return paintDefault(text, defaultFg) } - if (lang === 'python') { - if (KEYWORDS_PYTHON.has(word)) return applyColor(text, palette.keyword) - if (/^[A-Z][a-zA-Z0-9_]*$/.test(word)) return applyColor(text, palette.type) - return paintDefault(text, defaultFg) - } - if (lang === 'go') { - if (KEYWORDS_GO.has(word)) return applyColor(text, palette.keyword) - if (/^[A-Z][a-zA-Z0-9_]*$/.test(word)) return applyColor(text, palette.type) - return paintDefault(text, defaultFg) - } - if (lang === 'rust') { - if (KEYWORDS_RUST.has(word)) return applyColor(text, palette.keyword) - if (/^[A-Z][a-zA-Z0-9_]*$/.test(word)) return applyColor(text, palette.type) - return paintDefault(text, defaultFg) - } - if (lang === 'shell') { - if (KEYWORDS_SHELL.has(word)) return applyColor(text, palette.keyword) + const rule = KEYWORD_RULES[lang] + if (rule) { + if (rule.keywords.has(word)) return applyColor(text, palette.keyword) + if (rule.pascalAsType && /^[A-Z][a-zA-Z0-9_]*$/.test(word)) return applyColor(text, palette.type) return paintDefault(text, defaultFg) } return applyColor(text, palette.keyword) diff --git a/packages/cli/src/ui/terminal-glyphs.ts b/packages/cli/src/ui/terminal-glyphs.ts index c7bcfe3..0fea42a 100644 --- a/packages/cli/src/ui/terminal-glyphs.ts +++ b/packages/cli/src/ui/terminal-glyphs.ts @@ -16,10 +16,6 @@ // in ChatInput.tsx: WT_SESSION → Windows Terminal (Cascadia Mono, full // Unicode); TERM_PROGRAM=vscode → VSCode integrated terminal; neither on // win32 → legacy ConHost. Non-Windows platforms always get rich glyphs. -// -// ConHost also has a scrollbar that eats ~1 column from the visible width -// while process.stdout.columns still reports the full buffer width. The -// RIGHT_MARGIN_SAFETY constant lets right-alignment calculations compensate. /** True when the terminal is a legacy ConHost that can't reliably render * Unicode beyond CP437 / Latin-1 Supplement (U+0000–U+00FF) and the @@ -27,12 +23,6 @@ export const IS_LEGACY_TERMINAL = process.platform === 'win32' && !process.env.WT_SESSION && process.env.TERM_PROGRAM !== 'vscode' -/** Extra columns to reserve on the right edge when right-aligning text. - * ConHost's vertical scrollbar overlaps the rightmost column(s) of the - * buffer, clipping characters that sit at `columns - 1`. Modern terminals - * (Windows Terminal, VSCode, iTerm2, etc.) don't have this issue. */ -export const RIGHT_MARGIN_SAFETY = IS_LEGACY_TERMINAL ? 1 : 0 - // ── Glyph table ───────────────────────────────────────────────────────── // // Each export pair: `GLYPH_NAME` = rich Unicode, fallback = ASCII/Latin-1. diff --git a/packages/cli/src/ui/tool-display.ts b/packages/cli/src/ui/utils.ts similarity index 59% rename from packages/cli/src/ui/tool-display.ts rename to packages/cli/src/ui/utils.ts index e936c0c..dc6e340 100644 --- a/packages/cli/src/ui/tool-display.ts +++ b/packages/cli/src/ui/utils.ts @@ -1,32 +1,80 @@ -// @x-code-cli/cli — Shared tool display utilities +// @x-code-cli/cli — Shared UI utilities. // -// Provides human-readable labels, input previews, and result summaries -// for tool calls. Used by ChatInput's scrollback writer (committed tool -// rows) and its in-frame live tool indicator (`● Tool / ⎿ ⠋ Running...`) -// so both render paths produce the same label / preview / summary text. -// -// Tool name matching is case-insensitive to handle model/provider -// variations (e.g. "listDir" vs "ListDir", "readFile" vs "Read"). -import { getShellProvider } from '@x-code-cli/core' +// Small helpers used by multiple modules. Extracted here to avoid +// copy-paste drift across files. import type { DisplayToolCall } from '@x-code-cli/core' +import { getShellProvider } from '@x-code-cli/core' -const SHELL_LABELS: Record = { - bash: 'Bash', - zsh: 'Zsh', - powershell: 'PowerShell', +// ── Layout constants ─────────────────────────────────────────────────── + +/** Indent for tool-result rows so the body aligns under the ` ⎿ ` + * bracket (3 spaces + bracket + 2 spaces = 6 cells). Used by both the + * scrollback writer (stdout-writer) and the diff renderer (render-diff). */ +export const RESULT_INDENT = ' ' + +// ── Line-ending normalization ────────────────────────────────────────── + +/** Normalize line endings to `\n`. Critical before any terminal write: + * Windows pastes / clipboard content commonly arrive with `\r\n` or bare + * `\r`, and a bare `\r` in the terminal means "move cursor to column 0 + * of the current row" — subsequent characters OVERWRITE whatever was + * previously printed on that row. */ +export function normalizeLineEndings(s: string): string { + return s.replace(/\r\n?/g, '\n') +} + +// ── Boolean argument parsing ─────────────────────────────────────────── + +/** Parse a string as a boolean for CLI argument consumption. + * Accepts `on/true/1/enable/enabled` → true, + * `off/false/0/disable/disabled` → false, + * everything else → null (caller can show an error). */ +export function parseBooleanArg(s: string): boolean | null { + const trimmed = s.trim().toLowerCase() + if (trimmed === 'on' || trimmed === 'true' || trimmed === '1' || trimmed === 'enable' || trimmed === 'enabled') + return true + if (trimmed === 'off' || trimmed === 'false' || trimmed === '0' || trimmed === 'disable' || trimmed === 'disabled') + return false + return null +} + +// ── Duration formatting ──────────────────────────────────────────────── + +export interface DurationFmtOptions { + /** Sub-second precision: number of decimal places for the seconds + * field when duration < 60s. Default 1. */ + precision?: number + /** When true, omit trailing 's' on seconds fields. Default false. */ + compact?: boolean +} + +/** + * Format a millisecond duration into a human-readable string. + * <1s → `"120ms"` + * <60s → `"3.5s"` (precision from options) + * >=60s → `"2m 15s"` (or `"2m"` when compact && secs === 0) + */ +export function formatDuration(ms: number, opts: DurationFmtOptions = {}): string { + const { precision = 1, compact = false } = opts + if (ms < 1000) return `${ms}ms` + const seconds = ms / 1000 + if (seconds < 60) return `${seconds.toFixed(precision)}s` + const minutes = Math.floor(seconds / 60) + const secs = Math.round(seconds % 60) + if (compact && secs === 0) return `${minutes}m` + return `${minutes}m ${secs}s` } -/** Normalize tool name to lowercase for matching */ -function normalizeName(name: string): string { +// ── Tool display helpers ─────────────────────────────────────────────── + +function normalizeToolName(name: string): string { return name.toLowerCase().replace(/[_-]/g, '') } -/** Tools whose calls can be folded into a single "● Read 3 files" summary line - * when 2+ of them appear consecutively in scrollback. Excludes WebSearch / - * WebFetch (their result blurbs carry meaningful info — collapsing hides it), - * Shell (no reliable read-only classification), and Task (sub-agent, not a - * read). Mirrors the categories Claude Code groups in its `collapseReadSearch` - * pipeline minus the model-tagged Bash branch. */ +export function isCollapsibleReadOnlyTool(toolName: string): boolean { + return COLLAPSIBLE_READ_ONLY_TOOLS.has(normalizeToolName(toolName)) +} + const COLLAPSIBLE_READ_ONLY_TOOLS: ReadonlySet = new Set([ 'readfile', 'read', @@ -37,89 +85,19 @@ const COLLAPSIBLE_READ_ONLY_TOOLS: ReadonlySet = new Set([ 'ls', ]) -export function isCollapsibleReadOnlyTool(toolName: string): boolean { - return COLLAPSIBLE_READ_ONLY_TOOLS.has(normalizeName(toolName)) -} - -/** Strip directory prefix — used for the "(foo.ts, bar.ts)" detail suffix. - * Handles both POSIX and Windows separators because tool inputs sometimes - * carry mixed slashes on Windows (model outputs `/` while ListDir results - * use `\`). */ -function basename(p: string): string { +export function basename(p: string): string { const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) return i >= 0 ? p.slice(i + 1) : p } -export interface ReadGroupSummary { - /** Bold-rendered label, e.g. "Read 3 files" or - * "Searched for 2 patterns, read 1 file". Mirrors the single-tool - * `Tool` portion of an existing tool row. */ - label: string - /** Optional paren'd detail rendered in BLUE_PURPLE to match the - * single-tool `(input)` suffix. Currently used to list the basename - * of files Read'd so users still see WHAT was read at a glance — - * losing that to a bare count makes the summary feel opaque. */ - detail?: string -} - -/** Build the label/detail pair for a collapsed read-group. Caller (the - * stdout-writer flush path) wraps the label in `c.bold` and the detail - * in `c.hex(BLUE_PURPLE)` to visually match a regular tool row. - * - * Bucket strategy: count by category (read / search / glob / list). - * Single-clause cases get pluralization right; mixed clauses join with - * ", " and only the first clause is capitalized so the line reads as - * one sentence ("Read 2 files, searched for 1 pattern"). */ -export function formatReadGroupSummary(tools: readonly DisplayToolCall[]): ReadGroupSummary { - let readCount = 0 - let grepCount = 0 - let globCount = 0 - let lsCount = 0 - const readPaths: string[] = [] - - for (const tc of tools) { - const n = normalizeName(tc.toolName) - if (n === 'read' || n === 'readfile') { - readCount++ - const p = (tc.input.filePath as string) || (tc.input.file_path as string) || (tc.input.path as string) || '' - if (p) readPaths.push(basename(p)) - } else if (n === 'grep' || n === 'search') { - grepCount++ - } else if (n === 'glob') { - globCount++ - } else if (n === 'listdir' || n === 'ls') { - lsCount++ - } - } - - const clauses: string[] = [] - if (readCount > 0) clauses.push(`read ${readCount} file${readCount === 1 ? '' : 's'}`) - if (grepCount > 0) clauses.push(`searched for ${grepCount} pattern${grepCount === 1 ? '' : 's'}`) - if (globCount > 0) clauses.push(`globbed ${globCount} pattern${globCount === 1 ? '' : 's'}`) - if (lsCount > 0) clauses.push(`listed ${lsCount} director${lsCount === 1 ? 'y' : 'ies'}`) - - if (clauses.length > 0) { - const first = clauses[0]! - clauses[0] = first.charAt(0).toUpperCase() + first.slice(1) - } - const label = clauses.join(', ') - - // Sample basenames for read calls so the summary still says WHAT was - // read. Cap at 3 names; anything beyond becomes "+N more" so very long - // chains don't wrap onto a second line. - let detail: string | undefined - if (readPaths.length > 0) { - const shown = readPaths.slice(0, 3).join(', ') - const rest = readPaths.length > 3 ? `, +${readPaths.length - 3} more` : '' - detail = shown + rest - } - - return detail ? { label, detail } : { label } +const SHELL_LABELS: Record = { + bash: 'Bash', + zsh: 'Zsh', + powershell: 'PowerShell', } -/** Map tool name → human-readable label for display */ export function getToolLabel(toolName: string): string { - const n = normalizeName(toolName) + const n = normalizeToolName(toolName) if (n === 'shell' || n === 'bash') return SHELL_LABELS[getShellProvider().type] ?? 'Shell' if (n === 'readfile' || n === 'read') return 'Read' if (n === 'writefile' || n === 'write') return 'Write' @@ -138,63 +116,39 @@ export function getToolLabel(toolName: string): string { return toolName } -/** - * Extract the most relevant input preview for a tool call. - * Tries to find a file path, pattern, command, or query — never falls - * back to JSON.stringify (which produces escaped backslashes on Windows). - */ export function getToolInputPreview(toolName: string, input: Record): string { - const n = normalizeName(toolName) + const n = normalizeToolName(toolName) - // Shell / Bash — show the command if (n === 'shell' || n === 'bash') { return (input.command as string) || '' } - // File operations — show the file path if (n === 'readfile' || n === 'read' || n === 'writefile' || n === 'write' || n === 'edit' || n === 'update') { return (input.filePath as string) || (input.file_path as string) || (input.path as string) || '' } - // Directory listing if (n === 'listdir' || n === 'ls') { return (input.dirPath as string) || (input.dir_path as string) || (input.path as string) || '' } - // Pattern-based tools if (n === 'glob' || n === 'grep' || n === 'search') { return (input.pattern as string) || (input.query as string) || '' } - // Web tools if (n === 'websearch' || n === 'webfetch') { return (input.query as string) || (input.url as string) || '' } - // Task (sub-agent) — only show the description; subagent_type - // (explore, shell, etc.) is internal detail and redundant with - // the description the model already chose. if (n === 'task') { return (input.description as string) || '' } - // AskUser — show only the first line of the question in the - // preview (the title row). Full question text can be very long - // multi-paragraph markdown; collapsing it into one line makes - // it overflow the terminal width and become unreadable. if (n === 'askuser') { const q = (input.question as string) || '' const firstLine = q.split(/\r?\n/)[0]?.trim() || '' return firstLine } - // Generic: try common parameter names before falling back - for (const key of ['filePath', 'file_path', 'path', 'dirPath', 'dir_path', 'command', 'pattern', 'query', 'url']) { - if (typeof input[key] === 'string' && input[key]) { - return input[key] - } - } - // Last resort: show first string value (NOT JSON.stringify) for (const val of Object.values(input)) { if (typeof val === 'string' && val.length <= 100) return val @@ -203,15 +157,68 @@ export function getToolInputPreview(toolName: string, input: Record= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M` + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k` + return String(tokens) +} + +export interface ReadGroupSummary { + label: string + detail?: string +} + +export function formatReadGroupSummary(tools: readonly DisplayToolCall[]): ReadGroupSummary { + let readCount = 0 + let grepCount = 0 + let globCount = 0 + let lsCount = 0 + const readPaths: string[] = [] + + for (const tc of tools) { + const n = normalizeToolName(tc.toolName) + if (n === 'read' || n === 'readfile') { + readCount++ + const p = (tc.input.filePath as string) || (tc.input.file_path as string) || (tc.input.path as string) || '' + if (p) readPaths.push(basename(p)) + } else if (n === 'grep' || n === 'search') { + grepCount++ + } else if (n === 'glob') { + globCount++ + } else if (n === 'listdir' || n === 'ls') { + lsCount++ + } + } + + const clauses: string[] = [] + if (readCount > 0) clauses.push(`read ${readCount} file${readCount === 1 ? '' : 's'}`) + if (grepCount > 0) clauses.push(`searched for ${grepCount} pattern${grepCount === 1 ? '' : 's'}`) + if (globCount > 0) clauses.push(`globbed ${globCount} pattern${globCount === 1 ? '' : 's'}`) + if (lsCount > 0) clauses.push(`listed ${lsCount} director${lsCount === 1 ? 'y' : 'ies'}`) + + if (clauses.length > 0) { + const first = clauses[0]! + clauses[0] = first.charAt(0).toUpperCase() + first.slice(1) + } + const label = clauses.join(', ') + + let detail: string | undefined + if (readPaths.length > 0) { + const shown = readPaths.slice(0, 3).join(', ') + const rest = readPaths.length > 3 ? `, +${readPaths.length - 3} more` : '' + detail = shown + rest + } + + return detail ? { label, detail } : { label } +} + export function getToolResultSummary(toolName: string, output: string | undefined, status: string): string | null { if (status === 'denied') return 'Denied by user' if (!output) return 'Done' - const n = normalizeName(toolName) + const n = normalizeToolName(toolName) if (n === 'writefile' || n === 'write') { - // Result format: "File created: (N lines)" or "File written: (N lines)" const m = output.match(/\((\d+) lines?\)/) if (m) return `Wrote ${m[1]} lines` return 'Wrote file' @@ -252,7 +259,6 @@ export function getToolResultSummary(toolName: string, output: string | undefine return `${lines.length} result${lines.length !== 1 ? 's' : ''}` } - // Task (sub-agent) — show a CC-style stats summary line if (n === 'task') { const statsMatch = output.match(//) const resultMatch = output.match(/\n?([\s\S]*?)\n?<\/task_result>/) @@ -268,7 +274,7 @@ export function getToolResultSummary(toolName: string, output: string | undefine const durationMs = parseInt(statsMatch[3]!, 10) const toolStr = toolCalls === 1 ? '1 tool use' : `${toolCalls} tool uses` const tokenStr = formatTokenCount(tokens) - const durStr = formatTaskDuration(durationMs) + const durStr = formatDuration(durationMs, { compact: true, precision: 0 }) return `Done (${toolStr} · ${tokenStr} tokens · ${durStr})` } @@ -277,12 +283,6 @@ export function getToolResultSummary(toolName: string, output: string | undefine return lines.slice(0, 2).join('\n') + `\n... +${lines.length - 2} lines` } - // Web tools — compact one-line status in scrollback, matching Claude - // Code's pattern. The "which websites" info lives on the STREAMING - // progress line (⎿ ⠋ Found N results: host1, host2, host3) while the - // tool is in-flight; once it finishes we collapse to a tight summary - // so the history stays readable. Duration is appended by - // stdout-writer.formatToolCall → `Did 1 search (6s)` / `Fetched page (1.2s)`. if (n === 'websearch') { return 'Did 1 search' } @@ -293,7 +293,6 @@ export function getToolResultSummary(toolName: string, output: string | undefine if (n === 'shell' || n === 'bash') { let text = output.trim() - // Strip legacy "exit code: 0" prefix from old format results text = text.replace(/^exit code: 0\n?/, '') const lines = text.split('\n').filter((l) => l.trim()) if (lines.length === 0) return 'Done' @@ -301,7 +300,6 @@ export function getToolResultSummary(toolName: string, output: string | undefine return lines.slice(0, 3).join('\n') + `\n... +${lines.length - 3} lines` } - // Generic: show first few lines const lines = output .trim() .split('\n') @@ -310,18 +308,3 @@ export function getToolResultSummary(toolName: string, output: string | undefine if (lines.length <= 3) return lines.join('\n') return lines.slice(0, 2).join('\n') + `\n... +${lines.length - 2} lines` } - -function formatTokenCount(tokens: number): string { - if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M` - if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k` - return String(tokens) -} - -function formatTaskDuration(ms: number): string { - if (ms < 1_000) return `${ms}ms` - const seconds = Math.floor(ms / 1_000) - if (seconds < 60) return `${seconds}s` - const minutes = Math.floor(seconds / 60) - const remainingSecs = seconds % 60 - return remainingSecs > 0 ? `${minutes}m ${remainingSecs}s` : `${minutes}m` -} diff --git a/packages/core/src/agent/sub-agents/runner.ts b/packages/core/src/agent/sub-agents/runner.ts index 3be98d1..b98b665 100644 --- a/packages/core/src/agent/sub-agents/runner.ts +++ b/packages/core/src/agent/sub-agents/runner.ts @@ -176,7 +176,7 @@ export async function runSubAgent(args: RunSubAgentArgs, parentModel: LanguageMo onTextDelta: (delta) => { callbacks.onSubAgentEvent?.({ kind: 'text-delta', toolCallId, delta }) }, - onToolCall: (subToolCallId, subToolName, subInput) => { + onToolCall: (_subToolCallId, subToolName, subInput) => { callbacks.onSubAgentEvent?.({ kind: 'tool-call', toolCallId, @@ -184,7 +184,7 @@ export async function runSubAgent(args: RunSubAgentArgs, parentModel: LanguageMo subInput, }) // Also forward to parent's onToolProgress so the live indicator updates - callbacks.onToolProgress(toolCallId, `${subToolName}: ${previewInput(subToolName, subInput)}`) + callbacks.onToolProgress(toolCallId, `${subToolName}: ${previewInput(subInput)}`) }, onToolProgress: (_subToolCallId, message) => { callbacks.onToolProgress(toolCallId, message) @@ -214,7 +214,6 @@ export async function runSubAgent(args: RunSubAgentArgs, parentModel: LanguageMo }, } - let aborted = false try { const finalSubState = await agentLoop(prompt, subModel, subOptions, subCallbacks, subState) @@ -266,7 +265,6 @@ export async function runSubAgent(args: RunSubAgentArgs, parentModel: LanguageMo const durationMs = Date.now() - startTime if (isAbortError(err, parentOptions.abortSignal)) { - aborted = true const partial = extractFinalText(subState.messages) const text = partial ? `[Sub-agent interrupted by user]\n\nPartial output:\n${partial}` @@ -349,7 +347,7 @@ function countToolCalls(messages: LoopState['messages']): number { return count } -function previewInput(toolName: string, input: Record): string { +function previewInput(input: Record): string { const val = (input.filePath as string) ?? (input.command as string) ?? diff --git a/packages/core/src/agent/tool-execution.ts b/packages/core/src/agent/tool-execution.ts index 7e3ddbb..0200df4 100644 --- a/packages/core/src/agent/tool-execution.ts +++ b/packages/core/src/agent/tool-execution.ts @@ -584,10 +584,7 @@ export async function processToolCalls( // a future turn; if the guard fires, defer the user-role nudge // until after iteration. if (fulfilledIds.has(tc.toolCallId)) { - debugLog( - 'tool-exec.skip-fulfilled', - `${tc.toolName} ${tc.toolCallId} — tool-result already in state.messages`, - ) + debugLog('tool-exec.skip-fulfilled', `${tc.toolName} ${tc.toolCallId} — tool-result already in state.messages`) const loopCheck = checkForLoop(state, tc.toolName, tc.input, tc.toolCallId) recordToolCall(state, tc.toolName, tc.input, loopCheck.hash) if (loopCheck.kind !== 'ok') { @@ -626,9 +623,7 @@ export async function processToolCalls( break } - await Promise.all( - batch.map((tc) => handleToolCall(tc, state, options, callbacks, parentModel, deferred)), - ) + await Promise.all(batch.map((tc) => handleToolCall(tc, state, options, callbacks, parentModel, deferred))) dispatched += batch.length } diff --git a/packages/core/src/permissions/session-store.ts b/packages/core/src/permissions/session-store.ts index ac340b6..c0cd62c 100644 --- a/packages/core/src/permissions/session-store.ts +++ b/packages/core/src/permissions/session-store.ts @@ -72,7 +72,17 @@ export function extractCommandPrefix(command: string): string | null { // -File — no useful prefix; bail. if (lower === '-file') return null // Flags that take an argument (consume next token too). - if (lower === '-executionpolicy' || lower === '-encodedcommand' || lower === '-inputformat' || lower === '-outputformat' || lower === '-version' || lower === '-windowstyle' || lower === '-configurationname' || lower === '-mta' || lower === '-sta') { + if ( + lower === '-executionpolicy' || + lower === '-encodedcommand' || + lower === '-inputformat' || + lower === '-outputformat' || + lower === '-version' || + lower === '-windowstyle' || + lower === '-configurationname' || + lower === '-mta' || + lower === '-sta' + ) { i += 2 continue } diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index cf40e60..7bbbb0d 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -86,10 +86,7 @@ async function readTextResult(filePath: string, offset?: number, limit?: number) // message: tells the model exactly which next call will work, so it can // self-recover instead of giving up or repeating the same call. if (isHeadTruncation) { - const note = - includedLines < sliced.length - ? ` (further capped at ${MAX_READ_BYTES / 1024} KB)` - : '' + const note = includedLines < sliced.length ? ` (further capped at ${MAX_READ_BYTES / 1024} KB)` : '' return ( body + `\n\n[readFile: showing first ${includedLines}/${totalLines} lines${note}. ` + diff --git a/packages/core/tests/file-ingest.test.ts b/packages/core/tests/file-ingest.test.ts index edc7721..df6ea95 100644 --- a/packages/core/tests/file-ingest.test.ts +++ b/packages/core/tests/file-ingest.test.ts @@ -4,7 +4,13 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { buildUserContent, classifyFile, extractFileReferences, ingestFile, MAX_INGEST_BYTES } from '../src/agent/file-ingest.js' +import { + MAX_INGEST_BYTES, + buildUserContent, + classifyFile, + extractFileReferences, + ingestFile, +} from '../src/agent/file-ingest.js' import { captionImage, pickVisionProvider } from '../src/agent/vision-fallback.js' // Mock vision-fallback so the image-path test can prove the onNotice plumbing diff --git a/packages/core/tests/permissions.test.ts b/packages/core/tests/permissions.test.ts index a361c92..1622d53 100644 --- a/packages/core/tests/permissions.test.ts +++ b/packages/core/tests/permissions.test.ts @@ -194,7 +194,9 @@ describe('extractCommandPrefix', () => { it('handles powershell with leading flags before -Command', () => { // Real failure case from a.log: `-NoProfile` between launcher and `-Command` // hid the "don't ask again" option for every sub-agent shell call. - expect(extractCommandPrefix('powershell -NoProfile -Command "Get-CimInstance Win32_LogicalDisk"')).toBe('Get-CimInstance') + expect(extractCommandPrefix('powershell -NoProfile -Command "Get-CimInstance Win32_LogicalDisk"')).toBe( + 'Get-CimInstance', + ) expect(extractCommandPrefix('powershell -ExecutionPolicy Bypass -Command "git status"')).toBe('git') expect(extractCommandPrefix('powershell -NoLogo -NonInteractive -Command "Get-Process"')).toBe('Get-Process') expect(extractCommandPrefix('powershell -NoProfile -ExecutionPolicy Bypass -c "Get-CimInstance"')).toBe( diff --git a/packages/core/tests/process-tool-calls.test.ts b/packages/core/tests/process-tool-calls.test.ts index ce40544..95d5903 100644 --- a/packages/core/tests/process-tool-calls.test.ts +++ b/packages/core/tests/process-tool-calls.test.ts @@ -245,7 +245,12 @@ function shellAssistant(ids: string[]): ModelMessage { } as ModelMessage } -function toolResult(toolCallId: string, toolName: string, value: string, type: 'text' | 'error-text' = 'text'): ModelMessage { +function toolResult( + toolCallId: string, + toolName: string, + value: string, + type: 'text' | 'error-text' = 'text', +): ModelMessage { return { role: 'tool', content: [ @@ -420,7 +425,7 @@ describe('processToolCalls skip-fulfilled (SDK already produced a tool-result)', state.messages.push( { role: 'user', content: 'hi' } as ModelMessage, shellAssistant(['tc-1', 'tc-2']), - toolResult('tc-1', 'shell', 'first result'), // already fulfilled by SDK + toolResult('tc-1', 'shell', 'first result'), // already fulfilled by SDK ) const callbacks = makeCallbacks() await processToolCalls(