diff --git a/.qwen/design/tui-spacing-density-pr1.md b/.qwen/design/tui-spacing-density-pr1.md new file mode 100644 index 00000000000..dcf7993ebcb --- /dev/null +++ b/.qwen/design/tui-spacing-density-pr1.md @@ -0,0 +1,79 @@ +# TUI Spacing And Density PR1 + +## Why + +The current TUI often spends extra rows on spacing before assistant output, +between status/tool blocks, and inside expanded tool groups. In common +sessions this makes simple answers, file lists, tool output, error states, +diffs, and long streaming output harder to scan because users need to scroll +through blank space rather than content. + +This PR is the first focused pass for QwenLM/qwen-code#4588. It addresses only +spacing and density so the review can compare row usage before and after +without also reviewing thinking visibility, tool borders, SubAgent layout, +branding, or theme color changes. + +## How + +The implementation keeps the existing information structure and rendering +surfaces intact: + +- History item spacing is centralized near `HistoryItemDisplay`. User prompts + and standalone command views still start with a turn separator, while + assistant continuations, tool groups, status messages, tool summaries, and + related in-turn output no longer add an extra leading spacer row. +- Expanded tool groups keep their current border and status/title structure, + but no longer insert blank rows between adjacent tool entries. +- Tool results render directly below the tool title/status row. This removes + the extra blank line between the tool header and its output without changing + output content, truncation, shell focus, confirmation prompts, or compact + mode behavior. + +Markdown blank-line behavior is intentionally left unchanged. The renderer +already collapses consecutive blank lines to one spacer and preserves complex +blocks such as tables, code blocks, and math blocks. + +## Spacing Standard + +- Independent user turns keep one visual separator. +- Assistant output and in-turn follow-up blocks do not add a second separator. +- Tool header and tool result content are adjacent. +- Expanded multi-tool groups do not insert blank rows between each tool entry. +- Complex Markdown blocks keep their existing internal layout. + +## Expected Effect + +Under the same terminal width and same rendered content, target scenarios should +use fewer visible rows: + +- Simple Q&A should drop at least one visible row. +- Expanded tool output should drop at least one row for each rendered tool + result that previously had a blank header/result spacer. +- Multi-tool groups should drop one row between each adjacent tool entry. +- Project inspection, diff, file-list, error, and long-stream scenarios should + not gain rows unless terminal wrapping changes make that unavoidable. + +## Measurement + +The automated spacing assertions and terminal evidence use 100-column fixtures +for the changed rules: + +| Scenario | Width | Baseline rows | PR1 rows | Delta | Evidence | +| --- | ---: | ---: | ---: | ---: | --- | +| Simple assistant reply | 100 | 2 | 1 | -1 | leading history spacer removed | +| Tool header with one-line result | 100 | 3 | 2 | -1 | header and result are adjacent | +| Three-tool expanded group with rendered results | 100 | 16 | 11 | -5 | one header/result spacer removed per tool result and one inter-tool separator removed between adjacent tools | +| Full representative fixture | 100 | 26 | 19 | -7 | same rendered content captured in tmux | + +The snapshot diffs also cover the existing 80-column fixtures to confirm the +same row-count deltas in the current component test harness. + +## Out Of Scope + +- Hiding thinking traces. +- Removing tool borders. +- Redesigning SubAgent output. +- Changing startup branding or the banner. +- Changing theme colors. +- Adding per-turn assistant elapsed time. +- Changing table inline-code highlighting. diff --git a/.qwen/design/tui-user-message-half-line-pr2.md b/.qwen/design/tui-user-message-half-line-pr2.md new file mode 100644 index 00000000000..70817e2ab5a --- /dev/null +++ b/.qwen/design/tui-user-message-half-line-pr2.md @@ -0,0 +1,77 @@ +# TUI 间距优化 PR2 — 半行色带与紧凑间距 + +## 背景 + +PR1 通过去除工具组内部多余空行,初步收紧了 TUI 垂直间距。但在实际使用中仍有两个体验问题: + +1. **用户消息与助手回复之间缺少视觉分界** — 长对话中难以快速定位"我的提问从哪里开始" +2. **块间距仍然偏大** — 问答交替处各有一整行空白,浪费屏幕空间 + +## 本次改动 + +### 1. 用户消息半行色带 + +在用户消息上下各添加一条半高的淡色线条,内容区域设置同色 backgroundColor,形成三层无缝色带: + +``` +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ← foreground = bandColor(底半格着色) +> 用户的提问内容 ← backgroundColor = bandColor(整行背景) +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ← foreground = bandColor(顶半格着色) +``` + +- 颜色通过 `subtleBandColor()` 计算:在背景色基础上做 6% 纯亮度偏移(暗色终端 → 稍亮,浅色终端 → 稍暗),不引入色相变化 +- 不支持 24 位色的终端 / 屏幕阅读器 / NO_COLOR 环境自动降级为普通显示(marginTop=1) +- 负宽度/零宽度安全保护 + +### 2. 收紧问答间距 + +| 位置 | 改动前 | 改动后 | +|------|--------|--------| +| 用户消息上方 | 1 行空白 | 0(由色带提供视觉分隔;降级时保留 marginTop=1) | +| 模型输出上方 | 1 行空白 | 1 行空白(保留,区分思考过程和最终输出) | +| 工具调用/状态消息上方 | 1 行空白 | 0 | +| 思考文本末尾 | 可能有多余换行 | trimEnd() 避免双空行 | + +同一轮对话内的"回复 → 工具调用 → 回复"序列不再有多余空行,信息更紧凑连贯。 + +## 效果对比 + +**改动前:** +``` +(1 行空白) +> 帮我读取 package.json +(1 行空白) +✦ 好的,我来读取文件。 +(1 行空白) +┌ Read package.json ─────────┐ +│ ✓ Read package.json │ +└────────────────────────────┘ +(1 行空白) +✦ 文件内容如下:... + +(1 行空白) +┌─ 输入框 ──────────────────┐ +``` + +**改动后:** +``` +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +> 帮我读取 package.json +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +✦ 好的,我来读取文件。 +┌ Read package.json ─────────┐ +│ ✓ Read package.json │ +└────────────────────────────┘ +(1 行空白) +✦ 文件内容如下:... + +(1 行空白) +┌─ 输入框 ──────────────────┐ +``` + +## 未改动 + +- 工具调用边框样式保持不变 +- Markdown 正文段落间距保持不变(1 行已是终端最小单位) +- 深色/浅色主题色值不变 +- 输入区域(Composer)间距保持 marginTop=1 不变 diff --git a/.qwen/e2e-tests/tui-spacing-density-pr1.md b/.qwen/e2e-tests/tui-spacing-density-pr1.md new file mode 100644 index 00000000000..09187698ebd --- /dev/null +++ b/.qwen/e2e-tests/tui-spacing-density-pr1.md @@ -0,0 +1,92 @@ +# TUI Spacing And Density PR1 Evidence + +## Goal + +Provide before/after evidence that PR1 reduces visible row usage without +removing content or changing rendering scope. + +## Fixed Conditions + +- Terminal width: 100 columns. +- Compare the same prompt/output fixture before and after this PR. +- Strip ANSI control sequences before counting visible rows. +- Count rendered rows from the first non-empty fixture row through the last + non-empty fixture row. This keeps internal blank spacer rows in the metric + because those are the rows this PR removes. +- The fixture renders the real Ink TUI components directly, so it does not + require a model call or network access. + +## Scenarios + +- Simple Q&A. +- File list output. +- Long shell output. +- File-read error output. +- Multi-block project inspection output. +- Diff output. +- Long streaming output. + +## Commands + +Terminal capture: + +```bash +git checkout origin/main +REPO_ROOT="$PWD" +/tmp/qwen-pr1-spacing-evidence/run-tmux-capture.sh "$REPO_ROOT" 'base origin/main 34b7d472e' base +git switch feat/tui-spacing-density-pr1 +/tmp/qwen-pr1-spacing-evidence/run-tmux-capture.sh "$REPO_ROOT" 'PR1 fixed 848d6a166' fixed +``` + +VHS visual capture: + +```bash +git checkout origin/main +PATH=/Users/gawain/.nvm/versions/node/v24.15.0/bin:$PATH vhs /tmp/qwen-pr1-spacing-evidence/base.tape +git switch feat/tui-spacing-density-pr1 +PATH=/Users/gawain/.nvm/versions/node/v24.15.0/bin:$PATH vhs /tmp/qwen-pr1-spacing-evidence/fixed.tape +ffmpeg -y -i /tmp/qwen-pr1-spacing-evidence/base.gif -i /tmp/qwen-pr1-spacing-evidence/fixed.gif -filter_complex "[0:v]fps=5,scale=780:-1:flags=lanczos[left];[1:v]fps=5,scale=780:-1:flags=lanczos[right];[left][right]hstack=inputs=2,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" /tmp/qwen-pr1-spacing-evidence/base-vs-fixed-optimized.gif +``` + +## Evidence Artifacts + +- Release: +- Side-by-side GIF: +- Final screenshot: +- Base tmux capture: +- Fixed tmux capture: +- Base summary JSON: +- Fixed summary JSON: + +## Expected Results + +- Simple Q&A: at least 1 fewer visible row. +- Expanded tool output: at least 1 fewer visible row per rendered tool result + that previously had a blank header/result spacer. +- Multi-tool expanded groups: 1 fewer visible row between each adjacent tool + entry. +- No scenario should lose user-visible content. + +## Results + +| Scenario | Width | Baseline rows | PR1 rows | Delta | Notes | +| --- | ---: | ---: | ---: | ---: | --- | +| Simple Q&A | 100 | 2 | 1 | -1 | Assistant history item no longer starts with a spacer row | +| File list or shell output | 100 | 3 | 2 | -1 | Tool header and first result row are adjacent | +| File-read error | 100 | 3 | 2 | -1 | Error result uses the same tool header/result spacing | +| Project inspection | 100 | 16 | 11 | -5 | Three expanded tools no longer have header/result spacer rows or blank inter-tool rows | +| Diff output | 100 | 3 | 2 | -1 | Diff renderer remains unchanged; only tool header/result spacing changes | +| Long streaming output | 100 | N + 2 | N + 1 | -1 | Content rows are unchanged; the extra header/result spacer is removed | +| Full representative fixture | 100 | 26 | 19 | -7 | Same content rendered through real Ink components and captured in tmux | + +## What This Proves + +- The base branch reproduces the extra spacer rows in a real terminal capture. +- PR1 removes the targeted spacer rows while preserving the same fixture content. +- The row-count improvement is measurable under fixed 100-column conditions. + +## What This Does Not Prove + +- It does not cover later PR scopes such as thinking trace visibility, tool + border removal, SubAgent layout, branding, or theme colors. +- It does not replace manual review for extremely narrow terminal wrapping. diff --git a/package-lock.json b/package-lock.json index 5684e81e6ec..871a169f887 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "@qwen-code/qwen-code", "version": "0.17.1", - "hasInstallScript": true, "workspaces": [ "packages/*", "packages/channels/base", @@ -4784,13 +4783,6 @@ "kleur": "^3.0.3" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/proper-lockfile": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", @@ -14726,7 +14718,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -19421,7 +19412,7 @@ "fzf": "^0.5.2", "glob": "^10.5.0", "highlight.js": "^11.11.1", - "ink": "7.0.3", + "ink": "^7.0.3", "ink-gradient": "^3.0.0", "ink-link": "^4.1.0", "ink-spinner": "^5.0.0", @@ -22712,27 +22703,6 @@ "node": ">=12" } }, - "packages/web-templates/node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "packages/web-templates/node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, "packages/web-templates/node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 443a28d187e..edb3c3ac648 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -58,6 +58,37 @@ describe('', () => { expect(lastFrame()).toContain('/theme'); }); + it('renders assistant replies with a leading spacer row', () => { + const item: HistoryItem = { + id: 1, + type: 'gemini', + text: 'Hello', + }; + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame() ?? ''; + expect(output.startsWith('\n')).toBe(true); + expect(output).toContain('✦ Hello'); + }); + + it('renders tool summaries without a leading spacer row', () => { + const item: HistoryItem = { + id: 1, + type: 'tool_use_summary', + summary: 'Read txt files', + precedingToolUseIds: ['c1'], + }; + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame() ?? ''; + expect(output.startsWith('\n')).toBe(false); + expect(output).toContain('Read txt files'); + }); + it('renders StatsDisplay for "stats" type', () => { const item: HistoryItem = { ...baseItem, diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 235c8a60e70..e3e98f7d307 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -87,6 +87,38 @@ interface HistoryItemDisplayProps { sourceCopyIndexOffsets?: MarkdownSourceCopyIndexOffsets; } +function getHistoryItemMarginTop(item: HistoryItem): number { + switch (item.type) { + case 'gemini': + return 1; + case 'gemini_content': + case 'gemini_thought': + case 'gemini_thought_content': + case 'info': + case 'success': + case 'warning': + case 'error': + case 'retry_countdown': + case 'memory_saved': + case 'tool_group': + case 'tool_use_summary': + case 'notification': + case 'compression': + case 'summary': + case 'insight_progress': + case 'btw': + case 'away_recap': + case 'user': + case 'user_prompt_submit_blocked': + case 'stop_hook_loop': + case 'stop_hook_system_message': + case 'goal_status': + return 0; + default: + return 1; + } +} + const HistoryItemDisplayComponent: React.FC = ({ item, availableTerminalHeight, @@ -102,25 +134,13 @@ const HistoryItemDisplayComponent: React.FC = ({ summaryAbsorbed = false, sourceCopyIndexOffsets, }) => { - const { compactMode } = useCompactMode(); - - const isHiddenInCompact = - compactMode && - (item.type === 'gemini_thought' || - item.type === 'gemini_thought_content' || - (item.type === 'tool_use_summary' && summaryAbsorbed)); + const marginTop = getHistoryItemMarginTop(item); + const { compactMode } = useCompactMode(); const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]); const contentWidth = terminalWidth - 4; const boxWidth = mainAreaWidth || contentWidth; - if (isHiddenInCompact) return null; - - const marginTop = - item.type === 'gemini_content' || item.type === 'gemini_thought_content' - ? 0 - : 1; - return ( = ({ > {/* Render standard message types */} {itemForDisplay.type === 'user' && ( - + )} {itemForDisplay.type === 'notification' && ( @@ -163,7 +183,7 @@ const HistoryItemDisplayComponent: React.FC = ({ )} {!compactMode && itemForDisplay.type === 'gemini_thought' && ( = ({ )} {!compactMode && itemForDisplay.type === 'gemini_thought_content' && ( = ({ text }) => ( - -); +export const UserMessage: React.FC = ({ text, width }) => { + const isScreenReaderEnabled = useIsScreenReaderEnabled(); + + const useBand = + width !== undefined && + width > 0 && + !isScreenReaderEnabled && + !!theme.background.primary && + supportsTrueColor(); + + const fallback = ( + + ); + + if (!useBand) { + return fallback; + } + + const bg = resolveColor(theme.background.primary) || theme.background.primary; + const bandColor = subtleBandColor(bg); + if (!bandColor) { + return fallback; + } + + const prefix = '> '; + const lines = text.split('\n'); + + return ( + + {'▄'.repeat(width)} + {lines.map((line, i) => { + const linePrefix = i === 0 ? prefix : ' '; + const lineWidth = stringWidth(linePrefix + line); + const pad = Math.max(0, width - lineWidth); + return ( + + + {linePrefix} + {line} + + {pad > 0 ? ' '.repeat(pad) : ''} + + ); + })} + {'▀'.repeat(width)} + + ); +}; export const UserShellMessage: React.FC = ({ text }) => { const commandToDisplay = text.startsWith('!') ? text.substring(1) : text; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index bd0c9e80aea..c99669bd3c6 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -155,6 +155,26 @@ describe('', () => { expect(lastFrame()).toMatchSnapshot(); }); + it('renders expanded tool entries without blank separator rows', () => { + const toolCalls = [ + createToolCall({ callId: 'tool-1', name: 'first-tool' }), + createToolCall({ callId: 'tool-2', name: 'second-tool' }), + ]; + const { lastFrame } = renderWithProviders( + , + ); + const lines = (lastFrame() ?? '').split('\n'); + const firstLine = lines.findIndex((line) => line.includes('tool-1')); + const secondLine = lines.findIndex((line) => line.includes('tool-2')); + + expect(firstLine).toBeGreaterThanOrEqual(0); + expect(secondLine).toBe(firstLine + 1); + }); + it('renders tool call awaiting confirmation', () => { const toolCalls = [ createToolCall({ diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index c54b43a3951..74ca87ea5fc 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -426,7 +426,7 @@ export const ToolGroupMessage: React.FC = ({ hasPending && (!isShellCommand || !isEmbeddedShellFocused) } borderColor={borderColor} - gap={1} + gap={0} > {/* Memory badge for mixed groups (some memory ops + other ops) */} {!isMemoryOnlyGroup && diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 21ab2df5880..d9949cc851e 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -163,6 +163,21 @@ describe('', () => { expect(output).toContain('MockMarkdown:Test result'); }); + it('renders tool results directly below the header row', () => { + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const lines = (lastFrame() ?? '').split('\n'); + const headerLine = lines.findIndex((line) => line.includes('test-tool')); + const resultLine = lines.findIndex((line) => + line.includes('MockMarkdown:Test result'), + ); + + expect(headerLine).toBeGreaterThanOrEqual(0); + expect(resultLine).toBe(headerLine + 1); + }); + it('hides result output in compact mode (compactMode=true)', () => { const { lastFrame } = renderWithContext( , diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index d90bc5bd041..da4ee28a3a1 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -699,7 +699,7 @@ export const ToolMessage: React.FC = ({ {emphasis === 'high' && } {effectiveDisplayRenderer.type !== 'none' && ( - + {effectiveDisplayRenderer.type === 'todo' && ( diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index 4dbd1c399f7..28a00dabde2 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -3,7 +3,6 @@ exports[` > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │ -│ │ │MockTool[tool-2]: ✓ another-tool - A tool for testing (medium) │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; @@ -24,7 +23,6 @@ exports[` > Confirmation Handling > shows confirmation dialo "╭──────────────────────────────────────────────────────────────────────────────╮ │MockTool[tool-1]: ? first-confirm - A tool for testing (high) │ │MockConfirmation: Confirm first tool │ -│ │ │MockTool[tool-2]: ? second-confirm - A tool for testing (low) │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; @@ -37,9 +35,7 @@ exports[` > Golden Snapshots > renders empty tool calls arra exports[` > Golden Snapshots > renders mixed tool calls including shell command 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │MockTool[tool-1]: ✓ read_file - Read a file (medium) │ -│ │ │MockTool[tool-2]: ⊷ run_shell_command - Run command (medium) │ -│ │ │MockTool[tool-3]: o write_file - Write to file (medium) │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; @@ -47,9 +43,7 @@ exports[` > Golden Snapshots > renders mixed tool calls incl exports[` > Golden Snapshots > renders multiple tool calls with different statuses 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │MockTool[tool-1]: ✓ successful-tool - This tool succeeded (medium) │ -│ │ │MockTool[tool-2]: o pending-tool - This tool is pending (medium) │ -│ │ │MockTool[tool-3]: x error-tool - This tool failed (medium) │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; @@ -83,7 +77,6 @@ exports[` > Golden Snapshots > renders when not focused 1`] exports[` > Golden Snapshots > renders with limited terminal height 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │MockTool[tool-1]: ✓ tool-with-result - Tool with output (medium) │ -│ │ │MockTool[tool-2]: ✓ another-tool - Another tool (medium) │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; @@ -100,9 +93,7 @@ exports[` > Golden Snapshots > renders with narrow terminal exports[` > Height Calculation > calculates available height correctly with multiple tools with results 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │MockTool[tool-1]: ✓ test-tool - A tool for testing (medium) │ -│ │ │MockTool[tool-2]: ✓ test-tool - A tool for testing (medium) │ -│ │ │MockTool[tool-3]: ✓ test-tool - A tool for testing (medium) │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/themes/color-utils.test.ts b/packages/cli/src/ui/themes/color-utils.test.ts index dfb1be83c86..233b536d870 100644 --- a/packages/cli/src/ui/themes/color-utils.test.ts +++ b/packages/cli/src/ui/themes/color-utils.test.ts @@ -6,6 +6,9 @@ import { describe, it, expect } from 'vitest'; import { + interpolateColor, + subtleBandColor, + supportsTrueColor, isValidColor, resolveColor, CSS_NAME_TO_HEX_MAP, @@ -218,4 +221,69 @@ describe('Color Utils', () => { } }); }); + + describe('interpolateColor', () => { + it('returns color1 when factor <= 0', () => { + expect(interpolateColor('#000000', '#ffffff', 0)).toBe('#000000'); + expect(interpolateColor('#000000', '#ffffff', -1)).toBe('#000000'); + }); + + it('returns color2 when factor >= 1', () => { + expect(interpolateColor('#000000', '#ffffff', 1)).toBe('#ffffff'); + expect(interpolateColor('#000000', '#ffffff', 2)).toBe('#ffffff'); + }); + + it('blends two hex colors at 50%', () => { + expect(interpolateColor('#000000', '#ffffff', 0.5)).toBe('#808080'); + }); + + it('blends with a small factor', () => { + const result = interpolateColor('#000000', '#ffffff', 0.06); + expect(result).toMatch(/^#[0-9a-f]{6}$/); + expect(result).not.toBe('#000000'); + }); + + it('handles 3-digit hex shorthand', () => { + expect(interpolateColor('#000', '#fff', 0.5)).toBe('#808080'); + }); + + it('handles Ink color names', () => { + expect(interpolateColor('black', 'white', 0.5)).toBe('#808080'); + }); + + it('returns empty string for unparseable input', () => { + expect(interpolateColor('notacolor', '#ffffff', 0.5)).toBe(''); + expect(interpolateColor('#ffffff', 'notacolor', 0.5)).toBe(''); + }); + }); + + describe('subtleBandColor', () => { + it('shifts dark background toward white', () => { + const result = subtleBandColor('#000000'); + expect(result).toMatch(/^#[0-9a-f]{6}$/); + expect(result).not.toBe('#000000'); + const r = parseInt(result.slice(1, 3), 16); + expect(r).toBeGreaterThan(0); + expect(r).toBeLessThan(30); + }); + + it('shifts light background toward black', () => { + const result = subtleBandColor('#ffffff'); + expect(result).toMatch(/^#[0-9a-f]{6}$/); + expect(result).not.toBe('#ffffff'); + const r = parseInt(result.slice(1, 3), 16); + expect(r).toBeGreaterThan(225); + expect(r).toBeLessThan(255); + }); + + it('returns empty string for unparseable input', () => { + expect(subtleBandColor('notacolor')).toBe(''); + }); + }); + + describe('supportsTrueColor', () => { + it('returns a boolean', () => { + expect(typeof supportsTrueColor()).toBe('boolean'); + }); + }); }); diff --git a/packages/cli/src/ui/themes/color-utils.ts b/packages/cli/src/ui/themes/color-utils.ts index 08caf6b0013..06db89765b2 100644 --- a/packages/cli/src/ui/themes/color-utils.ts +++ b/packages/cli/src/ui/themes/color-utils.ts @@ -233,3 +233,125 @@ export function resolveColor(colorValue: string): string | undefined { ); return undefined; } + +// Basic Ink color names that resolveColor passes through as names rather than +// hex. Needed so interpolateColor can blend them numerically. +const INK_NAME_TO_HEX: Readonly> = { + black: '#000000', + red: '#ff0000', + green: '#00ff00', + yellow: '#ffff00', + blue: '#0000ff', + cyan: '#00ffff', + magenta: '#ff00ff', + white: '#ffffff', + gray: '#808080', + grey: '#808080', + blackbright: '#808080', + redbright: '#ff8080', + greenbright: '#80ff80', + yellowbright: '#ffff80', + bluebright: '#8080ff', + cyanbright: '#80ffff', + magentabright: '#ff80ff', + whitebright: '#ffffff', +}; + +/** + * Resolves any accepted color string to a 6-digit hex (#rrggbb), or undefined + * if it cannot be parsed into RGB. + */ +function toHex(color: string): string | undefined { + const resolved = (resolveColor(color) ?? color).toLowerCase(); + if (resolved.startsWith('#')) { + if (/^#[0-9a-f]{3}$/.test(resolved)) { + return `#${resolved + .slice(1) + .split('') + .map((c) => c + c) + .join('')}`; + } + if (/^#[0-9a-f]{6}$/.test(resolved)) { + return resolved; + } + return undefined; + } + return INK_NAME_TO_HEX[resolved]; +} + +/** + * Linearly blends two colors in RGB space. + * @param color1 Returned (unchanged) when factor <= 0. + * @param color2 Returned (unchanged) when factor >= 1. + * @param factor Blend amount in [0, 1] from color1 toward color2. + * @returns A #rrggbb hex string, or '' if either color cannot be parsed. + */ +export function interpolateColor( + color1: string, + color2: string, + factor: number, +): string { + if (factor <= 0) { + return color1; + } + if (factor >= 1) { + return color2; + } + const h1 = toHex(color1); + const h2 = toHex(color2); + if (!h1 || !h2) { + return ''; + } + const r1 = parseInt(h1.slice(1, 3), 16); + const g1 = parseInt(h1.slice(3, 5), 16); + const b1 = parseInt(h1.slice(5, 7), 16); + const r2 = parseInt(h2.slice(1, 3), 16); + const g2 = parseInt(h2.slice(3, 5), 16); + const b2 = parseInt(h2.slice(5, 7), 16); + const lerp = (a: number, b: number) => Math.round(a + (b - a) * factor); + const toByte = (n: number) => + Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0'); + return `#${toByte(lerp(r1, r2))}${toByte(lerp(g1, g2))}${toByte(lerp(b1, b2))}`; +} + +/** + * Computes a subtle band color by shifting the background brightness toward + * white (dark themes) or black (light themes) by `factor` (default 0.06). + * No hue change — just a brightness nudge, so the band is nearly invisible. + * Automatically detects dark/light from the background color luminance. + * Returns '' if the background color cannot be resolved. + */ +export function subtleBandColor(bgColor: string, factor = 0.06): string { + const hex = toHex(bgColor); + if (!hex) { + return ''; + } + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const isDark = (r * 299 + g * 587 + b * 114) / 1000 < 128; + const target = isDark ? '#ffffff' : '#000000'; + return interpolateColor(hex, target, factor); +} + +/** + * Detects whether the terminal supports 24-bit (true) color, required for the + * blended half-line background band. Result is cached at module scope since + * terminal color capability does not change during the process lifetime. + */ +let _supportsTrueColor: boolean | undefined; +export function supportsTrueColor(): boolean { + if (_supportsTrueColor !== undefined) return _supportsTrueColor; + const colorterm = process.env['COLORTERM']; + if ( + colorterm === 'truecolor' || + colorterm === '24bit' || + colorterm === 'kmscon' + ) { + return (_supportsTrueColor = true); + } + if (process.stdout.getColorDepth && process.stdout.getColorDepth() >= 24) { + return (_supportsTrueColor = true); + } + return (_supportsTrueColor = false); +}