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/collapsed-tool-cards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Collapsed tool cards now show a short outcome row and a width-aware header.
59 changes: 44 additions & 15 deletions apps/pythinker-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ const GOAL_TIMER_INTERVAL_MS = 1_000;
const TIP_ROTATE_INTERVAL_MS = 10_000;
const TIP_SEPARATOR = ' | ';

export type ToolOutputExpandHint = 'expand' | 'collapse';

/**
* Expand tips into a rotation sequence using smooth weighted round-robin
* (the nginx SWRR algorithm). Higher-`priority` tips appear more often while
Expand Down Expand Up @@ -222,6 +224,7 @@ export class FooterComponent implements Component {
*/
private backgroundBashTaskCount = 0;
private backgroundAgentCount = 0;
private expandHintProvider: (() => ToolOutputExpandHint | null) | null = null;

constructor(state: AppState, onRefresh: () => void = () => {}) {
this.state = state;
Expand Down Expand Up @@ -300,6 +303,10 @@ export class FooterComponent implements Component {
* count produces its own bracketed badge on line 1; zeros hide them
* independently.
*/
setExpandHintProvider(provider: () => ToolOutputExpandHint | null): void {
this.expandHintProvider = provider;
}

setBackgroundCounts(counts: { bashTasks: number; agentTasks: number }): void {
this.backgroundBashTaskCount = Math.max(0, counts.bashTasks);
this.backgroundAgentCount = Math.max(0, counts.agentTasks);
Expand Down Expand Up @@ -345,26 +352,20 @@ export class FooterComponent implements Component {

const leftWidth = visibleWidth(leftLine);

// Rotating hint tips stay on the right unless they were given an
// inline slot in items (rendered above at their configured position)
// or the user dropped 'tips' from items.
let tipText = '';
const tipsInline = order.includes('tips');
const showTips = !tipsInline && (configured === null || configured.includes('tips'));
const tipCandidates: string[] = [];
if (showTips) {
const { primary, pair } = tipsForIndex(currentTipIndex());
const gap = 2;
const remaining = Math.max(0, width - leftWidth - gap);
if (pair && visibleWidth(pair) <= remaining) {
tipText = pair;
} else if (primary && visibleWidth(primary) <= remaining) {
tipText = primary;
}
if (pair) tipCandidates.push(pair);
if (primary) tipCandidates.push(primary);
}
const remaining = Math.max(0, width - leftWidth - 2);
const rightText = this.buildRightText(tipCandidates, remaining, colors);

if (tipText) {
const pad = width - leftWidth - visibleWidth(tipText);
line1 = leftLine + ' '.repeat(Math.max(0, pad)) + chalk.hex(colors.textMuted)(tipText);
if (rightText.length > 0) {
const pad = width - leftWidth - visibleWidth(rightText);
line1 = leftLine + ' '.repeat(Math.max(0, pad)) + rightText;
} else if (leftWidth <= width) {
line1 = leftLine;
} else {
Expand Down Expand Up @@ -395,8 +396,14 @@ export class FooterComponent implements Component {
chalk.hex(colors.text)(contextText) +
chalk.hex(colors.textDim)(speedSuffix);
} else {
const leftPad = Math.max(0, width - rightWidth);
const shortcut = customLine !== null ? this.expandShortcut() : null;
const left =
shortcut !== null && visibleWidth(shortcut) + 1 + rightWidth <= width
? chalk.hex(colors.textDim)(shortcut)
: '';
const leftPad = Math.max(0, width - visibleWidth(left) - rightWidth);
line2 =
left +
' '.repeat(leftPad) +
chalk.hex(colors.text)(contextText) +
chalk.hex(colors.textDim)(speedSuffix);
Expand All @@ -405,6 +412,28 @@ export class FooterComponent implements Component {
return [truncateToWidth(line1, width), truncateToWidth(line2, width)];
}

private expandShortcut(): string | null {
const hint = this.expandHintProvider?.() ?? null;
return hint === null ? null : `ctrl+o ${hint}`;
}

private buildRightText(tips: readonly string[], remaining: number, colors: ColorPalette): string {
const shortcut = this.expandShortcut();
if (shortcut === null) {
const tip = tips.find((candidate) => visibleWidth(candidate) <= remaining);
return tip === undefined ? '' : chalk.hex(colors.textMuted)(tip);
}
for (const tip of tips) {
if (visibleWidth(`${shortcut}${TIP_SEPARATOR}${tip}`) <= remaining) {
return (
chalk.hex(colors.textDim)(shortcut) +
chalk.hex(colors.textMuted)(`${TIP_SEPARATOR}${tip}`)
);
}
}
return visibleWidth(shortcut) <= remaining ? chalk.hex(colors.textDim)(shortcut) : '';
}

/**
* Rendered pieces per status-line slot. Empty-content slots (e.g. no goal,
* outside a git repo) yield an empty list so composition just skips them.
Expand Down
21 changes: 15 additions & 6 deletions apps/pythinker-code/src/tui/components/messages/read-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';

import type { ToolCallComponent, ToolCallReadSnapshot } from './tool-call';
import { TruncatedHeaderLine, type HeaderContent } from './truncated-header-line';

const THROTTLE_MS = 200;

Expand All @@ -37,7 +38,7 @@ interface ReadEntry {

export class ReadGroupComponent extends Container {
private readonly entries: ReadEntry[] = [];
private readonly headerText: Text;
private readonly headerText: TruncatedHeaderLine;
private readonly bodyContainer: Container;
private throttleTimer: ReturnType<typeof setTimeout> | null = null;
private lastFlushPhases = new Map<string, ToolCallReadSnapshot['phase']>();
Expand All @@ -46,7 +47,7 @@ export class ReadGroupComponent extends Container {
constructor(private readonly ui: TUI | undefined) {
super();
this.addChild(new Spacer(1));
this.headerText = new Text('', 0, 0);
this.headerText = new TruncatedHeaderLine('');
this.addChild(this.headerText);
this.bodyContainer = new Container();
this.addChild(this.bodyContainer);
Expand Down Expand Up @@ -130,7 +131,12 @@ export class ReadGroupComponent extends Container {
this.ui?.requestRender();
}

private buildHeader(total: number, pending: number, failed: number, totalLines: number): string {
private buildHeader(
total: number,
pending: number,
failed: number,
totalLines: number,
): HeaderContent {
const dim = (text: string): string => currentTheme.dim(text);

if (pending > 0) {
Expand All @@ -139,7 +145,6 @@ export class ReadGroupComponent extends Container {
return `${bullet}${label}`;
}

// All reads have finished, either successfully or with failures.
if (failed === total) {
const bullet = currentTheme.fg('error', '✗ ');
const label = currentTheme.boldFg('error', `Read ${String(total)} files`);
Expand All @@ -149,8 +154,12 @@ export class ReadGroupComponent extends Container {
const bullet = currentTheme.fg('success', STATUS_BULLET);
const label = currentTheme.boldFg('textStrong', `Read ${String(total)} files`);
const linesPart = dim(` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`);
const failPart = failed > 0 ? currentTheme.fg('error', ` · ${String(failed)} failed`) : '';
return `${bullet}${label}${linesPart}${failPart}`;
if (failed === 0) return `${bullet}${label}${linesPart}`;
return {
head: `${bullet}${label}`,
flex: { text: ` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`, style: dim, keep: 'head' },
tail: currentTheme.fg('error', ` · ${String(failed)} failed`),
};
}

private buildBodyLine(snap: ToolCallReadSnapshot, isLast: boolean): string {
Expand Down
32 changes: 21 additions & 11 deletions apps/pythinker-code/src/tui/components/messages/shell-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { currentTheme } from '#/tui/theme';
import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';

import type { ResultRenderer } from './tool-renderers/types';
import { PREVIEW_LINES } from './tool-renderers/types';
import { isSpilledToolOutput, PREVIEW_LINES } from './tool-renderers/types';
import { outcomeRows } from './tool-renderers/outcome';
import { TruncatedOutputComponent } from './tool-renderers/truncated';

export interface ShellExecutionOptions {
Expand Down Expand Up @@ -60,6 +61,12 @@ export class ShellExecutionComponent extends Container {
}
}

wasTruncated(): boolean {
return this.children.some(
(child) => child instanceof TruncatedOutputComponent && child.wasTruncated(),
);
}

private addResultPreview(
result: ToolResultBlockData,
expanded: boolean,
Expand All @@ -85,13 +92,16 @@ export const shellExecutionResultRenderer: ResultRenderer = (
_toolCall: ToolCallBlockData,
result: ToolResultBlockData,
ctx,
): Component[] => [
// Result only. The command preview is owned by ToolCallComponent's
// buildCallPreview across the whole lifecycle (streaming, running, and
// done); rendering it here too would duplicate the command once the result
// lands.
new ShellExecutionComponent({
result,
expanded: ctx.expanded,
}),
];
): Component[] => {
if (!ctx.expanded && result.is_error !== true) {
const leadsWithMetadata =
result.output.startsWith('task_id:') || isSpilledToolOutput(result.output);
return outcomeRows(result.output, leadsWithMetadata ? 'first' : 'last');
}
return [
new ShellExecutionComponent({
result,
expanded: ctx.expanded,
}),
];
};
Loading
Loading