Skip to content

Commit 39ab58b

Browse files
committed
feat(tui): render the status bar below the editor as the single status line
1 parent d2f66bc commit 39ab58b

16 files changed

Lines changed: 300 additions & 220 deletions

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

Lines changed: 6 additions & 15 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') return [];
279276
return [truncateToWidth(renderLegacyRow(row), width, '…')];
280277
});
281278
}
@@ -376,16 +373,12 @@ 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: Exclude<
378+
FooterViewModelRow,
379+
{ readonly kind: 'composer' } | { readonly kind: 'status' }
380+
>,
384381
): string {
385-
return currentTheme.fg(emphasis === 'danger' ? 'error' : 'textDim', row);
386-
}
387-
388-
function renderLegacyRow(row: Exclude<FooterViewModelRow, { readonly kind: 'composer' }>): string {
389382
switch (row.kind) {
390383
case 'activity':
391384
return row.primary.length === 0
@@ -395,8 +388,6 @@ function renderLegacyRow(row: Exclude<FooterViewModelRow, { readonly kind: 'comp
395388
: `${row.primary} ${row.indicators.join(' ')}`;
396389
case 'validation':
397390
return row.level === 'info' ? row.message : `${row.level}: ${row.message}`;
398-
case 'status':
399-
return paintStatusRow(formatStatusRow(row.items), row.modelName, row.emphasis);
400391
}
401392
}
402393

apps/pythinker-code/src/tui/components/chrome/status-bar.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ export type StatusBarStatus = Pick<
1919
| 'planMode'
2020
| 'fastMode'
2121
| 'dynamicWorkflowMode'
22-
> & { readonly sessionKey: string };
22+
> & {
23+
readonly extras: readonly string[];
24+
readonly sessionKey: string;
25+
};
2326

2427
export class StatusBarComponent implements Component {
2528
private status: StatusBarStatus | undefined;
@@ -39,10 +42,16 @@ export class StatusBarComponent implements Component {
3942
)}`,
4043
);
4144
let modesChip = renderModesChip(status);
45+
const extraChips = status.extras.map((extra) =>
46+
chip(currentTheme.fg('textDim', extra)),
47+
);
4248
let cwdChip: string | undefined = chip(
4349
currentTheme.fg('textDim', shortenCwd(status.cwd, status.homeDir)),
4450
);
45-
const left = (): string => `${modelChip}${modesChip === undefined ? '' : ` ${modesChip}`}`;
51+
const left = (): string =>
52+
[modelChip, modesChip, ...extraChips]
53+
.filter((item): item is string => item !== undefined)
54+
.join(' ');
4655
const fullGapWidth =
4756
width - visibleWidth(left()) - (cwdChip === undefined ? 1 : visibleWidth(cwdChip) + 2);
4857

@@ -58,6 +67,10 @@ export class StatusBarComponent implements Component {
5867
line = cwdChip === undefined ? `${left()} ${gap}` : `${left()} ${gap} ${cwdChip}`;
5968
} else {
6069
line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`;
70+
while (visibleWidth(line) > width && extraChips.length > 0) {
71+
extraChips.pop();
72+
line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`;
73+
}
6174
if (visibleWidth(line) > width && modesChip !== undefined) {
6275
modesChip = undefined;
6376
line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`;

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,9 +507,20 @@ export class DynamicWorkflowMissionControlComponent implements Component {
507507

508508
private renderAggregate(width: number, nowMs: number): string {
509509
const terminal = isTerminalRequestPhase(this.model.requestPhase);
510+
const frame = Math.floor(
511+
Math.max(0, nowMs - this.model.startedAtMs) /
512+
DYNAMIC_WORKFLOW_RENDERING.progressFrameMs,
513+
);
510514
const loader = terminal
511515
? currentTheme.fg(requestPhaseColor(this.model.requestPhase), requestPhaseSymbol(this.model.requestPhase))
512-
: this.activitySpinnerText?.() ?? currentTheme.fg('primary', '●');
516+
: this.activitySpinnerText === undefined
517+
? currentTheme.fg('primary', '●')
518+
: currentTheme.fg(
519+
'primary',
520+
DYNAMIC_WORKFLOW_RENDERING.progressFrames[
521+
frame % DYNAMIC_WORKFLOW_RENDERING.progressFrames.length
522+
] ?? DYNAMIC_WORKFLOW_RENDERING.progressFrames[0],
523+
);
513524
const aggregateMembers = this.aggregateMembers();
514525
// All spawned agents are done but the tool result has not arrived yet:
515526
// the label says so instead of pretending orchestration is still active.

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@ export const DYNAMIC_WORKFLOW_RENDERING = {
3232
stateColumnWidth: 6,
3333
/** Cadence for the live aggregate-label shimmer. */
3434
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,
35+
/** Half-circle frames for a running row; all rows share one clock. */
36+
progressFrames: ['', '', '', ''],
37+
/** Rotation cadence in milliseconds — deliberately slow; this is ambience, not progress. */
38+
progressFrameMs: 300,
3939
/** Least room the task keeps before the detail may claim any of the row. */
4040
memberTaskMinWidth: 12,
4141
/** 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: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ import type { TuiPresentation } from './runtime/contracts';
141141
import {
142142
foldFooterEvents,
143143
selectFooterViewModel,
144+
selectStatusBarExtras,
144145
type FooterActivity,
145146
type FooterEvent,
146147
type FooterGoal,
@@ -964,8 +965,8 @@ export class PythinkerTUI {
964965
ui.addChild(this.state.queueContainer);
965966
ui.addChild(this.state.btwPanelContainer);
966967
ui.addChild(this.state.mcpStatusContainer);
967-
ui.addChild(this.state.statusBarContainer);
968968
ui.addChild(this.state.editorContainer);
969+
ui.addChild(this.state.statusBarContainer);
969970
// Footer is mounted later (mountFooter), not here.
970971
}
971972

@@ -1396,6 +1397,11 @@ export class PythinkerTUI {
13961397
);
13971398
this.state.statusBar.update({
13981399
...this.state.footerState.status,
1400+
extras: selectStatusBarExtras(
1401+
this.state.footerState,
1402+
Date.now(),
1403+
this.state.appState.statusLine,
1404+
),
13991405
sessionKey:
14001406
this.state.appState.sessionTitle?.trim() ||
14011407
this.state.appState.sessionId ||

apps/pythinker-code/src/tui/runtime/footer/footer-model.ts

Lines changed: 81 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -464,68 +464,113 @@ function selectActivityRow(state: FooterState): FooterActivityRowViewModel {
464464
});
465465
}
466466

467-
function selectStatusItems(
467+
function selectStatusItemParts(
468468
state: FooterState,
469469
clockMs: number,
470470
statusLine: StatusLineConfig,
471-
): string[] {
472-
const items: string[] = [];
471+
): {
472+
readonly update: string | null;
473+
readonly model: string | null;
474+
readonly speed: string | null;
475+
readonly spend: string | null;
476+
readonly context: string | null;
477+
readonly git: string | null;
478+
readonly modes: string | null;
479+
readonly elapsed: string | null;
480+
readonly goal: string | null;
481+
readonly background: readonly string[];
482+
} {
473483
const update = formatUpdate(state.update);
474-
if (update !== null) items.push(update);
475-
const model = normalizeSingleLine(state.status.model);
476-
if (statusLine.showModel && model.length > 0) {
484+
const modelName = normalizeSingleLine(state.status.model);
485+
const speed = statusLine.showTokenSpeed ? formatTokenSpeed(state.status) : null;
486+
let model: string | null = null;
487+
if (statusLine.showModel && modelName.length > 0) {
477488
const effortSuffix =
478489
statusLine.showEffort && state.status.thinkingLevel !== 'off'
479490
? ` · ${shortEffortLabel(state.status.thinkingLevel)}`
480491
: '';
481492
// Fast rides on the model item and only while mode badges are visible,
482493
// so it can never appear twice in the row.
483494
const fastSuffix = statusLine.showModes && state.status.fastMode ? ' · ↯ fast' : '';
484-
const speed = statusLine.showTokenSpeed ? formatTokenSpeed(state.status) : null;
485-
items.push(`${model}${effortSuffix}${fastSuffix}${speed === null ? '' : ` · ${speed}`}`);
486-
}
487-
488-
if (statusLine.showModel) {
489-
const spend = formatSessionSpend(state.status.sessionSpendUsd);
490-
if (spend !== null) items.push(spend);
491-
}
492-
493-
if (statusLine.showContextBar) items.push(formatContext(state.status));
494-
495-
if (statusLine.showGit) {
496-
const git = formatGitStatus(state.status.git);
497-
if (git !== null) items.push(git);
495+
model = `${modelName}${effortSuffix}${fastSuffix}${speed === null ? '' : ` · ${speed}`}`;
498496
}
499497

498+
let modes: string | null = null;
500499
if (statusLine.showModes) {
501-
const modes: string[] = [];
502-
if (state.status.dynamicWorkflowMode) modes.push('workflow');
503-
if (state.status.permissionMode === 'auto') modes.push('auto');
504-
if (state.status.planMode) modes.push('plan');
505-
if (modes.length > 0) items.push(modes.join(' '));
506-
}
507-
508-
if (statusLine.showElapsed && state.status.elapsedMs !== null) {
509-
items.push(`elapsed ${formatStatusElapsed(state.status.elapsedMs)}`);
510-
}
511-
512-
if (statusLine.showGoal) {
513-
const goal = formatGoal(state.goal, clockMs);
514-
if (goal !== null) items.push(goal);
500+
const modeItems: string[] = [];
501+
if (state.status.dynamicWorkflowMode) modeItems.push('workflow');
502+
if (state.status.permissionMode === 'auto') modeItems.push('auto');
503+
if (state.status.planMode) modeItems.push('plan');
504+
if (modeItems.length > 0) modes = modeItems.join(' ');
515505
}
516506

507+
const background: string[] = [];
517508
if (statusLine.showBackgroundTasks) {
518509
const bashTasks = nonNegativeInteger(state.background.bashTasks);
519510
if (bashTasks > 0) {
520-
items.push(`[${String(bashTasks)} ${plural(bashTasks, 'task')} running]`);
511+
background.push(`[${String(bashTasks)} ${plural(bashTasks, 'task')} running]`);
521512
}
522513
const agentTasks = nonNegativeInteger(state.background.agentTasks);
523514
if (agentTasks > 0) {
524-
items.push(
515+
background.push(
525516
`[${String(agentTasks)} ${plural(agentTasks, 'agent')} running]`,
526517
);
527518
}
528519
}
520+
521+
return {
522+
update,
523+
model,
524+
speed,
525+
spend: statusLine.showModel ? formatSessionSpend(state.status.sessionSpendUsd) : null,
526+
context: statusLine.showContextBar ? formatContext(state.status) : null,
527+
git: statusLine.showGit ? formatGitStatus(state.status.git) : null,
528+
modes,
529+
elapsed:
530+
statusLine.showElapsed && state.status.elapsedMs !== null
531+
? `elapsed ${formatStatusElapsed(state.status.elapsedMs)}`
532+
: null,
533+
goal: statusLine.showGoal ? formatGoal(state.goal, clockMs) : null,
534+
background,
535+
};
536+
}
537+
538+
function selectStatusItems(
539+
state: FooterState,
540+
clockMs: number,
541+
statusLine: StatusLineConfig,
542+
): string[] {
543+
const parts = selectStatusItemParts(state, clockMs, statusLine);
544+
const items = [
545+
parts.update,
546+
parts.model,
547+
parts.spend,
548+
parts.context,
549+
parts.git,
550+
parts.modes,
551+
parts.elapsed,
552+
parts.goal,
553+
].filter((item): item is string => item !== null);
554+
items.push(...parts.background);
555+
return items;
556+
}
557+
558+
export function selectStatusBarExtras(
559+
state: FooterState,
560+
clockMs: number,
561+
statusLine: StatusLineConfig,
562+
): string[] {
563+
const parts = selectStatusItemParts(state, clockMs, statusLine);
564+
const items = [
565+
parts.update,
566+
parts.speed,
567+
parts.spend,
568+
parts.context,
569+
parts.git,
570+
parts.elapsed,
571+
parts.goal,
572+
].filter((item): item is string => item !== null);
573+
items.push(...parts.background);
529574
return items;
530575
}
531576

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,8 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState {
114114
queueContainer,
115115
btwPanelContainer,
116116
mcpStatusContainer,
117-
statusBarContainer,
118117
editorContainer,
118+
statusBarContainer,
119119
],
120120
footerWrap,
121121
);

0 commit comments

Comments
 (0)