diff --git a/.qwen/design/tui-spacing-density-pr1.md b/.qwen/design/tui-spacing-density-pr1.md new file mode 100644 index 0000000000..dcf7993ebc --- /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 0000000000..f6f9fc23f8 --- /dev/null +++ b/.qwen/design/tui-user-message-half-line-pr2.md @@ -0,0 +1,76 @@ +# TUI 间距优化 PR2 — 半行色带与紧凑间距 + +## 背景 + +PR1 通过去除工具组内部多余空行,初步收紧了 TUI 垂直间距。但在实际使用中仍有两个体验问题: + +1. **用户消息与助手回复之间缺少视觉分界** — 长对话中难以快速定位"我的提问从哪里开始" +2. **块间距仍然偏大** — 输入区域上方、问答交替处各有一整行空白,浪费屏幕空间 + +## 本次改动 + +### 1. 用户消息半行色带 + +在用户消息上下各添加一条半高的淡色线条,形成类似"色带"的视觉效果: + +``` +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ← 淡色半行线(仅占半个字符高度) +> 用户的提问内容 +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ← 淡色半行线 +``` + +- 颜色自动适配深色/浅色主题,在背景色基础上混入 15% 主题强调色,不会过于显眼 +- 不支持 24 位色的终端自动降级为普通显示 + +### 2. 收紧问答间距 + +| 位置 | 改动前 | 改动后 | +|------|--------|--------| +| 用户消息上方 | 1 行空白 | 0(由色带提供视觉分隔) | +| 助手回复与工具之间 | 1 行空白 | 0 | +| 工具与下一段回复之间 | 1 行空白 | 0 | + +同一轮对话内的"回复 → 工具调用 → 回复"序列不再有多余空行,信息更紧凑连贯。 + +### 3. 输入区域分隔线 + +底部输入框上方的 1 行空白替换为一条半高淡色分隔线,视觉更轻,占用空间减半。 + +## 效果对比 + +**改动前:** +``` +(1 行空白) +> 帮我读取 package.json +(1 行空白) +✦ 好的,我来读取文件。 +(1 行空白) +┌ Read package.json ─────────┐ +│ ✓ Read package.json │ +└────────────────────────────┘ +(1 行空白) +✦ 文件内容如下:... + +(1 行空白) +┌─ 输入框 ──────────────────┐ +``` + +**改动后:** +``` +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +> 帮我读取 package.json +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +✦ 好的,我来读取文件。 +┌ Read package.json ─────────┐ +│ ✓ Read package.json │ +└────────────────────────────┘ +✦ 文件内容如下:... +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +┌─ 输入框 ──────────────────┐ +``` + +## 未改动 + +- 工具调用边框样式保持不变 +- Markdown 正文段落间距保持不变(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 0000000000..09187698eb --- /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/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 5e55f2efcb..9010366529 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -16,6 +16,11 @@ import { useUIActions } from '../contexts/UIActionsContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { theme } from '../semantic-colors.js'; +import { + interpolateColor, + resolveColor, + supportsTrueColor, +} from '../themes/color-utils.js'; import { StreamingState, type HistoryItemToolGroup } from '../types.js'; import { FeedbackDialog } from '../FeedbackDialog.js'; import { t } from '../../i18n/index.js'; @@ -91,8 +96,22 @@ export const Composer = () => { [uiActions], ); + const composerWidth = uiState.terminalWidth - 4; + const separatorColor = supportsTrueColor() + ? interpolateColor( + resolveColor(theme.background.primary || 'black') || 'black', + resolveColor(theme.text.secondary) || theme.text.secondary, + 0.15, + ) + : undefined; + return ( - + + {separatorColor && ( + + {'▄'.repeat(Math.max(0, composerWidth))} + + )} {!uiState.embeddedShellFocused && !suppressBottomLoadingIndicator && ( ', () => { expect(lastFrame()).toContain('/theme'); }); + it('renders assistant replies without a leading spacer row', () => { + const item: HistoryItem = { + id: 1, + type: 'gemini', + text: 'Hello', + }; + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame() ?? ''; + expect(output.startsWith('\n')).toBe(false); + 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 c5dc77614d..49bf86fcae 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -87,6 +87,37 @@ interface HistoryItemDisplayProps { sourceCopyIndexOffsets?: MarkdownSourceCopyIndexOffsets; } +function getHistoryItemMarginTop(item: HistoryItem): number { + switch (item.type) { + case 'gemini': + 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,10 +133,7 @@ const HistoryItemDisplayComponent: React.FC = ({ summaryAbsorbed = false, sourceCopyIndexOffsets, }) => { - const marginTop = - item.type === 'gemini_content' || item.type === 'gemini_thought_content' - ? 0 - : 1; + const marginTop = getHistoryItemMarginTop(item); const { compactMode } = useCompactMode(); const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]); @@ -122,7 +150,7 @@ const HistoryItemDisplayComponent: React.FC = ({ > {/* Render standard message types */} {itemForDisplay.type === 'user' && ( - + )} {itemForDisplay.type === 'notification' && ( diff --git a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap index c58c38dcab..c22e5cace4 100644 --- a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap @@ -1,8 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[` > should render a full gemini item when using availableTerminalHeightGemini 1`] = ` -" - ✦ Example code block: +" ✦ Example code block: 1 Line 1 2 Line 2 3 Line 3 @@ -110,8 +109,7 @@ exports[` > should render a full gemini_content item when `; exports[` > should render a truncated gemini item 1`] = ` -" - ✦ Example code block: +" ✦ Example code block: ... first 41 lines hidden ... 42 Line 42 43 Line 43 diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.tsx index b8d77596f1..8782b66f86 100644 --- a/packages/cli/src/ui/components/messages/ConversationMessages.tsx +++ b/packages/cli/src/ui/components/messages/ConversationMessages.tsx @@ -16,9 +16,9 @@ import { SCREEN_READER_MODEL_PREFIX, SCREEN_READER_USER_PREFIX, } from '../../textConstants.js'; - interface UserMessageProps { text: string; + width?: number; } interface UserShellMessageProps { @@ -185,16 +185,30 @@ const ContinuationMarkdownMessage: React.FC< ); }; -export const UserMessage: React.FC = ({ text }) => ( - -); +export const UserMessage: React.FC = ({ text, width }) => { + const content = ( + + ); + + if (width === undefined) { + return content; + } + + return ( + + {'▄'.repeat(width)} + {content} + {'▀'.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 c089ff3cf5..0b55c6a461 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 c54b43a395..74ca87ea5f 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 f559c55491..3daee810d3 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 cd68df0b74..460a46b3e1 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -684,7 +684,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 4dbd1c399f..28a00dabde 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/components/shared/HalfLinePaddedBox.tsx b/packages/cli/src/ui/components/shared/HalfLinePaddedBox.tsx new file mode 100644 index 0000000000..2aec38d2ba --- /dev/null +++ b/packages/cli/src/ui/components/shared/HalfLinePaddedBox.tsx @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useMemo } from 'react'; +import { Box, Text, useIsScreenReaderEnabled } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import { + interpolateColor, + resolveColor, + supportsTrueColor, +} from '../../themes/color-utils.js'; + +export interface HalfLinePaddedBoxProps { + /** + * Base accent color blended onto the terminal background at low opacity + * to produce a subtle half-block line color. + */ + bandColor: string; + /** + * Blend factor (0–1) from terminal background toward bandColor. + * Lower = more subtle. Default 0.35. + */ + bandOpacity?: number; + /** Width, in columns, of the band lines. */ + width: number; + /** When false, renders children without the band. */ + useBackgroundColor?: boolean; + children: React.ReactNode; +} + +/** + * Renders two thin half-block accent lines (▄ above, ▀ below) around content. + * The line color is `bandColor` blended at low opacity onto + * `theme.background.primary`, so it stays subtle on both light and dark + * themes. The content area has no backgroundColor — it inherits the terminal's + * real background. + */ +export const HalfLinePaddedBox: React.FC = (props) => { + const isScreenReaderEnabled = useIsScreenReaderEnabled(); + if (props.useBackgroundColor === false || isScreenReaderEnabled) { + return <>{props.children}; + } + return ; +}; + +const HalfLinePaddedBoxInternal: React.FC = ({ + bandColor, + bandOpacity = 0.15, + width, + children, +}) => { + const terminalBg = theme.background.primary || 'black'; + + const lineColor = useMemo(() => { + const bg = resolveColor(terminalBg) || terminalBg; + const accent = resolveColor(bandColor) || bandColor; + return interpolateColor(bg, accent, bandOpacity); + }, [bandColor, bandOpacity, terminalBg]); + + if (!lineColor) { + return <>{children}; + } + + if (!supportsTrueColor()) { + return <>{children}; + } + + return ( + + + {'▄'.repeat(width)} + + {children} + + {'▀'.repeat(width)} + + + ); +}; diff --git a/packages/cli/src/ui/themes/color-utils.ts b/packages/cli/src/ui/themes/color-utils.ts index 08caf6b001..72ba533ecd 100644 --- a/packages/cli/src/ui/themes/color-utils.ts +++ b/packages/cli/src/ui/themes/color-utils.ts @@ -233,3 +233,94 @@ 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', +}; + +/** + * 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))}`; +} + +/** + * Detects whether the terminal supports 24-bit (true) color, required for the + * blended half-line background band. + */ +export function supportsTrueColor(): boolean { + const colorterm = process.env['COLORTERM']; + if ( + colorterm === 'truecolor' || + colorterm === '24bit' || + colorterm === 'kmscon' + ) { + return true; + } + if (process.stdout.getColorDepth && process.stdout.getColorDepth() >= 24) { + return true; + } + return false; +}