Skip to content

Commit 9799e18

Browse files
committed
feat(tui): choose how much tool output shows before "Click to expand"
How much of a tool's output a block shows was fixed: 10 lines for bash, 3 for generic (plugin, MCP) tools, and the whole file or diff for write, edit and apply_patch, which had no way to fold at all. A long write or a chatty plugin call pushed the conversation off screen. tool_output.collapse in tui.json takes a number of lines or "never" per group (bash, generic, write, edit, apply_patch) or per tool name, a name winning over its group (toolOutputLimit). 0 hides the output until clicked, leaving the title, a blank line and the hint with the footer. The character cap stays at that many lines' worth, so long lines still fold. write, edit and apply_patch gain the expand toggle: ClippedRows shows the first N rendered rows of the code or diff and reports when it hides some. Unset keys keep the upstream limits, and the defaults render the transcript unchanged. AI-Tool: opencode 1.18.34-vt-163-907b3bc518 AI-Model: anthropic/claude-opus-5-5 AI-Variant: high AI-Platform: linux AI-Harness: Vibeterm ab1db14 AI-Session-ID: ses_ee68aab01ffei41gwUGJ5ESj6W
1 parent 868dc8a commit 9799e18

6 files changed

Lines changed: 293 additions & 64 deletions

File tree

‎packages/tui/src/config/index.tsx‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,7 @@ export const FooterSelector = Schema.Literals([...TimestampType.literals, "impor
166166
export const FooterValue = Schema.Union([
167167
Schema.Boolean,
168168
Schema.String.check(
169-
Schema.isPattern(
170-
new RegExp(`^\\s*(all|none|(${footerSelectorNames})(\\s*,\\s*(${footerSelectorNames}))*)\\s*$`),
171-
),
169+
Schema.isPattern(new RegExp(`^\\s*(all|none|(${footerSelectorNames})(\\s*,\\s*(${footerSelectorNames}))*)\\s*$`)),
172170
),
173171
Schema.Array(FooterSelector),
174172
])
@@ -328,6 +326,40 @@ export const DateTimeFormat = Schema.Struct({
328326
"How times and dates are written in message footers, entry timestamps, the compaction divider and the timeline and stash dialogs. Entries from today show only the time",
329327
})
330328

329+
export const ToolOutputGroup = Schema.Literals(["bash", "generic", "write", "edit", "apply_patch"])
330+
export type ToolOutputGroup = Schema.Schema.Type<typeof ToolOutputGroup>
331+
export const ToolOutputCollapseDefaults: Record<ToolOutputGroup, number | "never"> = {
332+
bash: 10,
333+
generic: 3,
334+
write: "never",
335+
edit: "never",
336+
apply_patch: "never",
337+
}
338+
export const ToolOutput = Schema.Struct({
339+
collapse: Schema.optional(
340+
Schema.Record(
341+
Schema.String,
342+
Schema.Union([Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), Schema.Literal("never")]),
343+
),
344+
).annotate({
345+
description: [
346+
"How many lines of a tool block's output show before 'Click to expand', per group or tool name.",
347+
"Groups: 'bash' (default 10), 'generic' - plugin, MCP and other tools without their own view (default 3),",
348+
"'write', 'edit', 'apply_patch' (default 'never').",
349+
"A tool name, such as 'vibeterm_prompt_session', overrides its group.",
350+
"A number of lines; 0 hides the output entirely until clicked; 'never' always shows all of it.",
351+
"Long lines count too: the preview is capped at that many lines' worth of characters.",
352+
].join(" "),
353+
}),
354+
}).annotate({ description: "Tool output in the session transcript" })
355+
export type ToolOutput = Schema.Schema.Type<typeof ToolOutput>
356+
357+
// Lines of output a tool block shows while collapsed; undefined when it never collapses.
358+
export function toolOutputLimit(config: ToolOutput | undefined, tool: string, group: ToolOutputGroup) {
359+
const value = config?.collapse?.[tool] ?? config?.collapse?.[group] ?? ToolOutputCollapseDefaults[group]
360+
return value === "never" ? undefined : value
361+
}
362+
331363
export const TranscriptMaxMessagesDefault = 100
332364
export const Transcript = Schema.Struct({
333365
max_messages: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))).annotate({
@@ -395,6 +427,7 @@ export const Info = Schema.Struct({
395427
}),
396428
footer: Schema.optional(Footer),
397429
datetime_format: Schema.optional(DateTimeFormat),
430+
tool_output: Schema.optional(ToolOutput),
398431
cursor: Schema.optional(Cursor),
399432
sidebar: Schema.optional(Sidebar),
400433
usage: Schema.optional(Usage),

‎packages/tui/src/routes/session/index.tsx‎

Lines changed: 145 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1911,7 +1911,10 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
19111911
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
19121912
<Show when={ctx.footer("time", "error", props.message.id)}>
19131913
<text fg={theme.textMuted}>
1914-
{Locale.todayTimeOrDateFirst(props.message.time.completed ?? props.message.time.created, ctx.tui.datetime_format)}
1914+
{Locale.todayTimeOrDateFirst(
1915+
props.message.time.completed ?? props.message.time.created,
1916+
ctx.tui.datetime_format,
1917+
)}
19151918
</text>
19161919
</Show>
19171920
</box>
@@ -2194,9 +2197,9 @@ function GenericTool(props: ToolProps) {
21942197
const ctx = use()
21952198
const output = createMemo(() => props.output?.trim() ?? "")
21962199
const [expanded, setExpanded] = createSignal(false)
2197-
const maxLines = 3
2198-
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
2199-
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
2200+
const collapsed = createMemo(() =>
2201+
collapseOutput(output(), TuiConfig.toolOutputLimit(ctx.tui.tool_output, props.tool, "generic"), ctx.width),
2202+
)
22002203
const limited = createMemo(() => {
22012204
if (expanded() || !collapsed().overflow) return output()
22022205
return collapsed().output
@@ -2223,9 +2226,11 @@ function GenericTool(props: ToolProps) {
22232226
hint={expandHint(collapsed().overflow, expanded())}
22242227
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
22252228
>
2226-
<box gap={1}>
2227-
<text fg={theme.text}>{limited()}</text>
2228-
</box>
2229+
<Show when={!collapsed().overflow || expanded() || limited()}>
2230+
<box gap={1}>
2231+
<text fg={theme.text}>{limited()}</text>
2232+
</box>
2233+
</Show>
22292234
</BlockTool>
22302235
</Show>
22312236
)
@@ -2269,9 +2274,7 @@ function InlineTool(props: {
22692274
)
22702275

22712276
const failed = createMemo(() => Boolean(error() && !denied()))
2272-
const timestamp = createMemo(() =>
2273-
formatToolTimestamp(props.part.state, toolFooter(ctx, props.part)),
2274-
)
2277+
const timestamp = createMemo(() => formatToolTimestamp(props.part.state, toolFooter(ctx, props.part)))
22752278
const clickable = createMemo(() => Boolean(props.onClick || failed()))
22762279
const fg = createMemo(() => {
22772280
if (props.color) return props.color
@@ -2499,9 +2502,9 @@ function Shell(props: ToolProps) {
24992502
const isRunning = createMemo(() => props.part.state.status === "running")
25002503
const output = createMemo(() => stripAnsi(stringValue(props.metadata.output)?.trim() ?? ""))
25012504
const [expanded, setExpanded] = createSignal(false)
2502-
const maxLines = 10
2503-
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
2504-
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
2505+
const collapsed = createMemo(() =>
2506+
collapseOutput(output(), TuiConfig.toolOutputLimit(ctx.tui.tool_output, props.tool, "bash"), ctx.width),
2507+
)
25052508
const limited = createMemo(() => {
25062509
if (expanded() || !collapsed().overflow) return output()
25072510
return collapsed().output
@@ -2534,7 +2537,7 @@ function Shell(props: ToolProps) {
25342537
<Show when={isRunning()} fallback={<text fg={theme.text}>$ {stringValue(props.input.command)}</text>}>
25352538
<Spinner color={theme.text}>{stringValue(props.input.command)}</Spinner>
25362539
</Show>
2537-
<Show when={output()}>
2540+
<Show when={limited()}>
25382541
<text fg={theme.text}>{limited()}</text>
25392542
</Show>
25402543
</box>
@@ -2555,20 +2558,32 @@ function Write(props: ToolProps) {
25552558
const code = createMemo(() => {
25562559
return stringValue(props.input.content) ?? ""
25572560
})
2561+
const collapse = useBlockCollapse(
2562+
() => props.tool,
2563+
"write",
2564+
() => code().length > 0,
2565+
)
25582566

25592567
return (
25602568
<Switch>
25612569
<Match when={props.metadata.diagnostics !== undefined}>
2562-
<BlockTool title={"# Wrote " + pathFormatter.format(stringValue(props.input.filePath))} part={props.part}>
2563-
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
2564-
<code
2565-
conceal={false}
2566-
fg={theme.text}
2567-
filetype={filetype(stringValue(props.input.filePath))}
2568-
syntaxStyle={syntax()}
2569-
content={code()}
2570-
/>
2571-
</line_number>
2570+
<BlockTool
2571+
title={"# Wrote " + pathFormatter.format(stringValue(props.input.filePath))}
2572+
part={props.part}
2573+
hint={collapse.hint()}
2574+
onClick={collapse.onClick()}
2575+
>
2576+
<ClippedRows limit={collapse.limit()} expanded={collapse.expanded()} onClip={collapse.setClipped}>
2577+
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
2578+
<code
2579+
conceal={false}
2580+
fg={theme.text}
2581+
filetype={filetype(stringValue(props.input.filePath))}
2582+
syntaxStyle={syntax()}
2583+
content={code()}
2584+
/>
2585+
</line_number>
2586+
</ClippedRows>
25722587
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.filePath) ?? ""} />
25732588
</BlockTool>
25742589
</Match>
@@ -2773,6 +2788,53 @@ function toolFooter(ctx: ReturnType<typeof use>, part: ToolPart) {
27732788
}
27742789
}
27752790

2791+
// A tool's text output cut to `limit` lines and that many lines' worth of characters; undefined never cuts.
2792+
export function collapseOutput(output: string, limit: number | undefined, width: number) {
2793+
if (limit === undefined) return { output, overflow: false }
2794+
return collapseToolOutput(output, limit, limit * Math.max(20, width - 6))
2795+
}
2796+
2797+
// Collapsing for a tool block drawn by a component (code, diff) rather than as text: shows the
2798+
// first `limit` rows of what it renders, and reports whether anything is hidden.
2799+
function useBlockCollapse(tool: () => string, group: TuiConfig.ToolOutputGroup, content: () => boolean) {
2800+
const ctx = use()
2801+
const limit = createMemo(() => TuiConfig.toolOutputLimit(ctx.tui.tool_output, tool(), group))
2802+
const [expanded, setExpanded] = createSignal(false)
2803+
const [clipped, setClipped] = createSignal(false)
2804+
const overflow = () => (limit() === 0 ? content() : clipped())
2805+
return {
2806+
limit,
2807+
expanded,
2808+
setClipped,
2809+
hint: () => expandHint(overflow(), expanded()),
2810+
onClick: () => (overflow() ? () => setExpanded((value) => !value) : undefined),
2811+
}
2812+
}
2813+
2814+
export function ClippedRows(props: {
2815+
limit: number | undefined
2816+
expanded: boolean
2817+
onClip: (clipped: boolean) => void
2818+
children: JSX.Element
2819+
}) {
2820+
return (
2821+
<Show when={props.limit !== undefined && !props.expanded} fallback={props.children}>
2822+
<Show when={props.limit! > 0}>
2823+
<box maxHeight={props.limit} overflow="hidden" flexShrink={0}>
2824+
<box
2825+
flexShrink={0}
2826+
onSizeChange={function () {
2827+
props.onClip(this.height > props.limit!)
2828+
}}
2829+
>
2830+
{props.children}
2831+
</box>
2832+
</box>
2833+
</Show>
2834+
</Show>
2835+
)
2836+
}
2837+
27762838
function expandHint(overflow: boolean, expanded: boolean) {
27772839
if (!overflow) return
27782840
return expanded ? "Click to collapse" : "Click to expand"
@@ -2908,32 +2970,44 @@ function Edit(props: ToolProps) {
29082970
const ft = createMemo(() => filetype(stringValue(props.input.filePath)))
29092971

29102972
const diffContent = createMemo(() => stringValue(props.metadata.diff) ?? "")
2973+
const collapse = useBlockCollapse(
2974+
() => props.tool,
2975+
"edit",
2976+
() => diffContent().length > 0,
2977+
)
29112978

29122979
return (
29132980
<Switch>
29142981
<Match when={stringValue(props.metadata.diff) !== undefined}>
2915-
<BlockTool title={"← Edit " + pathFormatter.format(stringValue(props.input.filePath))} part={props.part}>
2916-
<box paddingLeft={1}>
2917-
<diff
2918-
diff={diffContent()}
2919-
view={view()}
2920-
filetype={ft()}
2921-
syntaxStyle={syntax()}
2922-
showLineNumbers={true}
2923-
width="100%"
2924-
wrapMode={ctx.diffWrapMode()}
2925-
fg={theme.text}
2926-
addedBg={theme.diffAddedBg}
2927-
removedBg={theme.diffRemovedBg}
2928-
contextBg={theme.diffContextBg}
2929-
addedSignColor={theme.diffHighlightAdded}
2930-
removedSignColor={theme.diffHighlightRemoved}
2931-
lineNumberFg={theme.diffLineNumber}
2932-
lineNumberBg={theme.diffContextBg}
2933-
addedLineNumberBg={theme.diffAddedLineNumberBg}
2934-
removedLineNumberBg={theme.diffRemovedLineNumberBg}
2935-
/>
2936-
</box>
2982+
<BlockTool
2983+
title={"← Edit " + pathFormatter.format(stringValue(props.input.filePath))}
2984+
part={props.part}
2985+
hint={collapse.hint()}
2986+
onClick={collapse.onClick()}
2987+
>
2988+
<ClippedRows limit={collapse.limit()} expanded={collapse.expanded()} onClip={collapse.setClipped}>
2989+
<box paddingLeft={1}>
2990+
<diff
2991+
diff={diffContent()}
2992+
view={view()}
2993+
filetype={ft()}
2994+
syntaxStyle={syntax()}
2995+
showLineNumbers={true}
2996+
width="100%"
2997+
wrapMode={ctx.diffWrapMode()}
2998+
fg={theme.text}
2999+
addedBg={theme.diffAddedBg}
3000+
removedBg={theme.diffRemovedBg}
3001+
contextBg={theme.diffContextBg}
3002+
addedSignColor={theme.diffHighlightAdded}
3003+
removedSignColor={theme.diffHighlightRemoved}
3004+
lineNumberFg={theme.diffLineNumber}
3005+
lineNumberBg={theme.diffContextBg}
3006+
addedLineNumberBg={theme.diffAddedLineNumberBg}
3007+
removedLineNumberBg={theme.diffRemovedLineNumberBg}
3008+
/>
3009+
</box>
3010+
</ClippedRows>
29373011
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.filePath) ?? ""} />
29383012
</BlockTool>
29393013
</Match>
@@ -2996,21 +3070,31 @@ function ApplyPatch(props: ToolProps) {
29963070
<Switch>
29973071
<Match when={files().length > 0}>
29983072
<For each={files()}>
2999-
{(file) => (
3000-
<BlockTool title={title(file)} part={props.part}>
3001-
<Show
3002-
when={file.type !== "delete"}
3003-
fallback={
3004-
<text fg={theme.diffRemoved}>
3005-
-{file.deletions} line{file.deletions !== 1 ? "s" : ""}
3006-
</text>
3007-
}
3008-
>
3009-
<Diff diff={file.patch} filePath={file.filePath} />
3010-
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={file.movePath ?? file.filePath} />
3011-
</Show>
3012-
</BlockTool>
3013-
)}
3073+
{(file) => {
3074+
// A deleted file is one line already, so only the other files collapse.
3075+
const collapse = useBlockCollapse(
3076+
() => props.tool,
3077+
"apply_patch",
3078+
() => file.type !== "delete" && file.patch.length > 0,
3079+
)
3080+
return (
3081+
<BlockTool title={title(file)} part={props.part} hint={collapse.hint()} onClick={collapse.onClick()}>
3082+
<Show
3083+
when={file.type !== "delete"}
3084+
fallback={
3085+
<text fg={theme.diffRemoved}>
3086+
-{file.deletions} line{file.deletions !== 1 ? "s" : ""}
3087+
</text>
3088+
}
3089+
>
3090+
<ClippedRows limit={collapse.limit()} expanded={collapse.expanded()} onClip={collapse.setClipped}>
3091+
<Diff diff={file.patch} filePath={file.filePath} />
3092+
</ClippedRows>
3093+
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={file.movePath ?? file.filePath} />
3094+
</Show>
3095+
</BlockTool>
3096+
)
3097+
}}
30143098
</For>
30153099
</Match>
30163100
<Match when={true}>

‎packages/tui/src/util/collapse-tool-output.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export function collapseToolOutput(output: string, maxLines: number, maxChars: number) {
2+
if (maxLines === 0) return { output: "", overflow: output.length > 0 }
23
const lines = output.split("\n")
34
if (lines.length <= maxLines && Array.from(output).length <= maxChars) {
45
return { output, overflow: false }

0 commit comments

Comments
 (0)