Skip to content

Commit 065bf2e

Browse files
authored
feat(tui): state-tinted tool cards, session status bar, mode borders, cosine shimmer (#58)
## Related Issue No linked issue — directly requested design work; problem explained below. ## Problem Tool-call blocks, the input chrome, and the working shimmer are visually flat: tool state (running / succeeded / failed) is only distinguishable by a small glyph, parallel sessions look identical, and permission modes are invisible while typing. ## What changed Four signature visual elements ported from an MIT-licensed terminal-agent design: - **State-tinted tool cards** — every tool-call block paints a full-width background tint: subtle blue-gray while running, near-invisible dark on success, dark red on error (pale equivalents in the light theme). Three new theme tokens (`toolPendingBg`, `toolSuccessBg`, `toolErrorBg`) added to both palettes, the custom-theme JSON schema, the theme docs, and the custom-theme skill table. - **Status bar above the input** — one line showing a model+effort chip and mode badges, joined to a right-aligned cwd chip by a rule painted in a per-session accent color (stable hash of the session title/id), so parallel sessions are visually distinct. Mounted in both fixed and inline layouts. - **Editor border mode colors** — the input border now also reflects yolo and auto permission modes (plan mode and thinking-level colors already existed). - **Cosine shimmer** — the working-label shimmer sweeps a cosine brightness band at constant velocity with three intensity tiers, replacing the fixed sliding window; same API, callers unchanged. Three existing layout/shimmer expectation tests were updated to match the intended new visuals; all other tests pass unchanged. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/Pythoughts-labs/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked a related issue, or explained the problem above. - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a responsive status bar showing model, thinking level, token speed, active modes, extras, and working directory. * Added session-specific status-bar accent colors. * Added state-based background colors for pending, successful, and failed tool cards. * Improved permission and plan mode visual indicators. * Added smoother, multi-tone shimmer animation and updated workflow progress indicators. * **Documentation** * Documented new tool-card theme color options for dark and light themes. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 99c427c commit 065bf2e

35 files changed

Lines changed: 1099 additions & 333 deletions

.changeset/tui-signature-design.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": minor
3+
---
4+
5+
Redesign core TUI surfaces: tool cards get state-tinted backgrounds with three new theme tokens, a status bar with a per-session accent color appears between the input box and footer, and the prompt box uses a neutral border while permission mode appears in the status bar. The working-label shimmer uses a calmer constant-velocity sweep with alternating mission-control highlights.

apps/pythinker-code/src/tui/components/chrome/activity-loader.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ export class ActivityLoader extends Text {
8989
? shimmerText(this.label, {
9090
baseToken: 'primary',
9191
shimmerToken: 'primaryShimmer',
92-
frame: this.animationFrame,
9392
})
9493
: this.label;
9594
this.displayText = label ? `${coloredFrame} ${label}` : coloredFrame;

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

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,16 @@ import chalk from 'chalk';
1010

1111
import {
1212
createFooterState,
13-
formatStatusRow,
1413
reduceFooterState,
1514
selectFooterViewModel,
1615
type FooterBackgroundCounts,
1716
type FooterGitStatus,
1817
type FooterGoal,
1918
type FooterState,
2019
type FooterStatus,
21-
type FooterStatusRowViewModel,
2220
type FooterViewModel,
2321
type FooterViewModelRow,
2422
} from '#/tui/runtime/footer/footer-model';
25-
import { currentTheme } from '#/tui/theme';
2623
import type { AppState } from '#/tui/types';
2724
import {
2825
createGitStatusCache,
@@ -275,7 +272,7 @@ export class FooterComponent implements Component {
275272
this.state.statusLine,
276273
);
277274
return viewModel.rows.flatMap((row) => {
278-
if (row.kind === 'activity' || row.kind === 'composer') return [];
275+
if (row.kind === 'composer' || row.kind === 'status' || row.kind === 'activity') return [];
279276
return [truncateToWidth(renderLegacyRow(row), width, '…')];
280277
});
281278
}
@@ -376,28 +373,10 @@ export class FooterComponent implements Component {
376373
}
377374
}
378375

379-
/** Keep the persistent status quiet; danger rows remain explicitly red. */
380-
function paintStatusRow(
381-
row: string,
382-
_modelName: string | null,
383-
emphasis: FooterStatusRowViewModel['emphasis'],
376+
function renderLegacyRow(
377+
row: Extract<FooterViewModelRow, { readonly kind: 'validation' }>,
384378
): string {
385-
return currentTheme.fg(emphasis === 'danger' ? 'error' : 'textDim', row);
386-
}
387-
388-
function renderLegacyRow(row: Exclude<FooterViewModelRow, { readonly kind: 'composer' }>): string {
389-
switch (row.kind) {
390-
case 'activity':
391-
return row.primary.length === 0
392-
? row.indicators.join(' ')
393-
: row.indicators.length === 0
394-
? row.primary
395-
: `${row.primary} ${row.indicators.join(' ')}`;
396-
case 'validation':
397-
return row.level === 'info' ? row.message : `${row.level}: ${row.message}`;
398-
case 'status':
399-
return paintStatusRow(formatStatusRow(row.items), row.modelName, row.emphasis);
400-
}
379+
return row.level === 'info' ? row.message : `${row.level}: ${row.message}`;
401380
}
402381

403382
function hasGoalBadge(goal: AppState['goal']): boolean {
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { sep } from 'node:path';
2+
3+
import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui';
4+
import chalk from 'chalk';
5+
6+
import type { StatusLineConfig } from '#/tui/config';
7+
import {
8+
formatTokenSpeed,
9+
type FooterStatus,
10+
} from '#/tui/runtime/footer/footer-model';
11+
import { currentTheme } from '#/tui/theme';
12+
import { themeFromHexChannels } from '#/tui/theme/terminal-background';
13+
import { effortColorToken, shortEffortLabel } from '#/tui/utils/thinking-levels';
14+
import { sessionAccentHex } from '#/tui/utils/session-accent';
15+
16+
export type StatusBarStatus = Pick<
17+
FooterStatus,
18+
| 'model'
19+
| 'thinkingLevel'
20+
| 'cwd'
21+
| 'homeDir'
22+
| 'permissionMode'
23+
| 'planMode'
24+
| 'fastMode'
25+
| 'dynamicWorkflowMode'
26+
| 'tokenSpeed'
27+
| 'tokenSpeedEstimated'
28+
> & {
29+
readonly extras: readonly string[];
30+
readonly sessionKey: string;
31+
readonly statusLine: StatusLineConfig;
32+
};
33+
34+
export class StatusBarComponent implements Component {
35+
private status: StatusBarStatus | undefined;
36+
37+
update(status: StatusBarStatus): void {
38+
this.status = status;
39+
}
40+
41+
render(width: number): string[] {
42+
const status = this.status;
43+
if (status === undefined) return [];
44+
45+
const effortSuffix = status.statusLine.showEffort && status.thinkingLevel !== 'off'
46+
? `${currentTheme.fg('textDim', ' · ')}${currentTheme.fg(
47+
effortColorToken(status.thinkingLevel),
48+
shortEffortLabel(status.thinkingLevel),
49+
)}`
50+
: '';
51+
const fastSuffix = status.statusLine.showModes && status.fastMode
52+
? `${currentTheme.fg('textDim', ' · ')}${currentTheme.fg('modeFast', '↯ fast')}`
53+
: '';
54+
const speed = status.statusLine.showTokenSpeed ? formatTokenSpeed(status) : null;
55+
const modelChip = status.statusLine.showModel
56+
? chip(
57+
`${currentTheme.fg('text', status.model)}${effortSuffix}${fastSuffix}${
58+
speed === null ? '' : currentTheme.fg('textDim', ` · ${speed}`)
59+
}`,
60+
)
61+
: undefined;
62+
let modesChip = status.statusLine.showModes ? renderModesChip(status) : undefined;
63+
const extraChips = status.extras.map((extra) =>
64+
chip(currentTheme.fg('textDim', extra)),
65+
);
66+
let cwdChip: string | undefined = chip(
67+
currentTheme.fg('textDim', shortenCwd(status.cwd, status.homeDir)),
68+
);
69+
const left = (): string =>
70+
[modelChip, modesChip, ...extraChips]
71+
.filter((item): item is string => item !== undefined)
72+
.join(' ');
73+
const fullGapWidth =
74+
width - visibleWidth(left()) - (cwdChip === undefined ? 1 : visibleWidth(cwdChip) + 2);
75+
76+
let line: string;
77+
if (fullGapWidth > 0) {
78+
const background = currentTheme.color('background');
79+
const mode = themeFromHexChannels(
80+
background.slice(1, 3),
81+
background.slice(3, 5),
82+
background.slice(5, 7),
83+
);
84+
const gap = chalk.hex(sessionAccentHex(status.sessionKey, mode))('─'.repeat(fullGapWidth));
85+
line = cwdChip === undefined ? `${left()} ${gap}` : `${left()} ${gap} ${cwdChip}`;
86+
} else {
87+
line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`;
88+
while (visibleWidth(line) > width && extraChips.length > 0) {
89+
extraChips.pop();
90+
line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`;
91+
}
92+
if (visibleWidth(line) > width && modesChip !== undefined) {
93+
modesChip = undefined;
94+
line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`;
95+
}
96+
if (visibleWidth(line) > width && cwdChip !== undefined) {
97+
cwdChip = undefined;
98+
line = left();
99+
}
100+
}
101+
102+
return [truncateToWidth(line, Math.max(0, width))];
103+
}
104+
105+
invalidate(): void {}
106+
}
107+
108+
function chip(content: string): string {
109+
return currentTheme.bg('surfaceHighlight', ` ${content} `);
110+
}
111+
112+
function renderModesChip(status: StatusBarStatus): string | undefined {
113+
const modes: string[] = [];
114+
if (status.planMode) modes.push(currentTheme.fg('modePlan', 'plan'));
115+
if (status.permissionMode === 'auto') modes.push(currentTheme.fg('modePermission', 'auto'));
116+
if (status.permissionMode === 'yolo') modes.push(currentTheme.fg('error', 'yolo'));
117+
if (status.dynamicWorkflowMode) modes.push(currentTheme.fg('accent', 'workflow'));
118+
return modes.length === 0 ? undefined : chip(modes.join(' '));
119+
}
120+
121+
function shortenCwd(cwd: string, homeDir: string | null): string {
122+
const path = homeDir !== null && homeDir.length > 0
123+
? cwd === homeDir
124+
? '~'
125+
: cwd.startsWith(`${homeDir}${sep}`)
126+
? `~${cwd.slice(homeDir.length)}`
127+
: cwd
128+
: cwd;
129+
const segments = path.startsWith(`~${sep}`)
130+
? path.slice(2).split(sep)
131+
: path.startsWith(sep)
132+
? path.slice(1).split(sep)
133+
: [];
134+
return segments.length > 2 ? `…${sep}${segments.slice(-2).join(sep)}` : path;
135+
}

apps/pythinker-code/src/tui/components/dialogs/compaction.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,6 @@ export class CompactionComponent extends Container {
182182
shimmerText('Compacting conversation…', {
183183
baseToken: 'primary',
184184
shimmerToken: 'primaryShimmer',
185-
frame: this.animationFrame,
186185
}),
187186
);
188187
return `${label}${currentTheme.dim(` (${String(this.elapsedSeconds())}s)`)}`;

apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui';
22

3-
import { DYNAMIC_WORKFLOW_RENDERING } from '#/tui/constant/rendering';
3+
import {
4+
BRAILLE_SPINNER_FRAMES,
5+
BRAILLE_SPINNER_INTERVAL_MS,
6+
DYNAMIC_WORKFLOW_RENDERING,
7+
} from '#/tui/constant/rendering';
48
import { currentTheme } from '#/tui/theme';
59
import { shimmerText } from '#/tui/utils/shimmer';
610

@@ -507,9 +511,18 @@ export class DynamicWorkflowMissionControlComponent implements Component {
507511

508512
private renderAggregate(width: number, nowMs: number): string {
509513
const terminal = isTerminalRequestPhase(this.model.requestPhase);
514+
const frame = Math.floor(
515+
Math.max(0, nowMs - this.model.startedAtMs) /
516+
BRAILLE_SPINNER_INTERVAL_MS,
517+
);
510518
const loader = terminal
511519
? currentTheme.fg(requestPhaseColor(this.model.requestPhase), requestPhaseSymbol(this.model.requestPhase))
512-
: this.activitySpinnerText?.() ?? currentTheme.fg('primary', '●');
520+
: this.activitySpinnerText === undefined
521+
? currentTheme.fg('primary', '●')
522+
: currentTheme.fg(
523+
'primary',
524+
BRAILLE_SPINNER_FRAMES[frame % BRAILLE_SPINNER_FRAMES.length]!,
525+
);
513526
const aggregateMembers = this.aggregateMembers();
514527
// All spawned agents are done but the tool result has not arrived yet:
515528
// the label says so instead of pretending orchestration is still active.
@@ -525,15 +538,10 @@ export class DynamicWorkflowMissionControlComponent implements Component {
525538
const label = terminal
526539
? currentTheme.fg('text', requestPhaseLabel(this.model.requestPhase))
527540
: shimmerText(finalizing ? FINALIZING_LABEL : ORCHESTRATING_LABEL, {
528-
// `primary` / `primaryShimmer` are a designed pair, so the sweep stays
529-
// periwinkle throughout; the old grey `text` base washed it out.
530541
baseToken: 'primary',
531542
shimmerToken: 'primaryShimmer',
532-
frame: Math.floor(
533-
Math.max(0, nowMs - this.model.startedAtMs) /
534-
DYNAMIC_WORKFLOW_RENDERING.aggregateShimmerFrameMs,
535-
),
536-
windowSize: 4,
543+
altShimmerToken: 'warningShimmer',
544+
bandHalfWidth: 4,
537545
});
538546
const paddedLabel = padToWidth(label, LIVE_LABEL_WIDTH);
539547
const prefix = `${loader} ${paddedLabel}`;

apps/pythinker-code/src/tui/components/messages/thinking.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,7 @@ export class ThinkingComponent implements Component {
100100
const label = shimmerText(formatThinkingSpinnerLabel(), {
101101
baseToken: 'primary',
102102
shimmerToken: 'primaryShimmer',
103-
frame: this.animationFrame,
104-
windowSize: 4,
103+
bandHalfWidth: 4,
105104
});
106105
return ['', spinner + label, ...visibleLines.map((line) => MESSAGE_INDENT + line)];
107106
}

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,24 @@ export class ToolCallComponent extends Container {
658658

659659
override render(width: number): string[] {
660660
this.headerText.setText(truncateToWidth(this.buildHeader(), Math.max(0, width)));
661-
return super.render(width);
661+
const lines = super.render(width);
662+
const background =
663+
this.result === undefined
664+
? this.toolCall.truncated === true
665+
? undefined
666+
: 'toolPendingBg'
667+
: this.result.is_error !== true
668+
? 'toolSuccessBg'
669+
: 'toolErrorBg';
670+
if (background === undefined) return lines;
671+
return lines.map((line, index) =>
672+
index === 0
673+
? line
674+
: currentTheme.bg(
675+
background,
676+
`${line}${' '.repeat(Math.max(0, width - visibleWidth(line)))}`,
677+
),
678+
);
662679
}
663680

664681
setExpanded(expanded: boolean): void {

apps/pythinker-code/src/tui/constant/rendering.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,10 @@ export const DYNAMIC_WORKFLOW_RENDERING = {
3030
memberProgressWidth: 8,
3131
/** Least width of the lifecycle STATE column in member rows. */
3232
stateColumnWidth: 6,
33-
/** Cadence for the live aggregate-label shimmer. */
34-
aggregateShimmerFrameMs: BRAILLE_SPINNER_INTERVAL_MS,
35-
/** Thin-arc frames for a running row; all rows share one clock. */
36-
progressFrames: ['◜', '◝', '◞', '◟'],
37-
/** Arc cadence in milliseconds. */
38-
progressFrameMs: 120,
33+
/** Half-circle frames for a running row; all rows share one clock. */
34+
progressFrames: ['◐', '◓', '◑', '◒'],
35+
/** Rotation cadence in milliseconds — deliberately slow; this is ambience, not progress. */
36+
progressFrameMs: 300,
3937
/** Least room the task keeps before the detail may claim any of the row. */
4038
memberTaskMinWidth: 12,
4139
/** Share of the free row the task may take before the detail gets the rest. */

apps/pythinker-code/src/tui/pythinker-tui.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import { readUpdateInstallState } from '#/cli/update/install-state';
2727
import { detectInstallSource } from '#/cli/update/source';
2828
import type { InstallSource } from '#/cli/update/types';
2929
import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index';
30-
import { effortColorToken } from '#/tui/utils/thinking-levels';
3130
import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text';
3231
import {
3332
appendInputHistory,
@@ -141,6 +140,7 @@ import type { TuiPresentation } from './runtime/contracts';
141140
import {
142141
foldFooterEvents,
143142
selectFooterViewModel,
143+
selectStatusBarExtras,
144144
type FooterActivity,
145145
type FooterEvent,
146146
type FooterGoal,
@@ -965,6 +965,7 @@ export class PythinkerTUI {
965965
ui.addChild(this.state.btwPanelContainer);
966966
ui.addChild(this.state.mcpStatusContainer);
967967
ui.addChild(this.state.editorContainer);
968+
ui.addChild(this.state.statusBarContainer);
968969
// Footer is mounted later (mountFooter), not here.
969970
}
970971

@@ -974,6 +975,8 @@ export class PythinkerTUI {
974975
// only once init() succeeds. FooterComponent isn't a Container, so wrap it to
975976
// pick up the same outer gutter as the panels above.
976977
private mountFooter(): void {
978+
this.state.statusBarContainer.clear();
979+
this.state.statusBarContainer.addChild(this.state.statusBar);
977980
if (this.state.layout === 'fixed') {
978981
this.state.layoutRoot.setFooterMounted(true);
979982
return;
@@ -1333,7 +1336,7 @@ export class PythinkerTUI {
13331336
if (!hasPatchChanges(this.state.appState, patch)) return;
13341337
const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch;
13351338
Object.assign(this.state.appState, patch);
1336-
if ('planMode' in patch) this.updateEditorBorderHighlight();
1339+
if ('planMode' in patch || 'permissionMode' in patch) this.updateEditorBorderHighlight();
13371340
this.state.footer.syncAppState(this.state.appState);
13381341
this.syncFooterState();
13391342
this.updateActivityPane();
@@ -1391,6 +1394,19 @@ export class PythinkerTUI {
13911394
this.state.appState.statusLine,
13921395
),
13931396
);
1397+
this.state.statusBar.update({
1398+
...this.state.footerState.status,
1399+
extras: selectStatusBarExtras(
1400+
this.state.footerState,
1401+
Date.now(),
1402+
this.state.appState.statusLine,
1403+
),
1404+
sessionKey:
1405+
this.state.appState.sessionTitle?.trim() ||
1406+
this.state.appState.sessionId ||
1407+
this.state.appState.workDir,
1408+
statusLine: this.state.appState.statusLine,
1409+
});
13941410
}
13951411

13961412
private footerGoal(): FooterGoal | null {
@@ -2111,13 +2127,9 @@ export class PythinkerTUI {
21112127
const highlighted =
21122128
this.state.appState.planMode || findSlashAutocompleteContext(currentLine, col) !== null;
21132129
this.state.editor.borderHighlighted = highlighted;
2114-
// Reads thinkingLevel at paint time so cycling effort (Shift-Tab/Ctrl-T)
2115-
// recolors the prompt box on the next render without re-wiring the closure.
21162130
this.state.editor.borderColor = (s: string) => {
21172131
if (highlighted) return currentTheme.fg('primary', s);
2118-
const level = this.state.appState.thinkingLevel;
2119-
if (level === 'off' || level.trim().length === 0) return currentTheme.fg('border', s);
2120-
return currentTheme.fg(effortColorToken(level), s);
2132+
return currentTheme.fg('border', s);
21212133
};
21222134
this.state.ui.requestRender();
21232135
}

0 commit comments

Comments
 (0)