Skip to content

Commit fa436aa

Browse files
committed
fix(tui): honor status_line toggles and calm the shimmer sweep
Gate the status bar model, effort, and mode chips on their status_line settings, drop the effort suffix when thinking is off, and paint the yolo badge with the error token. Slow the shimmer sweep and alternate the mission-control peak with a warning highlight; remove the dead frame option in favor of a documented bandHalfWidth.
1 parent 6d51b93 commit fa436aa

11 files changed

Lines changed: 210 additions & 80 deletions

File tree

.changeset/tui-signature-design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@pythoughts/pythinker-code": minor
33
---
44

5-
Redesign core TUI surfaces: tool cards get state-tinted backgrounds (running, success, error — three new theme tokens), a status bar with a per-session accent color appears above the input box, the input border reflects yolo and auto permission modes, and the working-label shimmer uses a smoother constant-velocity sweep.
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 input border reflects yolo and auto permission modes. 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/status-bar.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { sep } from 'node:path';
33
import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui';
44
import chalk from 'chalk';
55

6+
import type { StatusLineConfig } from '#/tui/config';
67
import type { FooterStatus } from '#/tui/runtime/footer/footer-model';
78
import { currentTheme } from '#/tui/theme';
89
import { themeFromHexChannels } from '#/tui/theme/terminal-background';
@@ -22,6 +23,7 @@ export type StatusBarStatus = Pick<
2223
> & {
2324
readonly extras: readonly string[];
2425
readonly sessionKey: string;
26+
readonly statusLine: StatusLineConfig;
2527
};
2628

2729
export class StatusBarComponent implements Component {
@@ -35,13 +37,16 @@ export class StatusBarComponent implements Component {
3537
const status = this.status;
3638
if (status === undefined) return [];
3739

38-
const modelChip = chip(
39-
`${currentTheme.fg('text', status.model)}${currentTheme.fg('textDim', ' · ')}${currentTheme.fg(
40-
effortColorToken(status.thinkingLevel),
41-
shortEffortLabel(status.thinkingLevel),
42-
)}`,
43-
);
44-
let modesChip = renderModesChip(status);
40+
const effortSuffix = status.statusLine.showEffort && status.thinkingLevel !== 'off'
41+
? `${currentTheme.fg('textDim', ' · ')}${currentTheme.fg(
42+
effortColorToken(status.thinkingLevel),
43+
shortEffortLabel(status.thinkingLevel),
44+
)}`
45+
: '';
46+
const modelChip = status.statusLine.showModel
47+
? chip(`${currentTheme.fg('text', status.model)}${effortSuffix}`)
48+
: undefined;
49+
let modesChip = status.statusLine.showModes ? renderModesChip(status) : undefined;
4550
const extraChips = status.extras.map((extra) =>
4651
chip(currentTheme.fg('textDim', extra)),
4752
);
@@ -95,7 +100,7 @@ function renderModesChip(status: StatusBarStatus): string | undefined {
95100
const modes: string[] = [];
96101
if (status.planMode) modes.push(currentTheme.fg('modePlan', 'plan'));
97102
if (status.permissionMode === 'auto') modes.push(currentTheme.fg('modePermission', 'auto'));
98-
if (status.permissionMode === 'yolo') modes.push(currentTheme.fg('modeAutoAccept', 'yolo'));
103+
if (status.permissionMode === 'yolo') modes.push(currentTheme.fg('error', 'yolo'));
99104
if (status.fastMode) modes.push(currentTheme.fg('modeFast', '↯ fast'));
100105
if (status.dynamicWorkflowMode) modes.push(currentTheme.fg('accent', 'workflow'));
101106
return modes.length === 0 ? undefined : chip(modes.join(' '));

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: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -538,15 +538,10 @@ export class DynamicWorkflowMissionControlComponent implements Component {
538538
const label = terminal
539539
? currentTheme.fg('text', requestPhaseLabel(this.model.requestPhase))
540540
: shimmerText(finalizing ? FINALIZING_LABEL : ORCHESTRATING_LABEL, {
541-
// `primary` / `primaryShimmer` are a designed pair, so the sweep stays
542-
// periwinkle throughout; the old grey `text` base washed it out.
543541
baseToken: 'primary',
544542
shimmerToken: 'primaryShimmer',
545-
frame: Math.floor(
546-
Math.max(0, nowMs - this.model.startedAtMs) /
547-
DYNAMIC_WORKFLOW_RENDERING.aggregateShimmerFrameMs,
548-
),
549-
windowSize: 4,
543+
altShimmerToken: 'warningShimmer',
544+
bandHalfWidth: 4,
550545
});
551546
const paddedLabel = padToWidth(label, LIVE_LABEL_WIDTH);
552547
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/pythinker-tui.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,6 +1406,7 @@ export class PythinkerTUI {
14061406
this.state.appState.sessionTitle?.trim() ||
14071407
this.state.appState.sessionId ||
14081408
this.state.appState.workDir,
1409+
statusLine: this.state.appState.statusLine,
14091410
});
14101411
}
14111412

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

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import { currentTheme, type ColorToken } from '#/tui/theme';
33
export interface ShimmerTextOptions {
44
baseToken: ColorToken;
55
shimmerToken: ColorToken;
6-
frame: number;
7-
windowSize?: number;
6+
altShimmerToken?: ColorToken;
7+
/** Half-width of the cosine shimmer band, in terminal cells. */
8+
bandHalfWidth?: number;
89
phaseOffset?: number;
910
}
1011

11-
const CELLS_PER_SECOND = 30;
12+
const CELLS_PER_SECOND = 20;
1213
const BAND_HALF_WIDTH = 6;
1314

1415
type ShimmerTier = 'dim' | 'base' | 'shimmer';
@@ -17,11 +18,14 @@ export function shimmerText(text: string, options: ShimmerTextOptions): string {
1718
const chars = Array.from(text);
1819
if (chars.length === 0) return '';
1920

20-
const halfWidth = Math.max(1, options.windowSize ?? BAND_HALF_WIDTH);
21+
const halfWidth = Math.max(1, options.bandHalfWidth ?? BAND_HALF_WIDTH);
2122
const cycleLength = chars.length + halfWidth * 2;
22-
const center =
23-
((Date.now() / 1_000 * CELLS_PER_SECOND + (options.phaseOffset ?? 0)) % cycleLength) -
24-
halfWidth;
23+
const rawPosition = Date.now() / 1_000 * CELLS_PER_SECOND + (options.phaseOffset ?? 0);
24+
const center = rawPosition % cycleLength - halfWidth;
25+
const passIndex = Math.floor(rawPosition / cycleLength);
26+
const peakToken = options.altShimmerToken !== undefined && passIndex % 2 !== 0
27+
? options.altShimmerToken
28+
: options.shimmerToken;
2529

2630
let result = '';
2731
let segment = '';
@@ -46,20 +50,25 @@ export function shimmerText(text: string, options: ShimmerTextOptions): string {
4650
continue;
4751
}
4852

49-
result += paintTier(activeTier, segment, options);
53+
result += paintTier(activeTier, segment, options.baseToken, peakToken);
5054
activeTier = tier;
5155
segment = char;
5256
}
5357

5458
if (activeTier !== undefined) {
55-
result += paintTier(activeTier, segment, options);
59+
result += paintTier(activeTier, segment, options.baseToken, peakToken);
5660
}
5761

5862
return result;
5963
}
6064

61-
function paintTier(tier: ShimmerTier, text: string, options: ShimmerTextOptions): string {
65+
function paintTier(
66+
tier: ShimmerTier,
67+
text: string,
68+
baseToken: ColorToken,
69+
peakToken: ColorToken,
70+
): string {
6271
if (tier === 'dim') return currentTheme.fg('textDim', text);
63-
if (tier === 'shimmer') return currentTheme.boldFg(options.shimmerToken, text);
64-
return currentTheme.fg(options.baseToken, text);
72+
if (tier === 'shimmer') return currentTheme.boldFg(peakToken, text);
73+
return currentTheme.fg(baseToken, text);
6574
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ describe('DynamicWorkflowMissionControlComponent', () => {
417417
expect(first).toContain(chalk.hex(darkColors.primary)('◐'));
418418
expect(second).toContain(chalk.hex(darkColors.primary)('◓'));
419419
expect(first).toContain(chalk.hex(darkColors.primary)('RUN'));
420-
vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS);
420+
vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS * 1.5);
421421

422422
const aggregate = aggregateLine(component.render(100).join('\n'));
423423
expect(strip(aggregate)).toContain('Orchestrating');

apps/pythinker-code/test/tui/components/status-bar.test.ts

Lines changed: 65 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { visibleWidth } from '@earendil-works/pi-tui';
22
import chalk from 'chalk';
3-
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
import { describe, expect, it } from 'vitest';
44

55
import {
66
StatusBarComponent,
77
type StatusBarStatus,
88
} from '#/tui/components/chrome/status-bar';
9-
import { shimmerText } from '#/tui/utils/shimmer';
9+
import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config';
10+
import { currentTheme, darkColors } from '#/tui/theme';
1011

1112
function stripAnsi(text: string): string {
1213
return text.replaceAll(/\u001B\[[0-9;]*m/gu, '');
@@ -24,6 +25,7 @@ function status(overrides: Partial<StatusBarStatus> = {}): StatusBarStatus {
2425
dynamicWorkflowMode: true,
2526
extras: [],
2627
sessionKey: 'session-alpha',
28+
statusLine: DEFAULT_STATUS_LINE_CONFIG,
2729
...overrides,
2830
};
2931
}
@@ -39,6 +41,67 @@ describe('StatusBarComponent', () => {
3941
expect(stripAnsi(lines[0] ?? '')).toContain('Model Alpha · high');
4042
});
4143

44+
it('omits the effort suffix when thinking is off', () => {
45+
const component = new StatusBarComponent();
46+
component.update(status({ thinkingLevel: 'off' }));
47+
48+
const line = stripAnsi(component.render(80)[0] ?? '');
49+
50+
expect(line).toContain('Model Alpha');
51+
expect(line).not.toContain('· off');
52+
});
53+
54+
it('hides the model chip when showModel is false', () => {
55+
const component = new StatusBarComponent();
56+
component.update(status({
57+
statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showModel: false },
58+
}));
59+
60+
expect(stripAnsi(component.render(80)[0] ?? '')).not.toContain('Model Alpha');
61+
});
62+
63+
it('hides the modes chip when showModes is false', () => {
64+
const component = new StatusBarComponent();
65+
component.update(status({
66+
statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showModes: false },
67+
}));
68+
69+
const line = stripAnsi(component.render(80)[0] ?? '');
70+
71+
expect(line).not.toContain('plan');
72+
expect(line).not.toContain('auto');
73+
expect(line).not.toContain('workflow');
74+
});
75+
76+
it('hides only the effort suffix when showEffort is false', () => {
77+
const component = new StatusBarComponent();
78+
component.update(status({
79+
statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showEffort: false },
80+
}));
81+
82+
const line = stripAnsi(component.render(80)[0] ?? '');
83+
84+
expect(line).toContain('Model Alpha');
85+
expect(line).not.toContain('· high');
86+
});
87+
88+
it('renders yolo with the error colour', () => {
89+
const previousLevel = chalk.level;
90+
const previousPalette = currentTheme.palette;
91+
chalk.level = 3;
92+
currentTheme.setPalette(darkColors);
93+
94+
try {
95+
const component = new StatusBarComponent();
96+
component.update(status({ permissionMode: 'yolo' }));
97+
98+
expect(component.render(80)[0] ?? '').toContain(chalk.hex(darkColors.error)('yolo'));
99+
} finally {
100+
chalk.level = previousLevel;
101+
currentTheme.setPalette(previousPalette);
102+
}
103+
});
104+
42105
it('drops the gap, modes, and cwd in that order as width shrinks', () => {
43106
const component = new StatusBarComponent();
44107
component.update(status());
@@ -122,48 +185,3 @@ describe('StatusBarComponent', () => {
122185
expect(stripAnsi(component.render(240)[0] ?? '')).toContain(expected);
123186
});
124187
});
125-
126-
describe('shimmerText', () => {
127-
afterEach(() => {
128-
vi.restoreAllMocks();
129-
});
130-
131-
it('preserves the input text when ANSI is removed', () => {
132-
vi.spyOn(Date, 'now').mockReturnValue(0);
133-
const text = 'Thinking carefully';
134-
135-
expect(
136-
stripAnsi(
137-
shimmerText(text, {
138-
baseToken: 'primary',
139-
shimmerToken: 'primaryShimmer',
140-
frame: 0,
141-
}),
142-
),
143-
).toBe(text);
144-
});
145-
146-
it('moves the cosine band with wall-clock time', () => {
147-
const previousLevel = chalk.level;
148-
chalk.level = 3;
149-
const now = vi.spyOn(Date, 'now');
150-
try {
151-
now.mockReturnValue(0);
152-
const first = shimmerText('abcdefghijklmno', {
153-
baseToken: 'primary',
154-
shimmerToken: 'primaryShimmer',
155-
frame: 0,
156-
});
157-
now.mockReturnValue(100);
158-
const second = shimmerText('abcdefghijklmno', {
159-
baseToken: 'primary',
160-
shimmerToken: 'primaryShimmer',
161-
frame: 0,
162-
});
163-
164-
expect(second).not.toBe(first);
165-
} finally {
166-
chalk.level = previousLevel;
167-
}
168-
});
169-
});

0 commit comments

Comments
 (0)