Skip to content

Commit 09d69fe

Browse files
elkholy90elkaix
andauthored
feat(tui): show outcome rows on collapsed tool cards (#313)
## Related Issue No tracked issue — follow-on to the minidb test isolation merge (#311). ## Problem Collapsed tool cards hid useful output behind ctrl+o and truncated long headers without preserving the result chip. ## What changed - Collapsed cards show a width-aware header and a short outcome row (up to three lines, then one telling line). - Grep/Glob chips count files or matches from the tool output, skip notices, and keep the existing Glob pagination `N+ files` marker. - The footer shows `ctrl+o expand` or `ctrl+o collapse` when a recent card hides output. No docs update (existing ctrl+o tip still applies). Changeset included. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [ ] I have linked a related issue (external PRs: the issue must have a maintainer's `/approve`). - [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** - Collapsed tool cards now show concise outcome rows and width-aware headers. - Added clearer expand/collapse guidance for hidden tool output. - Improved summaries for shell commands, file changes, searches, reads, and fetches. - Headers preserve important details, such as filenames and result counts, when space is limited. - **Bug Fixes** - Improved handling of truncated, spilled, partial, and paginated tool output. - Search and glob summaries now report counts and incomplete results more accurately. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: elkaix <melkholy@techmatrix.com>
1 parent bdc2808 commit 09d69fe

25 files changed

Lines changed: 1117 additions & 201 deletions

‎.changeset/collapsed-tool-cards.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Collapsed tool cards now show a short outcome row and a width-aware header.

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

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ const GOAL_TIMER_INTERVAL_MS = 1_000;
5959
const TIP_ROTATE_INTERVAL_MS = 10_000;
6060
const TIP_SEPARATOR = ' | ';
6161

62+
export type ToolOutputExpandHint = 'expand' | 'collapse';
63+
6264
/**
6365
* Expand tips into a rotation sequence using smooth weighted round-robin
6466
* (the nginx SWRR algorithm). Higher-`priority` tips appear more often while
@@ -222,6 +224,7 @@ export class FooterComponent implements Component {
222224
*/
223225
private backgroundBashTaskCount = 0;
224226
private backgroundAgentCount = 0;
227+
private expandHintProvider: (() => ToolOutputExpandHint | null) | null = null;
225228

226229
constructor(state: AppState, onRefresh: () => void = () => {}) {
227230
this.state = state;
@@ -300,6 +303,10 @@ export class FooterComponent implements Component {
300303
* count produces its own bracketed badge on line 1; zeros hide them
301304
* independently.
302305
*/
306+
setExpandHintProvider(provider: () => ToolOutputExpandHint | null): void {
307+
this.expandHintProvider = provider;
308+
}
309+
303310
setBackgroundCounts(counts: { bashTasks: number; agentTasks: number }): void {
304311
this.backgroundBashTaskCount = Math.max(0, counts.bashTasks);
305312
this.backgroundAgentCount = Math.max(0, counts.agentTasks);
@@ -345,26 +352,20 @@ export class FooterComponent implements Component {
345352

346353
const leftWidth = visibleWidth(leftLine);
347354

348-
// Rotating hint tips stay on the right unless they were given an
349-
// inline slot in items (rendered above at their configured position)
350-
// or the user dropped 'tips' from items.
351-
let tipText = '';
352355
const tipsInline = order.includes('tips');
353356
const showTips = !tipsInline && (configured === null || configured.includes('tips'));
357+
const tipCandidates: string[] = [];
354358
if (showTips) {
355359
const { primary, pair } = tipsForIndex(currentTipIndex());
356-
const gap = 2;
357-
const remaining = Math.max(0, width - leftWidth - gap);
358-
if (pair && visibleWidth(pair) <= remaining) {
359-
tipText = pair;
360-
} else if (primary && visibleWidth(primary) <= remaining) {
361-
tipText = primary;
362-
}
360+
if (pair) tipCandidates.push(pair);
361+
if (primary) tipCandidates.push(primary);
363362
}
363+
const remaining = Math.max(0, width - leftWidth - 2);
364+
const rightText = this.buildRightText(tipCandidates, remaining, colors);
364365

365-
if (tipText) {
366-
const pad = width - leftWidth - visibleWidth(tipText);
367-
line1 = leftLine + ' '.repeat(Math.max(0, pad)) + chalk.hex(colors.textMuted)(tipText);
366+
if (rightText.length > 0) {
367+
const pad = width - leftWidth - visibleWidth(rightText);
368+
line1 = leftLine + ' '.repeat(Math.max(0, pad)) + rightText;
368369
} else if (leftWidth <= width) {
369370
line1 = leftLine;
370371
} else {
@@ -395,8 +396,14 @@ export class FooterComponent implements Component {
395396
chalk.hex(colors.text)(contextText) +
396397
chalk.hex(colors.textDim)(speedSuffix);
397398
} else {
398-
const leftPad = Math.max(0, width - rightWidth);
399+
const shortcut = customLine !== null ? this.expandShortcut() : null;
400+
const left =
401+
shortcut !== null && visibleWidth(shortcut) + 1 + rightWidth <= width
402+
? chalk.hex(colors.textDim)(shortcut)
403+
: '';
404+
const leftPad = Math.max(0, width - visibleWidth(left) - rightWidth);
399405
line2 =
406+
left +
400407
' '.repeat(leftPad) +
401408
chalk.hex(colors.text)(contextText) +
402409
chalk.hex(colors.textDim)(speedSuffix);
@@ -405,6 +412,28 @@ export class FooterComponent implements Component {
405412
return [truncateToWidth(line1, width), truncateToWidth(line2, width)];
406413
}
407414

415+
private expandShortcut(): string | null {
416+
const hint = this.expandHintProvider?.() ?? null;
417+
return hint === null ? null : `ctrl+o ${hint}`;
418+
}
419+
420+
private buildRightText(tips: readonly string[], remaining: number, colors: ColorPalette): string {
421+
const shortcut = this.expandShortcut();
422+
if (shortcut === null) {
423+
const tip = tips.find((candidate) => visibleWidth(candidate) <= remaining);
424+
return tip === undefined ? '' : chalk.hex(colors.textMuted)(tip);
425+
}
426+
for (const tip of tips) {
427+
if (visibleWidth(`${shortcut}${TIP_SEPARATOR}${tip}`) <= remaining) {
428+
return (
429+
chalk.hex(colors.textDim)(shortcut) +
430+
chalk.hex(colors.textMuted)(`${TIP_SEPARATOR}${tip}`)
431+
);
432+
}
433+
}
434+
return visibleWidth(shortcut) <= remaining ? chalk.hex(colors.textDim)(shortcut) : '';
435+
}
436+
408437
/**
409438
* Rendered pieces per status-line slot. Empty-content slots (e.g. no goal,
410439
* outside a git repo) yield an empty list so composition just skips them.

‎apps/pythinker-code/src/tui/components/messages/read-group.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { STATUS_BULLET } from '#/tui/constant/symbols';
2727
import { currentTheme } from '#/tui/theme';
2828

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

3132
const THROTTLE_MS = 200;
3233

@@ -37,7 +38,7 @@ interface ReadEntry {
3738

3839
export class ReadGroupComponent extends Container {
3940
private readonly entries: ReadEntry[] = [];
40-
private readonly headerText: Text;
41+
private readonly headerText: TruncatedHeaderLine;
4142
private readonly bodyContainer: Container;
4243
private throttleTimer: ReturnType<typeof setTimeout> | null = null;
4344
private lastFlushPhases = new Map<string, ToolCallReadSnapshot['phase']>();
@@ -46,7 +47,7 @@ export class ReadGroupComponent extends Container {
4647
constructor(private readonly ui: TUI | undefined) {
4748
super();
4849
this.addChild(new Spacer(1));
49-
this.headerText = new Text('', 0, 0);
50+
this.headerText = new TruncatedHeaderLine('');
5051
this.addChild(this.headerText);
5152
this.bodyContainer = new Container();
5253
this.addChild(this.bodyContainer);
@@ -130,7 +131,12 @@ export class ReadGroupComponent extends Container {
130131
this.ui?.requestRender();
131132
}
132133

133-
private buildHeader(total: number, pending: number, failed: number, totalLines: number): string {
134+
private buildHeader(
135+
total: number,
136+
pending: number,
137+
failed: number,
138+
totalLines: number,
139+
): HeaderContent {
134140
const dim = (text: string): string => currentTheme.dim(text);
135141

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

142-
// All reads have finished, either successfully or with failures.
143148
if (failed === total) {
144149
const bullet = currentTheme.fg('error', '✗ ');
145150
const label = currentTheme.boldFg('error', `Read ${String(total)} files`);
@@ -149,8 +154,12 @@ export class ReadGroupComponent extends Container {
149154
const bullet = currentTheme.fg('success', STATUS_BULLET);
150155
const label = currentTheme.boldFg('textStrong', `Read ${String(total)} files`);
151156
const linesPart = dim(` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`);
152-
const failPart = failed > 0 ? currentTheme.fg('error', ` · ${String(failed)} failed`) : '';
153-
return `${bullet}${label}${linesPart}${failPart}`;
157+
if (failed === 0) return `${bullet}${label}${linesPart}`;
158+
return {
159+
head: `${bullet}${label}`,
160+
flex: { text: ` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`, style: dim, keep: 'head' },
161+
tail: currentTheme.fg('error', ` · ${String(failed)} failed`),
162+
};
154163
}
155164

156165
private buildBodyLine(snap: ToolCallReadSnapshot, isLast: boolean): string {

‎apps/pythinker-code/src/tui/components/messages/shell-execution.ts‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { currentTheme } from '#/tui/theme';
55
import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';
66

77
import type { ResultRenderer } from './tool-renderers/types';
8-
import { PREVIEW_LINES } from './tool-renderers/types';
8+
import { isSpilledToolOutput, PREVIEW_LINES } from './tool-renderers/types';
9+
import { outcomeRows } from './tool-renderers/outcome';
910
import { TruncatedOutputComponent } from './tool-renderers/truncated';
1011

1112
export interface ShellExecutionOptions {
@@ -60,6 +61,12 @@ export class ShellExecutionComponent extends Container {
6061
}
6162
}
6263

64+
wasTruncated(): boolean {
65+
return this.children.some(
66+
(child) => child instanceof TruncatedOutputComponent && child.wasTruncated(),
67+
);
68+
}
69+
6370
private addResultPreview(
6471
result: ToolResultBlockData,
6572
expanded: boolean,
@@ -85,13 +92,16 @@ export const shellExecutionResultRenderer: ResultRenderer = (
8592
_toolCall: ToolCallBlockData,
8693
result: ToolResultBlockData,
8794
ctx,
88-
): Component[] => [
89-
// Result only. The command preview is owned by ToolCallComponent's
90-
// buildCallPreview across the whole lifecycle (streaming, running, and
91-
// done); rendering it here too would duplicate the command once the result
92-
// lands.
93-
new ShellExecutionComponent({
94-
result,
95-
expanded: ctx.expanded,
96-
}),
97-
];
95+
): Component[] => {
96+
if (!ctx.expanded && result.is_error !== true) {
97+
const leadsWithMetadata =
98+
result.output.startsWith('task_id:') || isSpilledToolOutput(result.output);
99+
return outcomeRows(result.output, leadsWithMetadata ? 'first' : 'last');
100+
}
101+
return [
102+
new ShellExecutionComponent({
103+
result,
104+
expanded: ctx.expanded,
105+
}),
106+
];
107+
};

0 commit comments

Comments
 (0)