Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tui-signature-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pythoughts/pythinker-code": minor
---

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.
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ export class ActivityLoader extends Text {
? shimmerText(this.label, {
baseToken: 'primary',
shimmerToken: 'primaryShimmer',
frame: this.animationFrame,
})
: this.label;
this.displayText = label ? `${coloredFrame} ${label}` : coloredFrame;
Expand Down
29 changes: 4 additions & 25 deletions apps/pythinker-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,16 @@ import chalk from 'chalk';

import {
createFooterState,
formatStatusRow,
reduceFooterState,
selectFooterViewModel,
type FooterBackgroundCounts,
type FooterGitStatus,
type FooterGoal,
type FooterState,
type FooterStatus,
type FooterStatusRowViewModel,
type FooterViewModel,
type FooterViewModelRow,
} from '#/tui/runtime/footer/footer-model';
import { currentTheme } from '#/tui/theme';
import type { AppState } from '#/tui/types';
import {
createGitStatusCache,
Expand Down Expand Up @@ -275,7 +272,7 @@ export class FooterComponent implements Component {
this.state.statusLine,
);
return viewModel.rows.flatMap((row) => {
if (row.kind === 'activity' || row.kind === 'composer') return [];
if (row.kind === 'composer' || row.kind === 'status' || row.kind === 'activity') return [];
return [truncateToWidth(renderLegacyRow(row), width, '…')];
});
}
Expand Down Expand Up @@ -376,28 +373,10 @@ export class FooterComponent implements Component {
}
}

/** Keep the persistent status quiet; danger rows remain explicitly red. */
function paintStatusRow(
row: string,
_modelName: string | null,
emphasis: FooterStatusRowViewModel['emphasis'],
function renderLegacyRow(
row: Extract<FooterViewModelRow, { readonly kind: 'validation' }>,
): string {
return currentTheme.fg(emphasis === 'danger' ? 'error' : 'textDim', row);
}

function renderLegacyRow(row: Exclude<FooterViewModelRow, { readonly kind: 'composer' }>): string {
switch (row.kind) {
case 'activity':
return row.primary.length === 0
? row.indicators.join(' ')
: row.indicators.length === 0
? row.primary
: `${row.primary} ${row.indicators.join(' ')}`;
case 'validation':
return row.level === 'info' ? row.message : `${row.level}: ${row.message}`;
case 'status':
return paintStatusRow(formatStatusRow(row.items), row.modelName, row.emphasis);
}
return row.level === 'info' ? row.message : `${row.level}: ${row.message}`;
}

function hasGoalBadge(goal: AppState['goal']): boolean {
Expand Down
135 changes: 135 additions & 0 deletions apps/pythinker-code/src/tui/components/chrome/status-bar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { sep } from 'node:path';

import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui';
import chalk from 'chalk';

import type { StatusLineConfig } from '#/tui/config';
import {
formatTokenSpeed,
type FooterStatus,
} from '#/tui/runtime/footer/footer-model';
import { currentTheme } from '#/tui/theme';
import { themeFromHexChannels } from '#/tui/theme/terminal-background';
import { effortColorToken, shortEffortLabel } from '#/tui/utils/thinking-levels';
import { sessionAccentHex } from '#/tui/utils/session-accent';

export type StatusBarStatus = Pick<
FooterStatus,
| 'model'
| 'thinkingLevel'
| 'cwd'
| 'homeDir'
| 'permissionMode'
| 'planMode'
| 'fastMode'
| 'dynamicWorkflowMode'
| 'tokenSpeed'
| 'tokenSpeedEstimated'
> & {
readonly extras: readonly string[];
readonly sessionKey: string;
readonly statusLine: StatusLineConfig;
};

export class StatusBarComponent implements Component {
private status: StatusBarStatus | undefined;

update(status: StatusBarStatus): void {
this.status = status;
}

render(width: number): string[] {
const status = this.status;
if (status === undefined) return [];

const effortSuffix = status.statusLine.showEffort && status.thinkingLevel !== 'off'
? `${currentTheme.fg('textDim', ' · ')}${currentTheme.fg(
effortColorToken(status.thinkingLevel),
shortEffortLabel(status.thinkingLevel),
)}`
: '';
const fastSuffix = status.statusLine.showModes && status.fastMode
? `${currentTheme.fg('textDim', ' · ')}${currentTheme.fg('modeFast', '↯ fast')}`
: '';
const speed = status.statusLine.showTokenSpeed ? formatTokenSpeed(status) : null;
const modelChip = status.statusLine.showModel
? chip(
`${currentTheme.fg('text', status.model)}${effortSuffix}${fastSuffix}${
speed === null ? '' : currentTheme.fg('textDim', ` · ${speed}`)
}`,
)
: undefined;
let modesChip = status.statusLine.showModes ? renderModesChip(status) : undefined;
const extraChips = status.extras.map((extra) =>
chip(currentTheme.fg('textDim', extra)),
);
let cwdChip: string | undefined = chip(
currentTheme.fg('textDim', shortenCwd(status.cwd, status.homeDir)),
);
const left = (): string =>
[modelChip, modesChip, ...extraChips]
.filter((item): item is string => item !== undefined)
.join(' ');
const fullGapWidth =
width - visibleWidth(left()) - (cwdChip === undefined ? 1 : visibleWidth(cwdChip) + 2);

let line: string;
if (fullGapWidth > 0) {
const background = currentTheme.color('background');
const mode = themeFromHexChannels(
background.slice(1, 3),
background.slice(3, 5),
background.slice(5, 7),
);
const gap = chalk.hex(sessionAccentHex(status.sessionKey, mode))('─'.repeat(fullGapWidth));
line = cwdChip === undefined ? `${left()} ${gap}` : `${left()} ${gap} ${cwdChip}`;
} else {
line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`;
while (visibleWidth(line) > width && extraChips.length > 0) {
extraChips.pop();
line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`;
}
if (visibleWidth(line) > width && modesChip !== undefined) {
modesChip = undefined;
line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`;
}
if (visibleWidth(line) > width && cwdChip !== undefined) {
cwdChip = undefined;
line = left();
}
}

return [truncateToWidth(line, Math.max(0, width))];
}

invalidate(): void {}
}

function chip(content: string): string {
return currentTheme.bg('surfaceHighlight', ` ${content} `);
}

function renderModesChip(status: StatusBarStatus): string | undefined {
const modes: string[] = [];
if (status.planMode) modes.push(currentTheme.fg('modePlan', 'plan'));
if (status.permissionMode === 'auto') modes.push(currentTheme.fg('modePermission', 'auto'));
if (status.permissionMode === 'yolo') modes.push(currentTheme.fg('error', 'yolo'));
if (status.dynamicWorkflowMode) modes.push(currentTheme.fg('accent', 'workflow'));
return modes.length === 0 ? undefined : chip(modes.join(' '));
}

function shortenCwd(cwd: string, homeDir: string | null): string {
const path = homeDir !== null && homeDir.length > 0
? cwd === homeDir
? '~'
: cwd.startsWith(`${homeDir}${sep}`)
? `~${cwd.slice(homeDir.length)}`
: cwd
: cwd;
const segments = path.startsWith(`~${sep}`)
? path.slice(2).split(sep)
: path.startsWith(sep)
? path.slice(1).split(sep)
: [];
return segments.length > 2 ? `…${sep}${segments.slice(-2).join(sep)}` : path;
}
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,6 @@ export class CompactionComponent extends Container {
shimmerText('Compacting conversation…', {
baseToken: 'primary',
shimmerToken: 'primaryShimmer',
frame: this.animationFrame,
}),
);
return `${label}${currentTheme.dim(` (${String(this.elapsedSeconds())}s)`)}`;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui';

import { DYNAMIC_WORKFLOW_RENDERING } from '#/tui/constant/rendering';
import {
BRAILLE_SPINNER_FRAMES,
BRAILLE_SPINNER_INTERVAL_MS,
DYNAMIC_WORKFLOW_RENDERING,
} from '#/tui/constant/rendering';
import { currentTheme } from '#/tui/theme';
import { shimmerText } from '#/tui/utils/shimmer';

Expand Down Expand Up @@ -507,9 +511,18 @@

private renderAggregate(width: number, nowMs: number): string {
const terminal = isTerminalRequestPhase(this.model.requestPhase);
const frame = Math.floor(
Math.max(0, nowMs - this.model.startedAtMs) /
BRAILLE_SPINNER_INTERVAL_MS,
);
const loader = terminal
? currentTheme.fg(requestPhaseColor(this.model.requestPhase), requestPhaseSymbol(this.model.requestPhase))
: this.activitySpinnerText?.() ?? currentTheme.fg('primary', '●');
: this.activitySpinnerText === undefined
? currentTheme.fg('primary', '●')
: currentTheme.fg(
'primary',
BRAILLE_SPINNER_FRAMES[frame % BRAILLE_SPINNER_FRAMES.length]!,
);
const aggregateMembers = this.aggregateMembers();
// All spawned agents are done but the tool result has not arrived yet:
// the label says so instead of pretending orchestration is still active.
Expand All @@ -525,15 +538,10 @@
const label = terminal
? currentTheme.fg('text', requestPhaseLabel(this.model.requestPhase))
: shimmerText(finalizing ? FINALIZING_LABEL : ORCHESTRATING_LABEL, {
// `primary` / `primaryShimmer` are a designed pair, so the sweep stays
// periwinkle throughout; the old grey `text` base washed it out.
baseToken: 'primary',
shimmerToken: 'primaryShimmer',
frame: Math.floor(
Math.max(0, nowMs - this.model.startedAtMs) /
DYNAMIC_WORKFLOW_RENDERING.aggregateShimmerFrameMs,
),
windowSize: 4,
altShimmerToken: 'warningShimmer',
bandHalfWidth: 4,
});
const paddedLabel = padToWidth(label, LIVE_LABEL_WIDTH);
const prefix = `${loader} ${paddedLabel}`;
Expand Down Expand Up @@ -829,7 +837,7 @@

/** Best-effort `description` read from a partially streamed JSON arguments string. */
export function dynamicWorkflowPartialDescriptionFromArguments(argumentsText: string): string {
const match = /"description"\s*:\s*"/.exec(argumentsText);

Check warning on line 840 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (match === null) return '';
return parsePartialJsonString(argumentsText, match.index + match[0].length).value;
}
Expand Down Expand Up @@ -878,7 +886,7 @@
}

function dynamicWorkflowPartialResumeItemsFromArguments(argumentsText: string): string[] {
const match = /"resume_agent_ids"\s*:\s*\{/.exec(argumentsText);

Check warning on line 889 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (match === null) return [];
return Array.from(
{ length: countPartialJsonObjectEntries(argumentsText, match.index + match[0].length) },
Expand All @@ -892,7 +900,7 @@
}

function dynamicWorkflowPartialPromptTemplateFromArguments(argumentsText: string): string {
const match = /"prompt_template"\s*:\s*"/.exec(argumentsText);

Check warning on line 903 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (match === null) return '';
return parsePartialJsonString(argumentsText, match.index + match[0].length).value;
}
Expand All @@ -904,7 +912,7 @@
// Indexes are validated and deduplicated: an explicit index is honored only
// once and within range; a duplicated one is dropped, not remapped.
const usedIndexes = new Set<number>();
const tagPattern = /<subagent\b([^>]*)>/g;

Check warning on line 915 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
let match: RegExpExecArray | null;
while (
statuses.length < MAX_DYNAMIC_WORKFLOW_MEMBERS &&
Expand Down Expand Up @@ -965,9 +973,9 @@

function dynamicWorkflowResultEnvelope(output: string): string | undefined {
let candidate = output.trim();
const prefix = /^dynamic_workflow:\s*(?:(?:completed|failed|cancelled|aborted)\s*)?/i.exec(candidate);

Check warning on line 976 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (prefix !== null) candidate = candidate.slice(prefix[0].length).trimStart();
const opening = /^<dynamic_workflow_result\b[^>]*>/.exec(candidate);

Check warning on line 978 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (opening === null) return undefined;
const close = candidate.indexOf('</dynamic_workflow_result>', opening[0].length);
if (close < 0) return undefined;
Expand All @@ -975,7 +983,7 @@
}

function dynamicWorkflowSummaryFromEnvelope(envelope: string): Omit<DynamicWorkflowResultSummary, 'parsed'> {
const summary = /<summary\b[^>]*>([\s\S]*?)<\/summary>/.exec(envelope)?.[1] ?? '';

Check warning on line 986 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
return {
completed: summaryCount(summary, 'completed'),
failed: summaryCount(summary, 'failed'),
Expand All @@ -984,12 +992,12 @@
}

function summaryCount(summary: string, label: string): number {
const value = new RegExp(`\\b${label}\\s*:\\s*(\\d+)`, 'i').exec(summary)?.[1];

Check warning on line 995 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
return value === undefined ? 0 : Number(value);
}

function xmlAttribute(attributes: string, name: string): string | undefined {
const value = new RegExp(`\\b${name}="([^"]*)"`).exec(attributes)?.[1];

Check warning on line 1000 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
return value === undefined ? undefined : decodeXmlEntities(value);
}

Expand Down Expand Up @@ -1076,7 +1084,7 @@
}

function normalizeText(text: string | undefined): string {
return text?.replaceAll(/\s+/g, ' ').trim() ?? '';

Check warning on line 1087 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
}

/**
Expand Down
3 changes: 1 addition & 2 deletions apps/pythinker-code/src/tui/components/messages/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,7 @@ export class ThinkingComponent implements Component {
const label = shimmerText(formatThinkingSpinnerLabel(), {
baseToken: 'primary',
shimmerToken: 'primaryShimmer',
frame: this.animationFrame,
windowSize: 4,
bandHalfWidth: 4,
});
return ['', spinner + label, ...visibleLines.map((line) => MESSAGE_INDENT + line)];
}
Expand Down
19 changes: 18 additions & 1 deletion apps/pythinker-code/src/tui/components/messages/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,24 @@ export class ToolCallComponent extends Container {

override render(width: number): string[] {
this.headerText.setText(truncateToWidth(this.buildHeader(), Math.max(0, width)));
return super.render(width);
const lines = super.render(width);
const background =
this.result === undefined
? this.toolCall.truncated === true
? undefined
: 'toolPendingBg'
: this.result.is_error !== true
? 'toolSuccessBg'
: 'toolErrorBg';
if (background === undefined) return lines;
return lines.map((line, index) =>
index === 0
? line
: currentTheme.bg(
background,
`${line}${' '.repeat(Math.max(0, width - visibleWidth(line)))}`,
),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

setExpanded(expanded: boolean): void {
Expand Down
10 changes: 4 additions & 6 deletions apps/pythinker-code/src/tui/constant/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,10 @@ export const DYNAMIC_WORKFLOW_RENDERING = {
memberProgressWidth: 8,
/** Least width of the lifecycle STATE column in member rows. */
stateColumnWidth: 6,
/** Cadence for the live aggregate-label shimmer. */
aggregateShimmerFrameMs: BRAILLE_SPINNER_INTERVAL_MS,
/** Thin-arc frames for a running row; all rows share one clock. */
progressFrames: ['◜', '◝', '◞', '◟'],
/** Arc cadence in milliseconds. */
progressFrameMs: 120,
/** Half-circle frames for a running row; all rows share one clock. */
progressFrames: ['◐', '◓', '◑', '◒'],
/** Rotation cadence in milliseconds — deliberately slow; this is ambience, not progress. */
progressFrameMs: 300,
/** Least room the task keeps before the detail may claim any of the row. */
memberTaskMinWidth: 12,
/** Share of the free row the task may take before the detail gets the rest. */
Expand Down
26 changes: 19 additions & 7 deletions apps/pythinker-code/src/tui/pythinker-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import { readUpdateInstallState } from '#/cli/update/install-state';
import { detectInstallSource } from '#/cli/update/source';
import type { InstallSource } from '#/cli/update/types';
import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index';
import { effortColorToken } from '#/tui/utils/thinking-levels';
import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text';
import {
appendInputHistory,
Expand Down Expand Up @@ -141,6 +140,7 @@ import type { TuiPresentation } from './runtime/contracts';
import {
foldFooterEvents,
selectFooterViewModel,
selectStatusBarExtras,
type FooterActivity,
type FooterEvent,
type FooterGoal,
Expand Down Expand Up @@ -965,6 +965,7 @@ export class PythinkerTUI {
ui.addChild(this.state.btwPanelContainer);
ui.addChild(this.state.mcpStatusContainer);
ui.addChild(this.state.editorContainer);
ui.addChild(this.state.statusBarContainer);
// Footer is mounted later (mountFooter), not here.
}

Expand All @@ -974,6 +975,8 @@ export class PythinkerTUI {
// only once init() succeeds. FooterComponent isn't a Container, so wrap it to
// pick up the same outer gutter as the panels above.
private mountFooter(): void {
this.state.statusBarContainer.clear();
this.state.statusBarContainer.addChild(this.state.statusBar);
if (this.state.layout === 'fixed') {
this.state.layoutRoot.setFooterMounted(true);
return;
Expand Down Expand Up @@ -1333,7 +1336,7 @@ export class PythinkerTUI {
if (!hasPatchChanges(this.state.appState, patch)) return;
const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch;
Object.assign(this.state.appState, patch);
if ('planMode' in patch) this.updateEditorBorderHighlight();
if ('planMode' in patch || 'permissionMode' in patch) this.updateEditorBorderHighlight();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
this.state.footer.syncAppState(this.state.appState);
this.syncFooterState();
this.updateActivityPane();
Expand Down Expand Up @@ -1391,6 +1394,19 @@ export class PythinkerTUI {
this.state.appState.statusLine,
),
);
this.state.statusBar.update({
...this.state.footerState.status,
extras: selectStatusBarExtras(
this.state.footerState,
Date.now(),
this.state.appState.statusLine,
),
sessionKey:
this.state.appState.sessionTitle?.trim() ||
this.state.appState.sessionId ||
this.state.appState.workDir,
statusLine: this.state.appState.statusLine,
});
}

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