Skip to content

feat(tui): collapse long bash commands and optionally tail their output - #55421

Closed
Nowaker wants to merge 10 commits into
anomalyco:v1from
Nowaker:tui-command-collapse
Closed

Nowaker wants to merge 10 commits into
anomalyco:v1from
Nowaker:tui-command-collapse

Conversation

@Nowaker

@Nowaker Nowaker commented Oct 14, 2026 •

Copy link
Copy Markdown

Note: I'm aware v1 is now the maintenance branch and new features go to v2 only. This PR is here for others to use, and I'll submit the v2 version once I've migrated my whole plugin system to it.

Issue for this PR

Closes #55420

Builds on #54039 (tool_output.collapse), which builds on #53877; their commits come first. Review only the last commit.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

A bash call's output collapses, but its command never does, so a long command or one with heredocs takes all its rows.

  • tool_input.collapse in tui.json takes the same values as tool_output.collapse: per group (bash) or tool name, a number of rendered rows, 0, or "never" (default). The command is cut with the ClippedRows that write and edit already use, so it wraps exactly as before and only its height is capped. 0 hides it until clicked.
  • The command and the output keep separate limits; tool_output.collapse.bash still decides the output's lines. One click expands both, with the same hint and footer.
  • tool_output.tail (per group bash/generic or tool name, default off) shows the last lines of collapsed output under (N earlier lines) instead of the first. It uses collapseTail as dev has it in util/collapse-tool-output.ts.
  • tool_output.head (per group or tool name, default 0) keeps that many first lines above a (N more lines) marker while tail is on, and the last lines fill the rest of the collapse limit: collapse 5 with head 2 shows 2 first lines, the marker and 2 last lines. head is capped at the limit minus 2 so a last line always shows. A collapse of 0 still hides the output until clicked, whatever tail and head say; the docs spell that out.
  • tool_output.collapse, tool_input.collapse, tool_output.tail and tool_output.head name their groups as schema properties beside the per-tool record, so the JSON schema documents them.

With nothing set the transcript is unchanged.

How did you verify your code works?

  • bun typecheck in packages/tui. bun test in packages/tui: 243 pass; 7 diff viewer tests failed under a load average of 55 and pass when their files run alone (29 pass). Config decoding and precedence tests, collapseTail/collapseHeadTail/collapseOutput tests, and a frame test that a command clipped to 2 rows shows exactly the first 2 rows it wraps to at 40, 80 and 160 columns.
  • A real TUI (isolated config and database, throwaway tmux socket) at 80 and 160 columns: with "tool_input": {"collapse": {"bash": 2}} long commands and heredoc commands show 2 rows and Click to expand, 2-row commands show no hint; a click shows the whole command and every heredoc line, a second click folds it back. With "tool_output": {"tail": {"bash": true}} a 40-line output shows (31 earlier lines) and lines 32-40. With "collapse": {"bash": 5}, "tail": {"bash": true}, "head": {"bash": 2} at 80 and 160 columns, the 40-line output shows lines 1-2, (36 more lines) and lines 39-40; a click shows all 40 lines and a second click folds it back. With collapse 0 and the same tail and head the output stays hidden. With {} commands show in full as before.

Screenshots / recordings

"tool_input": {"collapse": {"bash": 1}}:

$ mkdir -p templates/linux && cd templates/linux && cat > second-brain-

Click to expand

"tool_output": {"tail": {"bash": true}}:

$ seq 1 40 | sed 's/^/output line /'

(31 earlier lines)
output line 32
…
output line 40

Click to expand

"tool_output": {"collapse": {"bash": 5}, "tail": {"bash": true}, "head": {"bash": 2}}:

$ seq 1 40 | sed 's/^/output line /'

output line 1
output line 2
(36 more lines)
output line 39
output line 40

Click to expand

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

This PR is AI generated, but under direct supervision and on request of @Nowaker.

The assistant footer only appears on the final message of a prompt and
shows one number: the time since that prompt. Intermediate tool-call
turns show no footer, so there is no way to see when a turn finished or
which step of a long run took the time.

Add a `turn_timing` option to tui.json with two independent booleans:

- `time`: when each assistant turn finished, as the time of day if
  today and the date followed by the time otherwise
  (Locale.todayTimeOrDateFirst, e.g. `10/4/2026 11:26 PM`).
  Locale.todayTimeOrDateTime keeps its time-then-date order for its
  existing callers.
- `duration`: how long each turn took (created -> completed). The
  final turn keeps the existing total since the prompt and labels it
  `total`, so both numbers are visible.

Either option gives every completed assistant turn a footer. Both
default to false, so the footer is unchanged unless enabled.

/turn-times and /turn-durations (also in the command palette and
bindable via session_toggle_turn_time / session_toggle_turn_duration)
toggle them at runtime. Like /timestamps, a toggled value persists in
kv.json and takes precedence over tui.json, which supplies the
default.
Only prompts (/timestamps) and assistant turn footers (/turn-times,
turn_timing.time) can show when something happened. Tool calls,
thinking blocks, text blocks, errors and compactions never do, so a
long run gives no way to see when a step finished or how long a tool
call ran.

Add a `timestamps` option to tui.json naming the entry types that show
a timestamp: "all", "none", a comma-separated list such as
"user,tool", or an array. The types are user, assistant, text,
reasoning, tool, error and compaction. Tool calls show when they
finished and how long they ran; thinking blocks put the time before
their existing duration; text blocks and errors get a muted time line;
the compaction divider gets the time in its title. All use
Locale.todayTimeOrDateFirst, like the turn footer.

Unset, nothing changes: /timestamps, /turn-times and turn_timing.time
work and persist exactly as before. Set, the list decides every type
on each start and overrides turn_timing.time; /timestamps and
/turn-times still flip prompt and turn times, until the TUI exits. A
kv-wins rule would make the option dead for most existing installs:
kv.json usually already holds "timestamps", because the kv store
persists its in-memory defaults alongside any other write.

The JSON schema description documents every value, since opencode
ships no commented tui.jsonc template.
Message footers name the model by its display name only, so the same
model served through two providers (openai and a gateway clone, a
direct API key and a proxy) reads identically, and nothing on screen
says which provider answered a turn.

model_label in tui.json picks the label: 'name' (default) keeps the
display name; 'id' shows providerID/modelID, e.g.
anthropic/claude-sonnet-4-5, in every assistant message footer and in
the prompt's model indicator, where it replaces the model and provider
names. Variant and duration stay as they are.
Assistant message footers name the model but not the variant it ran
with, so a turn on high reasoning effort reads the same as one on the
default. The variant is stored on every message and the prompt shows
the current one, but nothing shows which one a past turn used.

footer_variant in tui.json (default false) adds the message's variant,
e.g. high or max, after the model in assistant message footers. Turns
without a variant are unchanged. Documented in tui.mdx.
A long session can only be moved through by line, page or the whole
transcript. messages_next/messages_previous exist but are unbound,
and they skip any prompt within 10 rows of the viewport top, because
they compare against a fixed threshold rather than the row they land
on. Assistant parts render without an id the scroll position could be
matched against.

Bind three levels of navigation by default, all overridable in
tui.json:

- messages_previous / messages_next (ctrl+up / ctrl+down) stop on
  every user prompt, now through the same picker as the two below.
- messages_landmark_previous / messages_landmark_next
  (ctrl+shift+up / ctrl+shift+down) stop on prompts, the last text
  part of each turn (its final response), and question, todowrite and
  task calls. Edits, reads and shell calls are left out because they
  are as dense as the blocks themselves.
- messages_block_previous / messages_block_next
  (ctrl+alt+shift+up / ctrl+alt+shift+down) stop on every prompt,
  text part, reasoning part with content, and tool call. Reasoning
  without content - an encrypted block or an empty "Thinking"
  placeholder, which GPT models emit on every step - renders as a
  bare header and is skipped.

Text, reasoning and tool boxes now carry their part id so the session
route can find them among the scrollbox children, as it already does
for user messages by message id. "Previous" and "next" are relative to
the row a navigated block lands on, one below the viewport top, so
repeated presses walk block by block; past either end the view goes
to the top or back to the bottom.

messages_first and messages_last also bind ctrl+home and ctrl+end.
Message footers grew one setting per detail, each with its own value
shape: turn_timing.time/duration are booleans, timestamps is a type
list, footer_variant is a boolean, and the agent, model and total have
no setting at all. There was also no way to see a message's ID, which
is what logs, the database and the API key everything by.

footer in tui.json has one key per detail - agent, model, variant,
time, duration, total and the new message_id - and every key takes the
same value: true/"all", false/"none", "important", or a list of entry
types from the timestamps vocabulary (user, assistant, text, reasoning,
tool, error, compaction), which may include "important". "important"
selects the landmarks ctrl+shift+up/down stops on, reusing
navigationTargets(..., "landmark"); a message-level entry counts when
the message or one of its parts is one (isLandmark in navigation.ts).
A type a key does not apply to has no effect, which each key's schema
description lists.

message_id shows the msg_ ID at the end of user and assistant footers
as plain text, so it can be selected with the mouse to copy. Time,
duration and message_id also give a turn's earlier steps a footer once
they finish, as turn_timing did.

Every tool call takes time, duration and message_id the same way,
whatever renders it - built-in, generic, plugin or MCP tools. A block
tool puts them on its own last line and an inline row after its text,
or on its own line when the arguments span lines, so a long prompt or
command cannot push them out of sight; a block that ends in a "Click
to expand" hint carries them on the hint line. A tool call shows its
message's ID only when that message's own footer does not, so each ID
appears once. While a tool call runs, its footer shows when it started
and a stopwatch in whole seconds driven by one shared 1s clock that
stops when nothing runs; once finished it shows the end time and the
duration with tenths of a second up to 11 minutes. A prompt's footer
sits one blank line below its text. The footer details are all muted
except the agent name, the variant included.

datetime_format takes a date and a time pattern in Day.js tokens
(YYYY-MM-DD, HH:mm:ss, h:mm A ...) for every time the TUI writes:
footers, entry timestamps, the compaction divider and the timeline and
stash dialogs. Entries from today still show only the time, and an
unset part keeps the locale output. Each part accepts only its own
tokens, so "HH:MM:SS" is rejected rather than printed with the month
in place of the minute.
Times already on screen switch to the date and time at local midnight
by themselves: the today check reads one shared day signal, driven by a
single timeout that fires after midnight or within the hour.

Unset keys keep today's behavior: turn_timing, footer_variant and
timestamps map onto footer.time, footer.duration and footer.variant
(footerSelections in config/index.tsx), and /timestamps, /turn-times
and /turn-durations persist as before. A key set in footer decides on
every start and its toggle lasts until exit, as timestamps already did.
Defaults render the upstream footer unchanged.
…rn toggles

`turn_timing.time` and `turn_timing.duration` were read after the
kv.json values /turn-times and /turn-durations save, so after one use
of either toggle a later `turn_timing` in tui.json was silently
ignored, while the toggle kept persisting as if nothing decided it.

`turn_timing` set in tui.json now decides turns on every start, the
same way `footer` and the legacy `timestamps` list already do:
/turn-times and /turn-durations then last until opencode exits and say
so in a toast. `footerConfigured` answers per element and entry type,
so a set `turn_timing.time` claims turn times without making
/timestamps (prompt times) session-only. Session-only footer toggles
also survive leaving and reopening a session now.
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.
…remembered hide

"Show/Hide generic tool output" saves its choice to kv.json and is off
by default, and while it is off plugin and MCP output is not drawn at
all. A `tool_output.collapse` limit for `generic` or for a tool's name
therefore did nothing on a machine where that toggle was ever off: the
limit had no output to collapse, and nothing said why.

Naming `generic` or a tool in `tool_output.collapse` now shows that
output on every start, collapsed to its limit, so 0 leaves the
response one "Click to expand" away (packages/tui/src/config/index.tsx
`genericToolOutputShown`). With `generic` set, the command then lasts
until opencode exits and says so in a toast. With neither set the
remembered toggle decides, as upstream draws it.
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

A long bash command, heredocs included, fills the transcript: only its
output collapses. `tool_input.collapse` takes the same values as
`tool_output.collapse` (rows, 0 or "never", per group or tool name) and
cuts the command after that many rendered rows with the `ClippedRows`
used by write and edit, so wrapping stays as it is. The default is
"never", so the transcript is unchanged unless it is set.

The command and the output keep separate limits; one click expands
both, with the same hint and footer as output.

`tool_output.tail` (per group `bash`/`generic` or tool name, default
off) shows the last lines of collapsed output under "(N earlier lines)",
using the `collapseTail` preview from `dev`'s
`util/collapse-tool-output.ts`. `tool_output.head` (default 0) keeps
that many first lines above a "(N more lines)" marker, and the last
lines fill the rest of the collapse limit: collapse 5 with head 2 shows
2 first lines, the marker and 2 last lines. Collapse 0 still hides the
output until clicked.

`tool_output.collapse`, `tool_input.collapse`, `tool_output.tail` and
`tool_output.head` name their groups as schema properties beside the
per-tool record, so the JSON schema documents them.
@Nowaker

Nowaker commented Nov 2, 2026

Copy link
Copy Markdown
Author

Closing: this targets the v1 TUI, which upstream now maintains for critical fixes only, so it is not going to be merged. The branch stays on Nowaker/opencode if anyone wants to pick it up.

@Nowaker Nowaker closed this Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant