Skip to content

Commit da69646

Browse files
committed
fix(tui): refresh terminal interface design
1 parent e6cb84e commit da69646

28 files changed

Lines changed: 460 additions & 115 deletions

‎.agents/skills/write-tui/DESIGN.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,13 @@
8383
Select a model (type to search)
8484
Tab toggle provider · ↑↓ navigate · Enter select · Esc cancel ← hint 首项即 Tab 切换
8585
← 空行
86-
All Pythinker Code openai ← tab 条:激活项填充背景(primary 底 + text 字 + bold),其余 textMuted
86+
All Pythinker Code openai ← tab 条:激活项填充背景(selectionBg 底 + inverseText 字 + bold),其余 textMuted
8787
← 空行
8888
❯ ...
8989
```
9090

9191
- tab 条位置:**在 hint 行下方**,且**上下各一空行**(与 hint、与列表都隔开)。
92-
- 激活 tab:`chalk.bgHex(colors.primary).hex(colors.text).bold(\` ${label} \`)`;非激活:`chalk.hex(colors.textMuted)`。两者可见宽度一致,切换不抖动。
92+
- 激活 tab:`chalk.bgHex(colors.selectionBg).hex(colors.inverseText).bold(\` ${label} \`)`;非激活:`chalk.hex(colors.textMuted)`。两者可见宽度一致,切换不抖动。
9393
- 第一个 tab 恒为 `All`(聚合所有 provider);**默认停在 `All`**。仅当显式传 `initialTabId`(如 `/provider` 新增完跳转)才停在指定 provider tab。
9494
- `Tab` / `Shift+Tab` 循环切换;hint 行首项即 `Tab toggle provider`。
9595
- 当前模型在所在 tab 内仍以 `❯` + ` ← current` 标记,切 tab 不丢失定位。

‎.changeset/refresh-cli-tui.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Update the terminal interface colors, transcript hierarchy, welcome panel, and workflow progress states.

‎apps/pythinker-code/src/tui/components/chrome/footer.ts‎

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,19 @@ import {
3333
usagePercentFromRatio,
3434
} from '#/utils/usage/usage-format';
3535

36-
const DEFAULT_STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git'] as const;
36+
const DEFAULT_STATUS_LINE_LAYOUT = [
37+
{ slot: 'mode', collapsePriority: null },
38+
{ slot: 'goal', collapsePriority: null },
39+
{ slot: 'model', collapsePriority: 2 },
40+
{ slot: 'tasks', collapsePriority: 1 },
41+
{ slot: 'cwd', collapsePriority: 3 },
42+
{ slot: 'git', collapsePriority: 0 },
43+
] as const;
44+
const DEFAULT_STATUS_LINE_ITEMS = DEFAULT_STATUS_LINE_LAYOUT.map(({ slot }) => slot);
45+
const DEFAULT_STATUS_LINE_COLLAPSE_ORDER = DEFAULT_STATUS_LINE_LAYOUT
46+
.filter(({ collapsePriority }) => collapsePriority !== null)
47+
.toSorted((left, right) => left.collapsePriority! - right.collapsePriority!)
48+
.map(({ slot }) => slot);
3749

3850
const MAX_CWD_SEGMENTS = 3;
3951
const GOAL_TIMER_INTERVAL_MS = 1_000;
@@ -313,13 +325,23 @@ export class FooterComponent implements Component {
313325
const slots = this.buildSlots(colors);
314326
const configured = this.state.statusLine?.items ?? null;
315327
const order: readonly string[] = configured ?? DEFAULT_STATUS_LINE_ITEMS;
316-
const left: string[] = [];
328+
let left: Array<{ slot: string; piece: string }> = [];
317329
for (const slot of order) {
318330
const pieces = slots[slot as keyof typeof slots];
319-
if (pieces !== undefined) left.push(...pieces);
331+
if (pieces !== undefined) {
332+
left.push(...pieces.map((piece) => ({ slot, piece })));
333+
}
334+
}
335+
336+
let leftLine = left.map(({ piece }) => piece).join(' ');
337+
if (configured === null) {
338+
for (const slot of DEFAULT_STATUS_LINE_COLLAPSE_ORDER) {
339+
if (visibleWidth(leftLine) <= width) break;
340+
left = left.filter((entry) => entry.slot !== slot);
341+
leftLine = left.map(({ piece }) => piece).join(' ');
342+
}
320343
}
321344

322-
const leftLine = left.join(' ');
323345
const leftWidth = visibleWidth(leftLine);
324346

325347
// Rotating hint tips stay on the right unless they were given an
@@ -405,9 +427,13 @@ export class FooterComponent implements Component {
405427
}
406428

407429
const modes: string[] = [];
408-
if (state.permissionMode === 'auto') modes.push(chalk.hex(colors.warning).bold('auto'));
409-
if (state.permissionMode === 'yolo') modes.push(chalk.hex(colors.warning).bold('yolo'));
410-
if (state.planMode) modes.push(chalk.hex(colors.primary).bold('plan'));
430+
if (state.permissionMode === 'auto') {
431+
modes.push(chalk.hex(colors.modeAutoAccept).bold('auto'));
432+
}
433+
if (state.permissionMode === 'yolo') {
434+
modes.push(chalk.hex(colors.modePermission).bold('yolo'));
435+
}
436+
if (state.planMode) modes.push(chalk.hex(colors.modePlan).bold('plan'));
411437
if (state.dynamicWorkflowMode) modes.push(chalk.hex(colors.accent).bold('dynamic_workflow'));
412438
if (state.towerMode) modes.push(chalk.hex(colors.accent).bold('tower'));
413439
if (modes.length > 0) slots['mode'] = [modes.join(' ')];
@@ -430,12 +456,10 @@ export class FooterComponent implements Component {
430456
? ` thinking: ${effort}`
431457
: ' thinking'
432458
: '';
433-
const modelLabel = `${model}${thinkingLabel}`;
434-
let renderedModelLabel = chalk.hex(colors.text)(modelLabel);
435-
if (isRainbowHatching()) {
436-
renderedModelLabel = renderHatchFooterModel(modelLabel);
437-
}
438-
slots['model'] = [renderedModelLabel];
459+
const renderedModel = isRainbowHatching()
460+
? renderHatchFooterModel(model)
461+
: chalk.hex(colors.primary)(model);
462+
slots['model'] = [renderedModel + chalk.hex(colors.textDim)(thinkingLabel)];
439463
}
440464

441465
// Background-task badges. `bash-*` tasks (shell processes) and `agent-*`

‎apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -164,16 +164,22 @@ function wrapPlain(text: string, maxWidth: number): string[] {
164164
}
165165

166166
function valueStyleForLabel(label: string, level: WelcomeInfoLevel): ColorToken {
167-
if (level === 'warn') return 'warning';
168-
if (level === 'error') return 'error';
167+
switch (level) {
168+
case 'warn':
169+
return 'warning';
170+
case 'error':
171+
return 'error';
172+
case 'info':
173+
break;
174+
}
169175
switch (label.trim()) {
170176
case 'Directory':
171177
return 'accent';
172178
case 'Session':
173179
case 'Branch':
174180
return 'textDim';
175181
case 'Model':
176-
return 'warning';
182+
return 'primary';
177183
case 'Auto-save':
178184
return 'textMuted';
179185
default:
@@ -393,10 +399,10 @@ export function renderWelcomeBanner(options: RenderWelcomeBannerOptions): string
393399
const modelItem = options.infoItems.find((item) => item.name === 'Model');
394400
if (modelItem !== undefined) {
395401
const value = asciiMode ? applyAsciiFallback(modelItem.value) : modelItem.value;
396-
const modelLine =
397-
modelItem.level === 'warn'
398-
? `Model: ${currentTheme.fg('warning', value)}`
399-
: `Model: ${value}`;
402+
const modelLine = `Model: ${currentTheme.fg(
403+
valueStyleForLabel('Model', modelItem.level ?? 'info'),
404+
value,
405+
)}`;
400406
lines.push(truncateToWidth(modelLine, safeWidth, ellipsis));
401407
}
402408
return lines;

‎apps/pythinker-code/src/tui/components/dialogs/question-dialog.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,7 @@ export class QuestionDialogComponent extends Container implements Focusable {
610610
private pushTabs(lines: string[]): void {
611611
const dim = (text: string) => currentTheme.fg('textDim', text);
612612
const active = (text: string) =>
613-
currentTheme.bg('primary', currentTheme.boldFg('text', text));
613+
currentTheme.bg('selectionBg', currentTheme.boldFg('inverseText', text));
614614

615615
const tabs: string[] = [];
616616
for (let i = 0; i < this.request.data.questions.length; i++) {

‎apps/pythinker-code/src/tui/components/messages/agent-group.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,15 +166,15 @@ export class AgentGroupComponent extends Container {
166166
const totalTools = snapshots.reduce((acc, s) => acc + s.toolCount, 0);
167167
const totalTokens = snapshots.reduce((acc, s) => acc + s.tokens, 0);
168168
const tail = formatHeaderTail({ toolCount: totalTools, tokens: totalTokens, elapsedSeconds });
169-
return `${bullet}${currentTheme.boldFg('primary', headerLabel)}${tail}`;
169+
return `${bullet}${currentTheme.boldFg('textStrong', headerLabel)}${tail}`;
170170
}
171171

172172
const parts = formatBreakdownParts(counts);
173173
const headerText = parts.length > 0
174174
? `Running ${String(total)} agents (${parts.join(', ')})`
175175
: `Running ${String(total)} agents`;
176176
const tail = formatHeaderTail({ toolCount: 0, tokens: 0, elapsedSeconds });
177-
return `${bullet}${currentTheme.boldFg('primary', headerText)}${tail}`;
177+
return `${bullet}${currentTheme.boldFg('textStrong', headerText)}${tail}`;
178178
}
179179

180180
private appendLines(snap: ToolCallSubagentSnapshot, isLast: boolean): void {

‎apps/pythinker-code/src/tui/components/messages/assistant-message.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Renders an assistant message using pi-tui Markdown.
33
*
4-
* Displays a white bullet prefix with markdown content indented
4+
* Displays a neutral bullet prefix with markdown content indented
55
* to align after the bullet.
66
*/
77

@@ -122,7 +122,7 @@ export class AssistantMessageComponent implements Component {
122122
const lines: string[] = [''];
123123
for (let i = 0; i < contentLines.length; i++) {
124124
const p =
125-
i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT;
125+
i === 0 && this.showBullet ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT;
126126
lines.push(p + contentLines[i]);
127127
}
128128
const rendered = markOsc133Zone(lines.map((line) => truncateToWidth(line, safeWidth, '…')));

‎apps/pythinker-code/src/tui/components/messages/read-group.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export class ReadGroupComponent extends Container {
135135

136136
if (pending > 0) {
137137
const bullet = currentTheme.fg('text', STATUS_BULLET);
138-
const label = currentTheme.boldFg('primary', `Reading ${String(total)} files…`);
138+
const label = currentTheme.boldFg('textStrong', `Reading ${String(total)} files…`);
139139
return `${bullet}${label}`;
140140
}
141141

@@ -147,7 +147,7 @@ export class ReadGroupComponent extends Container {
147147
}
148148

149149
const bullet = currentTheme.fg('success', STATUS_BULLET);
150-
const label = currentTheme.boldFg('primary', `Read ${String(total)} files`);
150+
const label = currentTheme.boldFg('textStrong', `Read ${String(total)} files`);
151151
const linesPart = dim(` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`);
152152
const failPart = failed > 0 ? currentTheme.fg('error', ` · ${String(failed)} failed`) : '';
153153
return `${bullet}${label}${linesPart}${failPart}`;

‎apps/pythinker-code/src/tui/components/messages/tool-call.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -710,7 +710,7 @@ export class ToolCallComponent extends Container {
710710
: 'toolPendingBg'
711711
: this.result.is_error === true
712712
? 'toolErrorBg'
713-
: 'toolSuccessBg';
713+
: undefined;
714714
const rendered =
715715
background === undefined
716716
? out
@@ -1523,7 +1523,7 @@ export class ToolCallComponent extends Container {
15231523
return `${bullet}${currentTheme.fg('error', 'Truncated')} ${currentTheme.boldFg('primary', 'Bash')}`;
15241524
}
15251525
const label = isFinished ? 'Ran a command' : 'Running a command';
1526-
const tone = isError ? 'error' : 'primary';
1526+
const tone = isError ? 'error' : 'textStrong';
15271527
const chipStr = isFinished && result !== undefined ? this.buildHeaderChip(result) : '';
15281528
return `${bullet}${currentTheme.boldFg(tone, label)}${chipStr}`;
15291529
}

‎apps/pythinker-code/src/tui/components/messages/user-message.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ export class UserMessageComponent implements Component {
5050
}
5151

5252
const marker = this.bullet ?? USER_MESSAGE_BULLET;
53-
const bullet = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : '';
53+
const isRoleMessage = marker.length > 0;
54+
const bullet = isRoleMessage ? currentTheme.boldFg('roleUser', marker) : '';
5455
const bulletWidth = visibleWidth(bullet);
5556
const contentWidth = Math.max(1, safeWidth - bulletWidth);
5657

@@ -63,11 +64,19 @@ export class UserMessageComponent implements Component {
6364

6465
// Text is re-dyed from the current theme; invalidate() (theme change) clears
6566
// the render cache so the new colours are picked up on the next render.
66-
const coloredText = currentTheme.boldFg('roleUser', this.text);
67+
const coloredText = isRoleMessage ? currentTheme.fg('textStrong', this.text) : this.text;
6768
const textLines = new Text(coloredText, 0, 0).render(contentWidth);
6869
for (let i = 0; i < textLines.length; i++) {
6970
const prefix = i === 0 ? bullet : ' '.repeat(bulletWidth);
70-
lines.push(prefix + textLines[i]);
71+
const line = prefix + textLines[i];
72+
lines.push(
73+
isRoleMessage
74+
? currentTheme.bg(
75+
'surfaceHighlight',
76+
line + ' '.repeat(Math.max(0, safeWidth - visibleWidth(line))),
77+
)
78+
: line,
79+
);
7180
}
7281

7382
// Images — indented to align with text after the bullet

0 commit comments

Comments
 (0)