detached a622146

Merge pull request #44 from bnema/feat/subagent-input-indicators

feat(tui): show live subagent activity
commit
a622146b54c27017c8e25ea2024fa2e068ac4a74
author
bnema · · 2026-08-06 12:43 UTC
committer
GitHub · · 2026-08-06 12:43 UTC
merge of
5c1a9ec + c636810 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.4.0
14 files · +967 -49

this diff holds invisible characters (direction controls, zero-width characters). They are shown as <U+XXXX> markers, never applied.

M README.md +1 -1
@@ -407,7 +407,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
407407 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
408408 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
409409 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
410-- **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).
410+- **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then running subagents. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. **Running subagent position** places live delegated-turn rows above or below the input; it defaults below the input and shows the profile or agent ID, current activity, and elapsed time since the last activity/heartbeat. On a short terminal, omitted rows collapse into one count summary so the editor remains visible. Idle, detached, and terminal turns disappear from this area immediately. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, `tui_working_position`, and `tui_subagent_position` (`above_input` or `below_input` for the position fields).
411411 - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.
412412 - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZUT_HOME/themes` or a loaded extension. Built-in and JSON themes follow terminal color capability: truecolor terminals receive RGB output, while RGB values fall back to xterm-256 elsewhere. Theme files can use xterm-256, ANSI, or RGB values for semantic UI colors and can also override syntax colors and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zut resets to auto. See [docs/themes.md](docs/themes.md).
413413 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
@@ -407,7 +407,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
407 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.407 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
408 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.408 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
409 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.409 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
410-- **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).410+- **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then running subagents. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. **Running subagent position** places live delegated-turn rows above or below the input; it defaults below the input and shows the profile or agent ID, current activity, and elapsed time since the last activity/heartbeat. On a short terminal, omitted rows collapse into one count summary so the editor remains visible. Idle, detached, and terminal turns disappear from this area immediately. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, `tui_working_position`, and `tui_subagent_position` (`above_input` or `below_input` for the position fields).
411 - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.411 - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.
412 - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZUT_HOME/themes` or a loaded extension. Built-in and JSON themes follow terminal color capability: truecolor terminals receive RGB output, while RGB values fall back to xterm-256 elsewhere. Theme files can use xterm-256, ANSI, or RGB values for semantic UI colors and can also override syntax colors and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zut resets to auto. See [docs/themes.md](docs/themes.md).412 - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZUT_HOME/themes` or a loaded extension. Built-in and JSON themes follow terminal color capability: truecolor terminals receive RGB output, while RGB values fall back to xterm-256 elsewhere. Theme files can use xterm-256, ANSI, or RGB values for semantic UI colors and can also override syntax colors and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zut resets to auto. See [docs/themes.md](docs/themes.md).
413 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.413 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
M docs/subagents.md +2 -0
@@ -90,6 +90,8 @@ Shared mode preserves the historical host working directory. For parallel coding
9090
9191 Every child has independent process and turn state. A process may be `alive` while its turn is `idle`; a supervisor restart marks the process `detached` without claiming that its last turn failed. Durable manifests include the task, parent/root session identity, workspace mode, attempt, process/turn state, heartbeat timestamps, and logical result references.
9292
93+During an active delegated turn, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), current activity, and elapsed time since the latest activity or heartbeat. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted workers collapse into one count summary so the editor remains visible. Rows disappear when the turn becomes idle, the process detaches, or the turn reaches a terminal state; use `/subagents` for durable history and results.
94+
9395 The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol.
9496
9597 A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references:
@@ -90,6 +90,8 @@ Shared mode preserves the historical host working directory. For parallel coding
90 90
91 Every child has independent process and turn state. A process may be `alive` while its turn is `idle`; a supervisor restart marks the process `detached` without claiming that its last turn failed. Durable manifests include the task, parent/root session identity, workspace mode, attempt, process/turn state, heartbeat timestamps, and logical result references.91 Every child has independent process and turn state. A process may be `alive` while its turn is `idle`; a supervisor restart marks the process `detached` without claiming that its last turn failed. Durable manifests include the task, parent/root session identity, workspace mode, attempt, process/turn state, heartbeat timestamps, and logical result references.
92 92
93+During an active delegated turn, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), current activity, and elapsed time since the latest activity or heartbeat. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted workers collapse into one count summary so the editor remains visible. Rows disappear when the turn becomes idle, the process detaches, or the turn reaches a terminal state; use `/subagents` for durable history and results.
94+
93 The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol.95 The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol.
94 96
95 A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references:97 A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references:
M packages/agent/cli.go +11 -2
@@ -1828,7 +1828,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
18281828 if subagentsMgr != nil && candidate.session != nil {
18291829 // Keep the dashboard scope in the same commit as the session,
18301830 // agent, usage, and persistence baseline.
1831- subagentsMgr.SetActiveSession(candidate.session.ID)
1831+ if iv != nil {
1832+ iv.SetSubagentSessionScope(candidate.session.ID)
1833+ } else {
1834+ subagentsMgr.SetActiveSession(candidate.session.ID)
1835+ }
18321836 }
18331837 committed = true
18341838 persistMu.Unlock()
@@ -2011,7 +2015,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
20112015
20122016 // Re-scope the subagent dashboard to the new session.
20132017 if subagentsMgr != nil && newSess != nil {
2014- subagentsMgr.SetActiveSession(newSess.ID)
2018+ if iv != nil {
2019+ iv.SetSubagentSessionScope(newSess.ID)
2020+ } else {
2021+ subagentsMgr.SetActiveSession(newSess.ID)
2022+ }
20152023 }
20162024 if newSess != nil {
20172025 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
@@ -2133,6 +2141,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
21332141 TUIInputStyle: initialCfg.TUIInputStyle,
21342142 TUIStatusPosition: initialCfg.TUIStatusPosition,
21352143 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2144+ TUISubagentPosition: initialCfg.TUISubagentPosition,
21362145 ThemeName: initialCfg.Theme,
21372146 FlatTools: initialCfg.FlatToolRender(),
21382147 CompactUser: initialCfg.CompactUserInput(),
@@ -1828,7 +1828,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1828 if subagentsMgr != nil && candidate.session != nil {1828 if subagentsMgr != nil && candidate.session != nil {
1829 // Keep the dashboard scope in the same commit as the session,1829 // Keep the dashboard scope in the same commit as the session,
1830 // agent, usage, and persistence baseline.1830 // agent, usage, and persistence baseline.
1831- subagentsMgr.SetActiveSession(candidate.session.ID)1831+ if iv != nil {
1832+ iv.SetSubagentSessionScope(candidate.session.ID)
1833+ } else {
1834+ subagentsMgr.SetActiveSession(candidate.session.ID)
1835+ }
1832 }1836 }
1833 committed = true1837 committed = true
1834 persistMu.Unlock()1838 persistMu.Unlock()
@@ -2011,7 +2015,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2011 2015
2012 // Re-scope the subagent dashboard to the new session.2016 // Re-scope the subagent dashboard to the new session.
2013 if subagentsMgr != nil && newSess != nil {2017 if subagentsMgr != nil && newSess != nil {
2014- subagentsMgr.SetActiveSession(newSess.ID)2018+ if iv != nil {
2019+ iv.SetSubagentSessionScope(newSess.ID)
2020+ } else {
2021+ subagentsMgr.SetActiveSession(newSess.ID)
2022+ }
2015 }2023 }
2016 if newSess != nil {2024 if newSess != nil {
2017 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)2025 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
@@ -2133,6 +2141,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2133 TUIInputStyle: initialCfg.TUIInputStyle,2141 TUIInputStyle: initialCfg.TUIInputStyle,
2134 TUIStatusPosition: initialCfg.TUIStatusPosition,2142 TUIStatusPosition: initialCfg.TUIStatusPosition,
2135 TUIWorkingPosition: initialCfg.TUIWorkingPosition,2143 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2144+ TUISubagentPosition: initialCfg.TUISubagentPosition,
2136 ThemeName: initialCfg.Theme,2145 ThemeName: initialCfg.Theme,
2137 FlatTools: initialCfg.FlatToolRender(),2146 FlatTools: initialCfg.FlatToolRender(),
2138 CompactUser: initialCfg.CompactUserInput(),2147 CompactUser: initialCfg.CompactUserInput(),
M packages/agent/config.go +5 -0
@@ -163,6 +163,11 @@ type Config struct {
163163 // (default) and "below_input".
164164 TUIWorkingPosition string `json:"tui_working_position,omitempty"`
165165
166+ // TUISubagentPosition controls whether live subagent activity renders
167+ // above or below the main input. Supported values: "above_input" and
168+ // "below_input" (default).
169+ TUISubagentPosition string `json:"tui_subagent_position,omitempty"`
170+
166171 // Insecure skips TLS verification for custom inference endpoints.
167172 Insecure bool `json:"insecure,omitempty"`
168173
@@ -163,6 +163,11 @@ type Config struct {
163 // (default) and "below_input".163 // (default) and "below_input".
164 TUIWorkingPosition string `json:"tui_working_position,omitempty"`164 TUIWorkingPosition string `json:"tui_working_position,omitempty"`
165 165
166+ // TUISubagentPosition controls whether live subagent activity renders
167+ // above or below the main input. Supported values: "above_input" and
168+ // "below_input" (default).
169+ TUISubagentPosition string `json:"tui_subagent_position,omitempty"`
170+
166 // Insecure skips TLS verification for custom inference endpoints.171 // Insecure skips TLS verification for custom inference endpoints.
167 Insecure bool `json:"insecure,omitempty"`172 Insecure bool `json:"insecure,omitempty"`
168 173
M packages/agent/modes/interactive.go +162 -46
@@ -144,6 +144,10 @@ type InteractiveConfig struct {
144144 // above or below the main input.
145145 TUIWorkingPosition string
146146
147+ // TUISubagentPosition controls whether live subagent activity renders
148+ // above or below the main input. Empty defaults below the input.
149+ TUISubagentPosition string
150+
147151 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
148152 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
149153 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
@@ -433,6 +437,7 @@ type SettingsStore interface {
433437 SetTUIInputStyle(style string) error
434438 SetTUIStatusPosition(position string) error
435439 SetTUIWorkingPosition(position string) error
440+ SetTUISubagentPosition(position string) error
436441 SetReasoning(level string) error
437442 SetTheme(name string) error
438443 }
@@ -512,27 +517,28 @@ type Interactive struct {
512517 // it. We snapshot the total expected stream length (already
513518 // streamed + still pending) at the moment the tool starts, and
514519 // hold the block back until the pacer reaches it.
515- toolGate map[string]int
516- statusErr string
517- statusOK string
518- reloadStatusSeq uint64
519- extStatuses map[string]map[string]extensionStatus
520- extWidgets map[string]map[string]extensionWidget
521- rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
522- liveBlock []string // live streaming/tool progress rendered outside scrollback
523- helpBlock []string // rendered above the chat when /help was typed
524- cumUsage provider.Usage
525- lastCtxInput int // input_tokens of the most recent turn — approximates current context size
526- busy bool
527- activity agentActivity
528- pendingIdleWork []func()
529- dirty chan struct{}
530- modelRefresh chan modelRefreshResult
531- modelRefreshing bool
532- startupPreDone chan startupPreResult
533- cancelTurn context.CancelFunc
534- scrollOffset int // rows from the bottom; 0 = pinned to latest
535- prevScrollOffset int // last value redraw snapped against; tracks intent
520+ toolGate map[string]int
521+ statusErr string
522+ statusOK string
523+ reloadStatusSeq uint64
524+ extStatuses map[string]map[string]extensionStatus
525+ extWidgets map[string]map[string]extensionWidget
526+ rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
527+ liveBlock []string // live streaming/tool progress rendered outside scrollback
528+ helpBlock []string // rendered above the chat when /help was typed
529+ cumUsage provider.Usage
530+ lastCtxInput int // input_tokens of the most recent turn — approximates current context size
531+ busy bool
532+ activity agentActivity
533+ subagentActivityActive bool
534+ pendingIdleWork []func()
535+ dirty chan struct{}
536+ modelRefresh chan modelRefreshResult
537+ modelRefreshing bool
538+ startupPreDone chan startupPreResult
539+ cancelTurn context.CancelFunc
540+ scrollOffset int // rows from the bottom; 0 = pinned to latest
541+ prevScrollOffset int // last value redraw snapped against; tracks intent
536542
537543 // prevChatLen and prevChatCols track the chat buffer's size at the
538544 // last redraw so that when content grows below the user's viewport
@@ -1129,9 +1135,10 @@ func (i *Interactive) Run(ctx context.Context) error {
11291135 // the same way it does inside btw.
11301136 i.mu.Lock()
11311137 busy := i.busy
1138+ subagentActivityActive := i.subagentActivityActive
11321139 sessionLoading := i.sessionDialog.Loading()
11331140 i.mu.Unlock()
1134- if busy || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
1141+ if busy || subagentActivityActive || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
11351142 requestRedraw()
11361143 }
11371144 }
@@ -1823,6 +1830,18 @@ func (i *Interactive) redraw() {
18231830 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
18241831 }
18251832 edLines, curR, curC := i.ed.Render(mainCols)
1833+ dashboardActive := i.subagentsDialog.Active()
1834+ var allSubagentLines []string
1835+ if !dashboardActive {
1836+ now := i.clock()
1837+ allSubagentLines = renderSubagentActivityLines(
1838+ i.cfg.Theme,
1839+ i.spin.FrameAt(now),
1840+ i.activeSubagentActivitySnapshots(),
1841+ mainCols,
1842+ now,
1843+ )
1844+ }
18261845 var workingLines []string
18271846 if busyPrefix != "" && !workingWithStatus {
18281847 workingLines = []string{" " + busyPrefix}
@@ -1870,26 +1889,31 @@ func (i *Interactive) redraw() {
18701889 // content. The status block and editor get their own dedicated
18711890 // blanks so spacing stays consistent whether or not a dialog or
18721891 // popup is showing.
1873- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(edLines)+9)
1874- inputStartRow := -1
1875- if len(dialog) > 0 {
1876- bottom = append(bottom, "")
1877- }
1878- bottom = append(bottom, dialog...)
1879- // The subagent dashboard owns the bottom of the screen while it's
1880- // active: it has its own inline editors for spawn (`n`) and
1881- // prompt (`p`), so the main input would be a confusing second
1882- // caret. The suggest popup, sliding-in queue, status block, and
1883- // main editor are all hidden underneath it. Keystrokes still
1884- // reach handleKey — it routes them to subagentsDialog.HandleKey
1885- // before the editor ever sees them — so the only effect of this
1886- // branch is visual.
1887- if !i.subagentsDialog.Active() {
1892+ composeBottom := func(subagentLines []string) (bottom []string, inputStartRow int) {
1893+ bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(subagentLines)+len(edLines)+9)
1894+ inputStartRow = -1
1895+ if len(dialog) > 0 {
1896+ bottom = append(bottom, "")
1897+ }
1898+ bottom = append(bottom, dialog...)
1899+ // The subagent dashboard owns the bottom of the screen while it's
1900+ // active: it has its own inline editors for spawn (`n`) and
1901+ // prompt (`p`), so the main input would be a confusing second
1902+ // caret. The suggest popup, sliding-in queue, status block, and
1903+ // main editor are all hidden underneath it. Keystrokes still
1904+ // reach handleKey — it routes them to subagentsDialog.HandleKey
1905+ // before the editor ever sees them — so the only effect of this
1906+ // branch is visual.
1907+ if dashboardActive {
1908+ return bottom, inputStartRow
1909+ }
1910+
18881911 bottom = append(bottom, suggest...)
18891912 bottom = append(bottom, queue...)
18901913 lineInput := inputStyle == tui.InputStyleLines
18911914 statusBelow := statusPosition == tui.StatusPositionBelowInput
18921915 workingBelow := workingPosition == tui.WorkingPositionBelowInput
1916+ subagentBelow := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition) == tui.SubagentPositionBelowInput
18931917
18941918 var aboveInput []string
18951919 aboveInput = append(aboveInput, extensionLines...)
@@ -1899,7 +1923,13 @@ func (i *Interactive) redraw() {
18991923 if !workingBelow {
19001924 aboveInput = append(aboveInput, workingLines...)
19011925 }
1926+ if !subagentBelow {
1927+ aboveInput = append(aboveInput, subagentLines...)
1928+ }
19021929 var belowInput []string
1930+ if subagentBelow {
1931+ belowInput = append(belowInput, subagentLines...)
1932+ }
19031933 if workingBelow {
19041934 belowInput = append(belowInput, workingLines...)
19051935 }
@@ -1919,12 +1949,36 @@ func (i *Interactive) redraw() {
19191949 inputStartRow = len(bottom)
19201950 bottom = append(bottom, edLines...)
19211951 if len(belowInput) > 0 {
1922- if !lineInput {
1952+ // Running subagents sit immediately under the prompt by default.
1953+ // Other below-input chrome keeps the existing breathing room.
1954+ if !lineInput && (!subagentBelow || len(subagentLines) == 0) {
19231955 bottom = append(bottom, "")
19241956 }
19251957 bottom = append(bottom, belowInput...)
19261958 }
1959+ return bottom, inputStartRow
1960+ }
1961+
1962+ // Preserve one chat row and the renderer-owned bottom margin. This keeps
1963+ // a bounded set of live indicators from pushing the editor or its cursor
1964+ // out of a fixed right-bar frame on short terminals.
1965+ const rendererBottomMarginRows = 1
1966+ maxBottomRows := rows - rendererBottomMarginRows - 1
1967+ subagentLines := allSubagentLines
1968+ bottom, inputStartRow := composeBottom(subagentLines)
1969+ if !dashboardActive && len(allSubagentLines) > 0 {
1970+ for maxRows := len(allSubagentLines); maxRows >= 0; maxRows-- {
1971+ candidate := limitSubagentActivityLines(i.cfg.Theme, allSubagentLines, maxRows, mainCols)
1972+ candidateBottom, candidateInputStartRow := composeBottom(candidate)
1973+ if len(candidateBottom) <= maxBottomRows || maxRows == 0 {
1974+ subagentLines = candidate
1975+ bottom = candidateBottom
1976+ inputStartRow = candidateInputStartRow
1977+ break
1978+ }
1979+ }
19271980 }
1981+ i.subagentActivityActive = !dashboardActive && len(subagentLines) > 0
19281982
19291983 chatRows := rows - len(bottom)
19301984 if rightBarActive {
@@ -3649,6 +3703,18 @@ func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model stri
36493703 i.invalidate()
36503704 }
36513705
3706+// SetSubagentSessionScope changes the live-worker filter and then requests a
3707+// redraw. Session changes must use this boundary after their host/session
3708+// state commits so input-adjacent activity cannot remain scoped to the prior
3709+// session until another UI event arrives.
3710+func (i *Interactive) SetSubagentSessionScope(sessionID string) {
3711+ if i.cfg.Supervisor == nil {
3712+ return
3713+ }
3714+ i.cfg.Supervisor.SetActiveSession(sessionID)
3715+ i.invalidate()
3716+}
3717+
36523718 // ApplyChangedCWD is called by hosts after a successful /cd hook that do
36533719 // not provide startup context metadata.
36543720 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
@@ -4117,6 +4183,7 @@ func (i *Interactive) openSettingsDialog() {
41174183 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
41184184 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
41194185 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
4186+ subagentPosition := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition)
41204187 quickItems := i.quickModelSettingItems()
41214188
41224189 autoCompactOptions := []settingsOption{
@@ -4198,6 +4265,18 @@ func (i *Interactive) openSettingsDialog() {
41984265 }
41994266 }
42004267
4268+ subagentPositionOptions := []settingsOption{
4269+ {value: tui.SubagentPositionAboveInput, label: "above input", desc: "show running subagents above the input"},
4270+ {value: tui.SubagentPositionBelowInput, label: "below input", desc: "show running subagents immediately below the input"},
4271+ }
4272+ subagentPositionChoice := 0
4273+ for idx, opt := range subagentPositionOptions {
4274+ if opt.value == subagentPosition {
4275+ subagentPositionChoice = idx
4276+ break
4277+ }
4278+ }
4279+
42014280 items := []settingsItem{
42024281 {
42034282 key: "inline_images_enabled",
@@ -4294,15 +4373,8 @@ func (i *Interactive) openSettingsDialog() {
42944373 {
42954374 key: "tui_settings",
42964375 label: "tui settings",
4297- desc: "choose input chrome and where status information appears",
4376+ desc: "choose input chrome in its order around the prompt",
42984377 children: []settingsItem{
4299- {
4300- key: "tui_input_style",
4301- label: "input style",
4302- desc: "choose between the plain prompt, lines, and a block input area",
4303- options: inputStyleOptions,
4304- choice: inputStyleChoice,
4305- },
43064378 {
43074379 key: "tui_status_position",
43084380 label: "status position",
@@ -4317,6 +4389,20 @@ func (i *Interactive) openSettingsDialog() {
43174389 options: workingPositionOptions,
43184390 choice: workingPositionChoice,
43194391 },
4392+ {
4393+ key: "tui_input_style",
4394+ label: "input style",
4395+ desc: "choose between the plain prompt, lines, and a block input area",
4396+ options: inputStyleOptions,
4397+ choice: inputStyleChoice,
4398+ },
4399+ {
4400+ key: "tui_subagent_position",
4401+ label: "running subagent position",
4402+ desc: "place live subagent activity above or below the input",
4403+ options: subagentPositionOptions,
4404+ choice: subagentPositionChoice,
4405+ },
43204406 },
43214407 },
43224408 reasoningItem,
@@ -4355,6 +4441,8 @@ func (i *Interactive) applySettingChange(act settingsAction) {
43554441 i.applyTUIStatusPositionSetting(act.StringValue)
43564442 case act.Key == "tui_working_position":
43574443 i.applyTUIWorkingPositionSetting(act.StringValue)
4444+ case act.Key == "tui_subagent_position":
4445+ i.applyTUISubagentPositionSetting(act.StringValue)
43584446 default:
43594447 i.applySettingToggle(act.Key, act.Value)
43604448 }
@@ -5001,6 +5089,33 @@ func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
50015089 i.mu.Unlock()
50025090 }
50035091
5092+func (i *Interactive) applyTUISubagentPositionSetting(position string) {
5093+ defer func() {
5094+ if i.rend != nil {
5095+ i.rend.Clear()
5096+ }
5097+ i.invalidate()
5098+ }()
5099+ position = tui.NormalizeSubagentPosition(position)
5100+ i.cfg.TUISubagentPosition = position
5101+ if i.cfg.SettingsStore != nil {
5102+ if err := i.cfg.SettingsStore.SetTUISubagentPosition(position); err != nil {
5103+ i.mu.Lock()
5104+ i.statusErr = "settings: " + err.Error()
5105+ i.mu.Unlock()
5106+ return
5107+ }
5108+ }
5109+ i.mu.Lock()
5110+ label := "below input"
5111+ if position == tui.SubagentPositionAboveInput {
5112+ label = "above input"
5113+ }
5114+ i.statusOK = "running subagent position " + label
5115+ i.statusErr = ""
5116+ i.mu.Unlock()
5117+}
5118+
50045119 func (i *Interactive) applyThemeSetting(name string) {
50055120 if i.cfg.SettingsStore != nil {
50065121 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
@@ -7609,6 +7724,7 @@ func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) {
76097724 i.subagentWatchMu.Lock()
76107725 i.subagentWatch = append(i.subagentWatch, entry)
76117726 i.subagentWatchMu.Unlock()
7727+ i.invalidate()
76127728
76137729 a.SetOnTurnEnd(func(step int, errMsg string) {
76147730 outcome := "completed"
@@ -144,6 +144,10 @@ type InteractiveConfig struct {
144 // above or below the main input.144 // above or below the main input.
145 TUIWorkingPosition string145 TUIWorkingPosition string
146 146
147+ // TUISubagentPosition controls whether live subagent activity renders
148+ // above or below the main input. Empty defaults below the input.
149+ TUISubagentPosition string
150+
147 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The151 // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The
148 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal152 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
149 // forwards Command/Super keypresses, but Ctrl is the displayed chord.153 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
@@ -433,6 +437,7 @@ type SettingsStore interface {
433 SetTUIInputStyle(style string) error437 SetTUIInputStyle(style string) error
434 SetTUIStatusPosition(position string) error438 SetTUIStatusPosition(position string) error
435 SetTUIWorkingPosition(position string) error439 SetTUIWorkingPosition(position string) error
440+ SetTUISubagentPosition(position string) error
436 SetReasoning(level string) error441 SetReasoning(level string) error
437 SetTheme(name string) error442 SetTheme(name string) error
438 }443 }
@@ -512,27 +517,28 @@ type Interactive struct {
512 // it. We snapshot the total expected stream length (already517 // it. We snapshot the total expected stream length (already
513 // streamed + still pending) at the moment the tool starts, and518 // streamed + still pending) at the moment the tool starts, and
514 // hold the block back until the pacer reaches it.519 // hold the block back until the pacer reaches it.
515- toolGate map[string]int520+ toolGate map[string]int
516- statusErr string521+ statusErr string
517- statusOK string522+ statusOK string
518- reloadStatusSeq uint64523+ reloadStatusSeq uint64
519- extStatuses map[string]map[string]extensionStatus524+ extStatuses map[string]map[string]extensionStatus
520- extWidgets map[string]map[string]extensionWidget525+ extWidgets map[string]map[string]extensionWidget
521- rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible526+ rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
522- liveBlock []string // live streaming/tool progress rendered outside scrollback527+ liveBlock []string // live streaming/tool progress rendered outside scrollback
523- helpBlock []string // rendered above the chat when /help was typed528+ helpBlock []string // rendered above the chat when /help was typed
524- cumUsage provider.Usage529+ cumUsage provider.Usage
525- lastCtxInput int // input_tokens of the most recent turn — approximates current context size530+ lastCtxInput int // input_tokens of the most recent turn — approximates current context size
526- busy bool531+ busy bool
527- activity agentActivity532+ activity agentActivity
528- pendingIdleWork []func()533+ subagentActivityActive bool
529- dirty chan struct{}534+ pendingIdleWork []func()
530- modelRefresh chan modelRefreshResult535+ dirty chan struct{}
531- modelRefreshing bool536+ modelRefresh chan modelRefreshResult
532- startupPreDone chan startupPreResult537+ modelRefreshing bool
533- cancelTurn context.CancelFunc538+ startupPreDone chan startupPreResult
534- scrollOffset int // rows from the bottom; 0 = pinned to latest539+ cancelTurn context.CancelFunc
535- prevScrollOffset int // last value redraw snapped against; tracks intent540+ scrollOffset int // rows from the bottom; 0 = pinned to latest
541+ prevScrollOffset int // last value redraw snapped against; tracks intent
536 542
537 // prevChatLen and prevChatCols track the chat buffer's size at the543 // prevChatLen and prevChatCols track the chat buffer's size at the
538 // last redraw so that when content grows below the user's viewport544 // last redraw so that when content grows below the user's viewport
@@ -1129,9 +1135,10 @@ func (i *Interactive) Run(ctx context.Context) error {
1129 // the same way it does inside btw.1135 // the same way it does inside btw.
1130 i.mu.Lock()1136 i.mu.Lock()
1131 busy := i.busy1137 busy := i.busy
1138+ subagentActivityActive := i.subagentActivityActive
1132 sessionLoading := i.sessionDialog.Loading()1139 sessionLoading := i.sessionDialog.Loading()
1133 i.mu.Unlock()1140 i.mu.Unlock()
1134- if busy || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {1141+ if busy || subagentActivityActive || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
1135 requestRedraw()1142 requestRedraw()
1136 }1143 }
1137 }1144 }
@@ -1823,6 +1830,18 @@ func (i *Interactive) redraw() {
1823 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)1830 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
1824 }1831 }
1825 edLines, curR, curC := i.ed.Render(mainCols)1832 edLines, curR, curC := i.ed.Render(mainCols)
1833+ dashboardActive := i.subagentsDialog.Active()
1834+ var allSubagentLines []string
1835+ if !dashboardActive {
1836+ now := i.clock()
1837+ allSubagentLines = renderSubagentActivityLines(
1838+ i.cfg.Theme,
1839+ i.spin.FrameAt(now),
1840+ i.activeSubagentActivitySnapshots(),
1841+ mainCols,
1842+ now,
1843+ )
1844+ }
1826 var workingLines []string1845 var workingLines []string
1827 if busyPrefix != "" && !workingWithStatus {1846 if busyPrefix != "" && !workingWithStatus {
1828 workingLines = []string{" " + busyPrefix}1847 workingLines = []string{" " + busyPrefix}
@@ -1870,26 +1889,31 @@ func (i *Interactive) redraw() {
1870 // content. The status block and editor get their own dedicated1889 // content. The status block and editor get their own dedicated
1871 // blanks so spacing stays consistent whether or not a dialog or1890 // blanks so spacing stays consistent whether or not a dialog or
1872 // popup is showing.1891 // popup is showing.
1873- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(edLines)+9)1892+ composeBottom := func(subagentLines []string) (bottom []string, inputStartRow int) {
1874- inputStartRow := -11893+ bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(subagentLines)+len(edLines)+9)
1875- if len(dialog) > 0 {1894+ inputStartRow = -1
1876- bottom = append(bottom, "")1895+ if len(dialog) > 0 {
1877- }1896+ bottom = append(bottom, "")
1878- bottom = append(bottom, dialog...)1897+ }
1879- // The subagent dashboard owns the bottom of the screen while it's1898+ bottom = append(bottom, dialog...)
1880- // active: it has its own inline editors for spawn (`n`) and1899+ // The subagent dashboard owns the bottom of the screen while it's
1881- // prompt (`p`), so the main input would be a confusing second1900+ // active: it has its own inline editors for spawn (`n`) and
1882- // caret. The suggest popup, sliding-in queue, status block, and1901+ // prompt (`p`), so the main input would be a confusing second
1883- // main editor are all hidden underneath it. Keystrokes still1902+ // caret. The suggest popup, sliding-in queue, status block, and
1884- // reach handleKey — it routes them to subagentsDialog.HandleKey1903+ // main editor are all hidden underneath it. Keystrokes still
1885- // before the editor ever sees them — so the only effect of this1904+ // reach handleKey — it routes them to subagentsDialog.HandleKey
1886- // branch is visual.1905+ // before the editor ever sees them — so the only effect of this
1887- if !i.subagentsDialog.Active() {1906+ // branch is visual.
1907+ if dashboardActive {
1908+ return bottom, inputStartRow
1909+ }
1910+
1888 bottom = append(bottom, suggest...)1911 bottom = append(bottom, suggest...)
1889 bottom = append(bottom, queue...)1912 bottom = append(bottom, queue...)
1890 lineInput := inputStyle == tui.InputStyleLines1913 lineInput := inputStyle == tui.InputStyleLines
1891 statusBelow := statusPosition == tui.StatusPositionBelowInput1914 statusBelow := statusPosition == tui.StatusPositionBelowInput
1892 workingBelow := workingPosition == tui.WorkingPositionBelowInput1915 workingBelow := workingPosition == tui.WorkingPositionBelowInput
1916+ subagentBelow := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition) == tui.SubagentPositionBelowInput
1893 1917
1894 var aboveInput []string1918 var aboveInput []string
1895 aboveInput = append(aboveInput, extensionLines...)1919 aboveInput = append(aboveInput, extensionLines...)
@@ -1899,7 +1923,13 @@ func (i *Interactive) redraw() {
1899 if !workingBelow {1923 if !workingBelow {
1900 aboveInput = append(aboveInput, workingLines...)1924 aboveInput = append(aboveInput, workingLines...)
1901 }1925 }
1926+ if !subagentBelow {
1927+ aboveInput = append(aboveInput, subagentLines...)
1928+ }
1902 var belowInput []string1929 var belowInput []string
1930+ if subagentBelow {
1931+ belowInput = append(belowInput, subagentLines...)
1932+ }
1903 if workingBelow {1933 if workingBelow {
1904 belowInput = append(belowInput, workingLines...)1934 belowInput = append(belowInput, workingLines...)
1905 }1935 }
@@ -1919,12 +1949,36 @@ func (i *Interactive) redraw() {
1919 inputStartRow = len(bottom)1949 inputStartRow = len(bottom)
1920 bottom = append(bottom, edLines...)1950 bottom = append(bottom, edLines...)
1921 if len(belowInput) > 0 {1951 if len(belowInput) > 0 {
1922- if !lineInput {1952+ // Running subagents sit immediately under the prompt by default.
1953+ // Other below-input chrome keeps the existing breathing room.
1954+ if !lineInput && (!subagentBelow || len(subagentLines) == 0) {
1923 bottom = append(bottom, "")1955 bottom = append(bottom, "")
1924 }1956 }
1925 bottom = append(bottom, belowInput...)1957 bottom = append(bottom, belowInput...)
1926 }1958 }
1959+ return bottom, inputStartRow
1960+ }
1961+
1962+ // Preserve one chat row and the renderer-owned bottom margin. This keeps
1963+ // a bounded set of live indicators from pushing the editor or its cursor
1964+ // out of a fixed right-bar frame on short terminals.
1965+ const rendererBottomMarginRows = 1
1966+ maxBottomRows := rows - rendererBottomMarginRows - 1
1967+ subagentLines := allSubagentLines
1968+ bottom, inputStartRow := composeBottom(subagentLines)
1969+ if !dashboardActive && len(allSubagentLines) > 0 {
1970+ for maxRows := len(allSubagentLines); maxRows >= 0; maxRows-- {
1971+ candidate := limitSubagentActivityLines(i.cfg.Theme, allSubagentLines, maxRows, mainCols)
1972+ candidateBottom, candidateInputStartRow := composeBottom(candidate)
1973+ if len(candidateBottom) <= maxBottomRows || maxRows == 0 {
1974+ subagentLines = candidate
1975+ bottom = candidateBottom
1976+ inputStartRow = candidateInputStartRow
1977+ break
1978+ }
1979+ }
1927 }1980 }
1981+ i.subagentActivityActive = !dashboardActive && len(subagentLines) > 0
1928 1982
1929 chatRows := rows - len(bottom)1983 chatRows := rows - len(bottom)
1930 if rightBarActive {1984 if rightBarActive {
@@ -3649,6 +3703,18 @@ func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model stri
3649 i.invalidate()3703 i.invalidate()
3650 }3704 }
3651 3705
3706+// SetSubagentSessionScope changes the live-worker filter and then requests a
3707+// redraw. Session changes must use this boundary after their host/session
3708+// state commits so input-adjacent activity cannot remain scoped to the prior
3709+// session until another UI event arrives.
3710+func (i *Interactive) SetSubagentSessionScope(sessionID string) {
3711+ if i.cfg.Supervisor == nil {
3712+ return
3713+ }
3714+ i.cfg.Supervisor.SetActiveSession(sessionID)
3715+ i.invalidate()
3716+}
3717+
3652 // ApplyChangedCWD is called by hosts after a successful /cd hook that do3718 // ApplyChangedCWD is called by hosts after a successful /cd hook that do
3653 // not provide startup context metadata.3719 // not provide startup context metadata.
3654 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {3720 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
@@ -4117,6 +4183,7 @@ func (i *Interactive) openSettingsDialog() {
4117 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)4183 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
4118 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)4184 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
4119 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)4185 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
4186+ subagentPosition := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition)
4120 quickItems := i.quickModelSettingItems()4187 quickItems := i.quickModelSettingItems()
4121 4188
4122 autoCompactOptions := []settingsOption{4189 autoCompactOptions := []settingsOption{
@@ -4198,6 +4265,18 @@ func (i *Interactive) openSettingsDialog() {
4198 }4265 }
4199 }4266 }
4200 4267
4268+ subagentPositionOptions := []settingsOption{
4269+ {value: tui.SubagentPositionAboveInput, label: "above input", desc: "show running subagents above the input"},
4270+ {value: tui.SubagentPositionBelowInput, label: "below input", desc: "show running subagents immediately below the input"},
4271+ }
4272+ subagentPositionChoice := 0
4273+ for idx, opt := range subagentPositionOptions {
4274+ if opt.value == subagentPosition {
4275+ subagentPositionChoice = idx
4276+ break
4277+ }
4278+ }
4279+
4201 items := []settingsItem{4280 items := []settingsItem{
4202 {4281 {
4203 key: "inline_images_enabled",4282 key: "inline_images_enabled",
@@ -4294,15 +4373,8 @@ func (i *Interactive) openSettingsDialog() {
4294 {4373 {
4295 key: "tui_settings",4374 key: "tui_settings",
4296 label: "tui settings",4375 label: "tui settings",
4297- desc: "choose input chrome and where status information appears",4376+ desc: "choose input chrome in its order around the prompt",
4298 children: []settingsItem{4377 children: []settingsItem{
4299- {
4300- key: "tui_input_style",
4301- label: "input style",
4302- desc: "choose between the plain prompt, lines, and a block input area",
4303- options: inputStyleOptions,
4304- choice: inputStyleChoice,
4305- },
4306 {4378 {
4307 key: "tui_status_position",4379 key: "tui_status_position",
4308 label: "status position",4380 label: "status position",
@@ -4317,6 +4389,20 @@ func (i *Interactive) openSettingsDialog() {
4317 options: workingPositionOptions,4389 options: workingPositionOptions,
4318 choice: workingPositionChoice,4390 choice: workingPositionChoice,
4319 },4391 },
4392+ {
4393+ key: "tui_input_style",
4394+ label: "input style",
4395+ desc: "choose between the plain prompt, lines, and a block input area",
4396+ options: inputStyleOptions,
4397+ choice: inputStyleChoice,
4398+ },
4399+ {
4400+ key: "tui_subagent_position",
4401+ label: "running subagent position",
4402+ desc: "place live subagent activity above or below the input",
4403+ options: subagentPositionOptions,
4404+ choice: subagentPositionChoice,
4405+ },
4320 },4406 },
4321 },4407 },
4322 reasoningItem,4408 reasoningItem,
@@ -4355,6 +4441,8 @@ func (i *Interactive) applySettingChange(act settingsAction) {
4355 i.applyTUIStatusPositionSetting(act.StringValue)4441 i.applyTUIStatusPositionSetting(act.StringValue)
4356 case act.Key == "tui_working_position":4442 case act.Key == "tui_working_position":
4357 i.applyTUIWorkingPositionSetting(act.StringValue)4443 i.applyTUIWorkingPositionSetting(act.StringValue)
4444+ case act.Key == "tui_subagent_position":
4445+ i.applyTUISubagentPositionSetting(act.StringValue)
4358 default:4446 default:
4359 i.applySettingToggle(act.Key, act.Value)4447 i.applySettingToggle(act.Key, act.Value)
4360 }4448 }
@@ -5001,6 +5089,33 @@ func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
5001 i.mu.Unlock()5089 i.mu.Unlock()
5002 }5090 }
5003 5091
5092+func (i *Interactive) applyTUISubagentPositionSetting(position string) {
5093+ defer func() {
5094+ if i.rend != nil {
5095+ i.rend.Clear()
5096+ }
5097+ i.invalidate()
5098+ }()
5099+ position = tui.NormalizeSubagentPosition(position)
5100+ i.cfg.TUISubagentPosition = position
5101+ if i.cfg.SettingsStore != nil {
5102+ if err := i.cfg.SettingsStore.SetTUISubagentPosition(position); err != nil {
5103+ i.mu.Lock()
5104+ i.statusErr = "settings: " + err.Error()
5105+ i.mu.Unlock()
5106+ return
5107+ }
5108+ }
5109+ i.mu.Lock()
5110+ label := "below input"
5111+ if position == tui.SubagentPositionAboveInput {
5112+ label = "above input"
5113+ }
5114+ i.statusOK = "running subagent position " + label
5115+ i.statusErr = ""
5116+ i.mu.Unlock()
5117+}
5118+
5004 func (i *Interactive) applyThemeSetting(name string) {5119 func (i *Interactive) applyThemeSetting(name string) {
5005 if i.cfg.SettingsStore != nil {5120 if i.cfg.SettingsStore != nil {
5006 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {5121 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
@@ -7609,6 +7724,7 @@ func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) {
7609 i.subagentWatchMu.Lock()7724 i.subagentWatchMu.Lock()
7610 i.subagentWatch = append(i.subagentWatch, entry)7725 i.subagentWatch = append(i.subagentWatch, entry)
7611 i.subagentWatchMu.Unlock()7726 i.subagentWatchMu.Unlock()
7727+ i.invalidate()
7612 7728
7613 a.SetOnTurnEnd(func(step int, errMsg string) {7729 a.SetOnTurnEnd(func(step int, errMsg string) {
7614 outcome := "completed"7730 outcome := "completed"
M packages/agent/modes/spinner.go +22 -0
@@ -56,6 +56,28 @@ func (s *spinner) Frame() string {
5656 return s.frames[idx]
5757 }
5858
59+// FrameAt returns a frame based on an absolute clock. Independent background
60+// activity uses it so its animation does not need to reset or interfere with
61+// the main turn's spinner lifecycle.
62+func (s *spinner) FrameAt(now time.Time) string {
63+ if len(s.frames) == 0 {
64+ return ""
65+ }
66+ if now.IsZero() {
67+ now = time.Now()
68+ }
69+ interval := s.interval
70+ if interval <= 0 {
71+ interval = 80 * time.Millisecond
72+ }
73+ ticks := now.UnixNano() / int64(interval)
74+ idx := int(ticks % int64(len(s.frames)))
75+ if idx < 0 {
76+ idx += len(s.frames)
77+ }
78+ return s.frames[idx]
79+}
80+
5981 // Elapsed returns the wall-clock duration the spinner has been running.
6082 func (s *spinner) Elapsed() time.Duration {
6183 if s.startedAt.IsZero() {
@@ -56,6 +56,28 @@ func (s *spinner) Frame() string {
56 return s.frames[idx]56 return s.frames[idx]
57 }57 }
58 58
59+// FrameAt returns a frame based on an absolute clock. Independent background
60+// activity uses it so its animation does not need to reset or interfere with
61+// the main turn's spinner lifecycle.
62+func (s *spinner) FrameAt(now time.Time) string {
63+ if len(s.frames) == 0 {
64+ return ""
65+ }
66+ if now.IsZero() {
67+ now = time.Now()
68+ }
69+ interval := s.interval
70+ if interval <= 0 {
71+ interval = 80 * time.Millisecond
72+ }
73+ ticks := now.UnixNano() / int64(interval)
74+ idx := int(ticks % int64(len(s.frames)))
75+ if idx < 0 {
76+ idx += len(s.frames)
77+ }
78+ return s.frames[idx]
79+}
80+
59 // Elapsed returns the wall-clock duration the spinner has been running.81 // Elapsed returns the wall-clock duration the spinner has been running.
60 func (s *spinner) Elapsed() time.Duration {82 func (s *spinner) Elapsed() time.Duration {
61 if s.startedAt.IsZero() {83 if s.startedAt.IsZero() {
A packages/agent/modes/spinner_test.go +17 -0
@@ -0,0 +1,17 @@
1+package modes
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestSpinnerFrameAtPreEpoch(t *testing.T) {
9+ spinner := &spinner{
10+ frames: []string{"one", "two", "three"},
11+ interval: 80 * time.Millisecond,
12+ }
13+
14+ if got, want := spinner.FrameAt(time.Unix(0, -80*int64(time.Millisecond))), "three"; got != want {
15+ t.Fatalf("FrameAt before Unix epoch = %q, want %q", got, want)
16+ }
17+}
@@ -0,0 +1,17 @@
1+package modes
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestSpinnerFrameAtPreEpoch(t *testing.T) {
9+ spinner := &spinner{
10+ frames: []string{"one", "two", "three"},
11+ interval: 80 * time.Millisecond,
12+ }
13+
14+ if got, want := spinner.FrameAt(time.Unix(0, -80*int64(time.Millisecond))), "three"; got != want {
15+ t.Fatalf("FrameAt before Unix epoch = %q, want %q", got, want)
16+ }
17+}
A packages/agent/modes/subagent_indicator.go +282 -0
@@ -0,0 +1,282 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/agent/subagents"
9+ "github.com/bnema/zut/packages/tui"
10+ "github.com/mattn/go-runewidth"
11+)
12+
13+// renderSubagentActivityLines returns one compact, input-adjacent row for each
14+// active delegated turn. Terminal and idle workers are intentionally omitted:
15+// the /subagents dashboard remains the durable history, while this surface is
16+// only a live "is it still moving?" signal.
17+func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []subagents.AgentSnapshot, width int, now time.Time) []string {
18+ lines := make([]string, 0, len(snapshots))
19+ for _, snapshot := range snapshots {
20+ if !subagentTurnIsActive(snapshot) {
21+ continue
22+ }
23+ line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, width, now)
24+ if line != "" {
25+ lines = append(lines, line)
26+ }
27+ }
28+ return lines
29+}
30+
31+func subagentTurnIsActive(snapshot subagents.AgentSnapshot) bool {
32+ switch snapshot.ProcessState {
33+ case subagents.ProcessPending, subagents.ProcessStarting, subagents.ProcessAlive:
34+ default:
35+ // A detached worker has no confirmed live connection. Keep it in the
36+ // dashboard, rather than presenting an endlessly animated row beside
37+ // the input as if it were still making progress.
38+ return false
39+ }
40+
41+ switch snapshot.TurnState {
42+ case subagents.TurnSucceeded, subagents.TurnFailed, subagents.TurnCanceled:
43+ return false
44+ }
45+ switch snapshot.Status {
46+ case subagents.StatusPending:
47+ return true
48+ case subagents.StatusRunning:
49+ default:
50+ return false
51+ }
52+
53+ switch snapshot.TurnState {
54+ case subagents.TurnQueued, subagents.TurnRunning, subagents.TurnCanceling:
55+ return true
56+ }
57+
58+ // The worker enters TurnIdle while it waits for a follow-up prompt. Keep a
59+ // brief "starting" phase visible, but remove the row as soon as it reports
60+ // ordinary idle so an already-completed task never looks active.
61+ return strings.TrimSpace(snapshot.Activity) != "" && strings.TrimSpace(snapshot.Activity) != "idle"
62+}
63+
64+func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, width int, now time.Time) string {
65+ name := sanitizeSubagentIndicatorText(snapshot.Subagent)
66+ if name == "" {
67+ name = sanitizeSubagentIndicatorText(snapshot.ID)
68+ }
69+ if name == "" {
70+ name = "subagent"
71+ }
72+ spinnerGlyph = strings.TrimSpace(spinnerGlyph)
73+ if spinnerGlyph == "" {
74+ spinnerGlyph = "."
75+ }
76+ activity := compactSubagentActivity(snapshot)
77+ age := formatSubagentActivityAge(snapshot, now)
78+ plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)
79+ if plain == "" {
80+ return ""
81+ }
82+
83+ switch layout {
84+ case subagentActivityFull:
85+ parts := strings.SplitN(plain, " · ", 3)
86+ if len(parts) != 3 {
87+ return " " + th.FGColor(th.Muted, plain)
88+ }
89+ name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")
90+ return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
91+ th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · ") +
92+ th.FGColor(th.FG, parts[1]) + th.FGColor(th.Muted, " · "+parts[2])
93+ case subagentActivityNameAge:
94+ parts := strings.SplitN(plain, " · ", 2)
95+ if len(parts) != 2 {
96+ return " " + th.FGColor(th.Muted, plain)
97+ }
98+ name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")
99+ return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
100+ th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · "+parts[1])
101+ case subagentActivityCompact:
102+ prefix := spinnerGlyph + " "
103+ suffix := " " + age
104+ if strings.HasPrefix(plain, prefix) && strings.HasSuffix(plain, suffix) {
105+ name = strings.TrimSuffix(strings.TrimPrefix(plain, prefix), suffix)
106+ return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
107+ th.FGColor(th.Assistant, name) + " " + th.FGColor(th.Muted, age)
108+ }
109+ }
110+ return " " + th.FGColor(th.Muted, plain)
111+}
112+
113+type subagentActivityLayout uint8
114+
115+const (
116+ subagentActivityFull subagentActivityLayout = iota
117+ subagentActivityNameAge
118+ subagentActivityCompact
119+)
120+
121+func fitSubagentActivityLine(spinnerGlyph, name, activity, age string, width int) (string, subagentActivityLayout) {
122+ spinnerGlyph = strings.TrimSpace(spinnerGlyph)
123+ if spinnerGlyph == "" {
124+ spinnerGlyph = "."
125+ }
126+ if width <= 0 {
127+ return "", subagentActivityCompact
128+ }
129+
130+ full := spinnerGlyph + " " + name + " · " + activity + " · " + age
131+ if runewidth.StringWidth(full) <= width {
132+ return full, subagentActivityFull
133+ }
134+
135+ nameAge := spinnerGlyph + " " + name + " · " + age
136+ if activityWidth := width - runewidth.StringWidth(nameAge) - runewidth.StringWidth(" · "); activityWidth >= 4 {
137+ activity = truncateSubagentIndicatorText(activity, activityWidth)
138+ return spinnerGlyph + " " + name + " · " + activity + " · " + age, subagentActivityFull
139+ }
140+ if runewidth.StringWidth(nameAge) <= width {
141+ return nameAge, subagentActivityNameAge
142+ }
143+
144+ prefix := spinnerGlyph + " "
145+ suffix := " " + age
146+ nameWidth := width - runewidth.StringWidth(prefix) - runewidth.StringWidth(suffix)
147+ if nameWidth >= 1 {
148+ return prefix + truncateSubagentIndicatorText(name, nameWidth) + suffix, subagentActivityCompact
149+ }
150+ if runewidth.StringWidth(age) <= width {
151+ return age, subagentActivityCompact
152+ }
153+ return truncateSubagentIndicatorText(spinnerGlyph, width), subagentActivityCompact
154+}
155+
156+func compactSubagentActivity(snapshot subagents.AgentSnapshot) string {
157+ activity := sanitizeSubagentIndicatorText(snapshot.Activity)
158+ // Heartbeats report "idle" even while a delegated turn is still running.
159+ // Keep the row meaningful in that interval instead of contradicting its
160+ // spinner and turn state.
161+ if activity != "" && (activity != "idle" || snapshot.TurnState != subagents.TurnRunning) {
162+ return activity
163+ }
164+ switch snapshot.TurnState {
165+ case subagents.TurnQueued:
166+ return "queued"
167+ case subagents.TurnCanceling:
168+ return "cancelling"
169+ default:
170+ return "working"
171+ }
172+}
173+
174+func formatSubagentActivityAge(snapshot subagents.AgentSnapshot, now time.Time) string {
175+ last := snapshot.LastActivity
176+ if last.IsZero() {
177+ last = snapshot.UpdatedAt
178+ }
179+ if last.IsZero() {
180+ last = snapshot.Started
181+ }
182+ if last.IsZero() {
183+ return "-"
184+ }
185+ if now.IsZero() {
186+ now = time.Now()
187+ }
188+ elapsed := now.Sub(last)
189+ if elapsed < 0 {
190+ elapsed = 0
191+ }
192+ seconds := int(elapsed.Seconds())
193+ switch {
194+ case seconds < 60:
195+ return fmt.Sprintf("%ds", seconds)
196+ case seconds < 60*60:
197+ return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
198+ case seconds < 24*60*60:
199+ minutes := (seconds % (60 * 60)) / 60
200+ return fmt.Sprintf("%dh%02dm", seconds/(60*60), minutes)
201+ default:
202+ hours := (seconds % (24 * 60 * 60)) / (60 * 60)
203+ return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), hours)
204+ }
205+}
206+
207+func truncateSubagentIndicatorText(s string, width int) string {
208+ if width <= 0 {
209+ return ""
210+ }
211+ if runewidth.StringWidth(s) <= width {
212+ return s
213+ }
214+ if width <= 3 {
215+ return strings.Repeat(".", width)
216+ }
217+ return runewidth.Truncate(s, width, "...")
218+}
219+
220+// sanitizeSubagentIndicatorText keeps worker-derived labels on one safe
221+// terminal row before they are measured or styled. A worker's profile, id, or
222+// tool activity is data, never terminal control output.
223+func sanitizeSubagentIndicatorText(s string) string {
224+ return sanitizeSessionTreeText(s)
225+}
226+
227+// limitSubagentActivityLines reserves the input's terminal rows before adding
228+// activity rows. When the frame has room for only part of the live set, retain
229+// the earliest rows and make the omitted count explicit in one final row.
230+func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int) []string {
231+ if maxRows <= 0 || len(lines) == 0 {
232+ return nil
233+ }
234+ if len(lines) <= maxRows {
235+ return lines
236+ }
237+ if maxRows == 1 {
238+ return []string{subagentActivityOverflowLine(th, len(lines), width)}
239+ }
240+ out := append([]string(nil), lines[:maxRows-1]...)
241+ return append(out, subagentActivityOverflowLine(th, len(lines)-len(out), width))
242+}
243+
244+func subagentActivityOverflowLine(th tui.Theme, hidden, width int) string {
245+ text := fmt.Sprintf(" … %d more active subagents", hidden)
246+ return th.FGColor(th.Muted, truncateSubagentIndicatorText(text, width))
247+}
248+
249+// activeSubagentActivitySnapshots builds the compact state needed by the
250+// input indicator without copying transcript buffers. It mirrors the
251+// supervisor's live-session filtering while deliberately omitting completed
252+// workers, which belong in the dashboard and completion notification instead.
253+func (i *Interactive) activeSubagentActivitySnapshots() []subagents.AgentSnapshot {
254+ supervisor := i.cfg.Supervisor
255+ if supervisor == nil {
256+ return nil
257+ }
258+
259+ activeSession := supervisor.ActiveSession()
260+ agents := supervisor.List()
261+ out := make([]subagents.AgentSnapshot, 0, len(agents))
262+ for _, agent := range agents {
263+ if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession {
264+ continue
265+ }
266+ snapshot := subagents.AgentSnapshot{
267+ ID: agent.ID,
268+ Status: agent.Status(),
269+ ProcessState: agent.ProcessState(),
270+ TurnState: agent.TurnState(),
271+ Activity: agent.Activity(),
272+ Started: agent.Started,
273+ UpdatedAt: agent.UpdatedAt(),
274+ LastActivity: agent.LastActivity(),
275+ Subagent: agent.Subagent,
276+ }
277+ if subagentTurnIsActive(snapshot) {
278+ out = append(out, snapshot)
279+ }
280+ }
281+ return out
282+}
@@ -0,0 +1,282 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/agent/subagents"
9+ "github.com/bnema/zut/packages/tui"
10+ "github.com/mattn/go-runewidth"
11+)
12+
13+// renderSubagentActivityLines returns one compact, input-adjacent row for each
14+// active delegated turn. Terminal and idle workers are intentionally omitted:
15+// the /subagents dashboard remains the durable history, while this surface is
16+// only a live "is it still moving?" signal.
17+func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []subagents.AgentSnapshot, width int, now time.Time) []string {
18+ lines := make([]string, 0, len(snapshots))
19+ for _, snapshot := range snapshots {
20+ if !subagentTurnIsActive(snapshot) {
21+ continue
22+ }
23+ line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, width, now)
24+ if line != "" {
25+ lines = append(lines, line)
26+ }
27+ }
28+ return lines
29+}
30+
31+func subagentTurnIsActive(snapshot subagents.AgentSnapshot) bool {
32+ switch snapshot.ProcessState {
33+ case subagents.ProcessPending, subagents.ProcessStarting, subagents.ProcessAlive:
34+ default:
35+ // A detached worker has no confirmed live connection. Keep it in the
36+ // dashboard, rather than presenting an endlessly animated row beside
37+ // the input as if it were still making progress.
38+ return false
39+ }
40+
41+ switch snapshot.TurnState {
42+ case subagents.TurnSucceeded, subagents.TurnFailed, subagents.TurnCanceled:
43+ return false
44+ }
45+ switch snapshot.Status {
46+ case subagents.StatusPending:
47+ return true
48+ case subagents.StatusRunning:
49+ default:
50+ return false
51+ }
52+
53+ switch snapshot.TurnState {
54+ case subagents.TurnQueued, subagents.TurnRunning, subagents.TurnCanceling:
55+ return true
56+ }
57+
58+ // The worker enters TurnIdle while it waits for a follow-up prompt. Keep a
59+ // brief "starting" phase visible, but remove the row as soon as it reports
60+ // ordinary idle so an already-completed task never looks active.
61+ return strings.TrimSpace(snapshot.Activity) != "" && strings.TrimSpace(snapshot.Activity) != "idle"
62+}
63+
64+func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, width int, now time.Time) string {
65+ name := sanitizeSubagentIndicatorText(snapshot.Subagent)
66+ if name == "" {
67+ name = sanitizeSubagentIndicatorText(snapshot.ID)
68+ }
69+ if name == "" {
70+ name = "subagent"
71+ }
72+ spinnerGlyph = strings.TrimSpace(spinnerGlyph)
73+ if spinnerGlyph == "" {
74+ spinnerGlyph = "."
75+ }
76+ activity := compactSubagentActivity(snapshot)
77+ age := formatSubagentActivityAge(snapshot, now)
78+ plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)
79+ if plain == "" {
80+ return ""
81+ }
82+
83+ switch layout {
84+ case subagentActivityFull:
85+ parts := strings.SplitN(plain, " · ", 3)
86+ if len(parts) != 3 {
87+ return " " + th.FGColor(th.Muted, plain)
88+ }
89+ name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")
90+ return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
91+ th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · ") +
92+ th.FGColor(th.FG, parts[1]) + th.FGColor(th.Muted, " · "+parts[2])
93+ case subagentActivityNameAge:
94+ parts := strings.SplitN(plain, " · ", 2)
95+ if len(parts) != 2 {
96+ return " " + th.FGColor(th.Muted, plain)
97+ }
98+ name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")
99+ return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
100+ th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · "+parts[1])
101+ case subagentActivityCompact:
102+ prefix := spinnerGlyph + " "
103+ suffix := " " + age
104+ if strings.HasPrefix(plain, prefix) && strings.HasSuffix(plain, suffix) {
105+ name = strings.TrimSuffix(strings.TrimPrefix(plain, prefix), suffix)
106+ return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
107+ th.FGColor(th.Assistant, name) + " " + th.FGColor(th.Muted, age)
108+ }
109+ }
110+ return " " + th.FGColor(th.Muted, plain)
111+}
112+
113+type subagentActivityLayout uint8
114+
115+const (
116+ subagentActivityFull subagentActivityLayout = iota
117+ subagentActivityNameAge
118+ subagentActivityCompact
119+)
120+
121+func fitSubagentActivityLine(spinnerGlyph, name, activity, age string, width int) (string, subagentActivityLayout) {
122+ spinnerGlyph = strings.TrimSpace(spinnerGlyph)
123+ if spinnerGlyph == "" {
124+ spinnerGlyph = "."
125+ }
126+ if width <= 0 {
127+ return "", subagentActivityCompact
128+ }
129+
130+ full := spinnerGlyph + " " + name + " · " + activity + " · " + age
131+ if runewidth.StringWidth(full) <= width {
132+ return full, subagentActivityFull
133+ }
134+
135+ nameAge := spinnerGlyph + " " + name + " · " + age
136+ if activityWidth := width - runewidth.StringWidth(nameAge) - runewidth.StringWidth(" · "); activityWidth >= 4 {
137+ activity = truncateSubagentIndicatorText(activity, activityWidth)
138+ return spinnerGlyph + " " + name + " · " + activity + " · " + age, subagentActivityFull
139+ }
140+ if runewidth.StringWidth(nameAge) <= width {
141+ return nameAge, subagentActivityNameAge
142+ }
143+
144+ prefix := spinnerGlyph + " "
145+ suffix := " " + age
146+ nameWidth := width - runewidth.StringWidth(prefix) - runewidth.StringWidth(suffix)
147+ if nameWidth >= 1 {
148+ return prefix + truncateSubagentIndicatorText(name, nameWidth) + suffix, subagentActivityCompact
149+ }
150+ if runewidth.StringWidth(age) <= width {
151+ return age, subagentActivityCompact
152+ }
153+ return truncateSubagentIndicatorText(spinnerGlyph, width), subagentActivityCompact
154+}
155+
156+func compactSubagentActivity(snapshot subagents.AgentSnapshot) string {
157+ activity := sanitizeSubagentIndicatorText(snapshot.Activity)
158+ // Heartbeats report "idle" even while a delegated turn is still running.
159+ // Keep the row meaningful in that interval instead of contradicting its
160+ // spinner and turn state.
161+ if activity != "" && (activity != "idle" || snapshot.TurnState != subagents.TurnRunning) {
162+ return activity
163+ }
164+ switch snapshot.TurnState {
165+ case subagents.TurnQueued:
166+ return "queued"
167+ case subagents.TurnCanceling:
168+ return "cancelling"
169+ default:
170+ return "working"
171+ }
172+}
173+
174+func formatSubagentActivityAge(snapshot subagents.AgentSnapshot, now time.Time) string {
175+ last := snapshot.LastActivity
176+ if last.IsZero() {
177+ last = snapshot.UpdatedAt
178+ }
179+ if last.IsZero() {
180+ last = snapshot.Started
181+ }
182+ if last.IsZero() {
183+ return "-"
184+ }
185+ if now.IsZero() {
186+ now = time.Now()
187+ }
188+ elapsed := now.Sub(last)
189+ if elapsed < 0 {
190+ elapsed = 0
191+ }
192+ seconds := int(elapsed.Seconds())
193+ switch {
194+ case seconds < 60:
195+ return fmt.Sprintf("%ds", seconds)
196+ case seconds < 60*60:
197+ return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
198+ case seconds < 24*60*60:
199+ minutes := (seconds % (60 * 60)) / 60
200+ return fmt.Sprintf("%dh%02dm", seconds/(60*60), minutes)
201+ default:
202+ hours := (seconds % (24 * 60 * 60)) / (60 * 60)
203+ return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), hours)
204+ }
205+}
206+
207+func truncateSubagentIndicatorText(s string, width int) string {
208+ if width <= 0 {
209+ return ""
210+ }
211+ if runewidth.StringWidth(s) <= width {
212+ return s
213+ }
214+ if width <= 3 {
215+ return strings.Repeat(".", width)
216+ }
217+ return runewidth.Truncate(s, width, "...")
218+}
219+
220+// sanitizeSubagentIndicatorText keeps worker-derived labels on one safe
221+// terminal row before they are measured or styled. A worker's profile, id, or
222+// tool activity is data, never terminal control output.
223+func sanitizeSubagentIndicatorText(s string) string {
224+ return sanitizeSessionTreeText(s)
225+}
226+
227+// limitSubagentActivityLines reserves the input's terminal rows before adding
228+// activity rows. When the frame has room for only part of the live set, retain
229+// the earliest rows and make the omitted count explicit in one final row.
230+func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int) []string {
231+ if maxRows <= 0 || len(lines) == 0 {
232+ return nil
233+ }
234+ if len(lines) <= maxRows {
235+ return lines
236+ }
237+ if maxRows == 1 {
238+ return []string{subagentActivityOverflowLine(th, len(lines), width)}
239+ }
240+ out := append([]string(nil), lines[:maxRows-1]...)
241+ return append(out, subagentActivityOverflowLine(th, len(lines)-len(out), width))
242+}
243+
244+func subagentActivityOverflowLine(th tui.Theme, hidden, width int) string {
245+ text := fmt.Sprintf(" … %d more active subagents", hidden)
246+ return th.FGColor(th.Muted, truncateSubagentIndicatorText(text, width))
247+}
248+
249+// activeSubagentActivitySnapshots builds the compact state needed by the
250+// input indicator without copying transcript buffers. It mirrors the
251+// supervisor's live-session filtering while deliberately omitting completed
252+// workers, which belong in the dashboard and completion notification instead.
253+func (i *Interactive) activeSubagentActivitySnapshots() []subagents.AgentSnapshot {
254+ supervisor := i.cfg.Supervisor
255+ if supervisor == nil {
256+ return nil
257+ }
258+
259+ activeSession := supervisor.ActiveSession()
260+ agents := supervisor.List()
261+ out := make([]subagents.AgentSnapshot, 0, len(agents))
262+ for _, agent := range agents {
263+ if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession {
264+ continue
265+ }
266+ snapshot := subagents.AgentSnapshot{
267+ ID: agent.ID,
268+ Status: agent.Status(),
269+ ProcessState: agent.ProcessState(),
270+ TurnState: agent.TurnState(),
271+ Activity: agent.Activity(),
272+ Started: agent.Started,
273+ UpdatedAt: agent.UpdatedAt(),
274+ LastActivity: agent.LastActivity(),
275+ Subagent: agent.Subagent,
276+ }
277+ if subagentTurnIsActive(snapshot) {
278+ out = append(out, snapshot)
279+ }
280+ }
281+ return out
282+}
A packages/agent/modes/subagent_indicator_test.go +384 -0
@@ -0,0 +1,384 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "testing"
8+ "time"
9+ "unicode"
10+
11+ "github.com/bnema/zut/packages/agent/extproto"
12+ "github.com/bnema/zut/packages/agent/subagents"
13+ "github.com/bnema/zut/packages/tui"
14+ "github.com/mattn/go-runewidth"
15+)
16+
17+type sizedSubagentIndicatorTerminal struct {
18+ *alertTestTerminal
19+ cols int
20+ rows int
21+}
22+
23+func (t *sizedSubagentIndicatorTerminal) Size() (int, int) { return t.cols, t.rows }
24+
25+func TestRenderSubagentActivityLinesShowsOnlyActiveTurns(t *testing.T) {
26+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
27+ lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{
28+ {
29+ ID: "design-123",
30+ Subagent: "senior-engineer",
31+ Status: subagents.StatusPending,
32+ ProcessState: subagents.ProcessPending,
33+ TurnState: subagents.TurnQueued,
34+ Activity: "queued",
35+ LastActivity: now.Add(-4 * time.Second),
36+ },
37+ {
38+ ID: "tests-456",
39+ Subagent: "",
40+ Status: subagents.StatusRunning,
41+ ProcessState: subagents.ProcessAlive,
42+ TurnState: subagents.TurnRunning,
43+ Activity: "tool: read",
44+ LastActivity: now.Add(-72 * time.Second),
45+ },
46+ {
47+ ID: "heartbeat-789",
48+ Subagent: "implementer",
49+ Status: subagents.StatusRunning,
50+ ProcessState: subagents.ProcessAlive,
51+ TurnState: subagents.TurnRunning,
52+ Activity: "idle",
53+ LastActivity: now.Add(-5 * time.Second),
54+ },
55+ {
56+ ID: "idle-789",
57+ Subagent: "reviewer",
58+ Status: subagents.StatusRunning,
59+ ProcessState: subagents.ProcessAlive,
60+ TurnState: subagents.TurnIdle,
61+ Activity: "idle",
62+ LastActivity: now.Add(-time.Second),
63+ },
64+ {
65+ ID: "settled-012",
66+ Subagent: "implementer",
67+ Status: subagents.StatusPending,
68+ ProcessState: subagents.ProcessPending,
69+ TurnState: subagents.TurnSucceeded,
70+ Activity: "done",
71+ LastActivity: now.Add(-time.Second),
72+ },
73+ {
74+ ID: "done-345",
75+ Subagent: "implementer",
76+ Status: subagents.StatusDone,
77+ ProcessState: subagents.ProcessExited,
78+ TurnState: subagents.TurnSucceeded,
79+ Activity: "done",
80+ LastActivity: now.Add(-time.Second),
81+ },
82+ {
83+ ID: "detached-678",
84+ Subagent: "reviewer",
85+ Status: subagents.StatusRunning,
86+ ProcessState: subagents.ProcessDetached,
87+ TurnState: subagents.TurnRunning,
88+ Activity: "detached",
89+ LastActivity: now.Add(-time.Second),
90+ },
91+ }, 80, now)
92+
93+ got := plainActivityLines(lines)
94+ want := []string{
95+ " / senior-engineer · queued · 4s",
96+ " / tests-456 · tool: read · 1m12s",
97+ " / implementer · working · 5s",
98+ }
99+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
100+ t.Fatalf("activity lines = %#v, want %#v", got, want)
101+ }
102+}
103+
104+func TestRenderSubagentActivityLinesKeepsNameAndAgeOnNarrowTerminals(t *testing.T) {
105+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
106+ const width = 27
107+ lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{
108+ ID: "review-123",
109+ Subagent: "reviewer",
110+ Status: subagents.StatusRunning,
111+ ProcessState: subagents.ProcessAlive,
112+ TurnState: subagents.TurnRunning,
113+ Activity: "tool: read an exceptionally long filename",
114+ LastActivity: now.Add(-72 * time.Second),
115+ }}, width, now)
116+
117+ if len(lines) != 1 {
118+ t.Fatalf("line count = %d, want 1", len(lines))
119+ }
120+ line := plainActivityLines(lines)[0]
121+ if runewidth.StringWidth(line) > width {
122+ t.Fatalf("line width = %d, want <= %d: %q", runewidth.StringWidth(line), width, line)
123+ }
124+ for _, want := range []string{"reviewer", "1m12s"} {
125+ if !strings.Contains(line, want) {
126+ t.Fatalf("narrow line %q is missing %q", line, want)
127+ }
128+ }
129+}
130+
131+func TestRenderSubagentActivityLinesSanitizesWorkerText(t *testing.T) {
132+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
133+ lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{
134+ ID: "review-123",
135+ Subagent: "reviewer\x1b]52;c;ignored\a",
136+ Status: subagents.StatusRunning,
137+ ProcessState: subagents.ProcessAlive,
138+ TurnState: subagents.TurnRunning,
139+ Activity: "tool:\tread\x1b[2J\rfile",
140+ LastActivity: now.Add(-time.Second),
141+ }}, 80, now)
142+ if len(lines) != 1 {
143+ t.Fatalf("line count = %d, want 1", len(lines))
144+ }
145+ plain := plainActivityLines(lines)[0]
146+ if strings.IndexFunc(plain, unicode.IsControl) >= 0 {
147+ t.Fatalf("indicator retained a control character: %q", plain)
148+ }
149+ for _, want := range []string{"reviewer", "tool: read file"} {
150+ if !strings.Contains(plain, want) {
151+ t.Fatalf("sanitized indicator %q is missing %q", plain, want)
152+ }
153+ }
154+}
155+
156+func TestTruncateSubagentIndicatorTextPreservesGraphemeClusters(t *testing.T) {
157+ if got, want := truncateSubagentIndicatorText("a👩<U+200D>💻abcdef", 6), "a👩<U+200D>💻..."; got != want {
158+ t.Fatalf("truncated grapheme = %q, want %q", got, want)
159+ }
160+}
161+
162+func TestFormatSubagentActivityAgeFallsBackToLifecycleTimes(t *testing.T) {
163+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
164+ if got, want := formatSubagentActivityAge(subagents.AgentSnapshot{UpdatedAt: now.Add(-7 * time.Second)}, now), "7s"; got != want {
165+ t.Fatalf("updated-at age = %q, want %q", got, want)
166+ }
167+ if got, want := formatSubagentActivityAge(subagents.AgentSnapshot{Started: now.Add(-8 * time.Second)}, now), "8s"; got != want {
168+ t.Fatalf("started-at age = %q, want %q", got, want)
169+ }
170+}
171+
172+func TestLimitSubagentActivityLinesSummarizesOmittedWorkers(t *testing.T) {
173+ lines := []string{"one", "two", "three", "four"}
174+ got := plainActivityLines(limitSubagentActivityLines(tui.Dark, lines, 3, 80))
175+ want := []string{"one", "two", " … 2 more active subagents"}
176+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
177+ t.Fatalf("limited lines = %#v, want %#v", got, want)
178+ }
179+}
180+
181+func TestSubagentActivityRowsFitFixedRightBarFrame(t *testing.T) {
182+ release := make(chan struct{})
183+ supervisor := subagents.New(subagents.Config{
184+ Root: t.TempDir(),
185+ RepoRoot: t.TempDir(),
186+ NewRunner: func(*subagents.Agent) subagents.Runner {
187+ return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
188+ sink.Activity("thinking")
189+ select {
190+ case <-release:
191+ return nil
192+ case <-ctx.Done():
193+ return ctx.Err()
194+ }
195+ })
196+ },
197+ })
198+ defer func() {
199+ close(release)
200+ supervisor.StopAll()
201+ }()
202+ for n := 0; n < 7; n++ {
203+ if _, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
204+ Task: "inspect layout",
205+ Subagent: "reviewer",
206+ }); err != nil {
207+ t.Fatal(err)
208+ }
209+ }
210+
211+ term := &sizedSubagentIndicatorTerminal{
212+ alertTestTerminal: &alertTestTerminal{},
213+ cols: 80,
214+ rows: 8,
215+ }
216+ interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
217+ interactive.rend.Resize(80, 8)
218+ interactive.SetWidget("test", "right", extproto.WidgetPositionRightBar, "status", nil)
219+ interactive.redraw()
220+
221+ output := stripANSIBytes(term.String())
222+ if !strings.Contains(output, "▌") {
223+ t.Fatalf("input was pushed out of the fixed frame: %q", output)
224+ }
225+ if !strings.Contains(output, "more active subagents") {
226+ t.Fatalf("omitted subagents were not summarized: %q", output)
227+ }
228+ if strings.Contains(term.String(), "\x1b[-") {
229+ t.Fatalf("renderer received a negative cursor row: %q", term.String())
230+ }
231+}
232+
233+func TestSubagentActivityLinesFollowInputPosition(t *testing.T) {
234+ started := make(chan struct{})
235+ release := make(chan struct{})
236+ var releaseOnce sync.Once
237+ releaseRunner := func() { releaseOnce.Do(func() { close(release) }) }
238+ supervisor := subagents.New(subagents.Config{
239+ Root: t.TempDir(),
240+ RepoRoot: t.TempDir(),
241+ NewRunner: func(*subagents.Agent) subagents.Runner {
242+ return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
243+ sink.Activity("thinking")
244+ close(started)
245+ select {
246+ case <-release:
247+ return nil
248+ case <-ctx.Done():
249+ return ctx.Err()
250+ }
251+ })
252+ },
253+ })
254+ defer func() {
255+ releaseRunner()
256+ supervisor.StopAll()
257+ }()
258+ agent, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
259+ Task: "inspect the input layout",
260+ Subagent: "senior-engineer",
261+ })
262+ if err != nil {
263+ t.Fatal(err)
264+ }
265+ select {
266+ case <-started:
267+ case <-time.After(time.Second):
268+ t.Fatal("subagent runner did not start")
269+ }
270+
271+ assertPosition := func(t *testing.T, position string, wantBelow bool) {
272+ t.Helper()
273+ term := &alertTestTerminal{}
274+ interactive := NewInteractive(InteractiveConfig{
275+ Terminal: term,
276+ Theme: tui.Dark,
277+ Supervisor: supervisor,
278+ TUISubagentPosition: position,
279+ })
280+ interactive.clock = func() time.Time { return time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) }
281+ interactive.rend.Resize(80, 24)
282+ interactive.redraw()
283+
284+ output := stripANSIBytes(term.String())
285+ inputAt := strings.LastIndex(output, "▌")
286+ subagentAt := strings.LastIndex(output, "senior-engineer")
287+ if inputAt < 0 || subagentAt < 0 {
288+ t.Fatalf("rendered input/subagent missing from %q", output)
289+ }
290+ if gotBelow := subagentAt > inputAt; gotBelow != wantBelow {
291+ t.Fatalf("subagent below input = %v, want %v: %q", gotBelow, wantBelow, output)
292+ }
293+ }
294+
295+ t.Run("default below", func(t *testing.T) {
296+ assertPosition(t, "", true)
297+ })
298+ t.Run("configured above", func(t *testing.T) {
299+ assertPosition(t, tui.SubagentPositionAboveInput, false)
300+ })
301+ t.Run("dashboard editor suppresses inline animation", func(t *testing.T) {
302+ term := &alertTestTerminal{}
303+ interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
304+ interactive.rend.Resize(80, 24)
305+ interactive.subagentsDialog.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "")
306+ interactive.subagentsDialog.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
307+ if interactive.subagentsDialog.NeedsTickRefresh() {
308+ t.Fatal("subagent spawn editor should suppress dashboard ticks")
309+ }
310+ interactive.redraw()
311+ if interactive.subagentActivityActive {
312+ t.Fatal("hidden inline indicators should not request animation ticks")
313+ }
314+ })
315+
316+ releaseRunner()
317+ agent.Wait()
318+ term := &alertTestTerminal{}
319+ interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
320+ interactive.rend.Resize(80, 24)
321+ interactive.redraw()
322+ if output := stripANSIBytes(term.String()); strings.Contains(output, "senior-engineer") {
323+ t.Fatalf("finished subagent indicator remained visible: %q", output)
324+ }
325+}
326+
327+func TestSetSubagentSessionScopeRefreshesActivityFilter(t *testing.T) {
328+ release := make(chan struct{})
329+ supervisor := subagents.New(subagents.Config{
330+ Root: t.TempDir(),
331+ RepoRoot: t.TempDir(),
332+ NewRunner: func(*subagents.Agent) subagents.Runner {
333+ return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
334+ select {
335+ case <-release:
336+ return nil
337+ case <-ctx.Done():
338+ return ctx.Err()
339+ }
340+ })
341+ },
342+ })
343+ defer func() {
344+ close(release)
345+ supervisor.StopAll()
346+ }()
347+ supervisor.SetActiveSession("session-a")
348+ if _, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
349+ Task: "inspect scope",
350+ Subagent: "reviewer",
351+ }); err != nil {
352+ t.Fatal(err)
353+ }
354+
355+ interactive := NewInteractive(InteractiveConfig{Supervisor: supervisor})
356+ if got := len(interactive.activeSubagentActivitySnapshots()); got != 1 {
357+ t.Fatalf("active rows in initial scope = %d, want 1", got)
358+ }
359+ interactive.SetSubagentSessionScope("session-b")
360+ if got := supervisor.ActiveSession(); got != "session-b" {
361+ t.Fatalf("active session = %q, want session-b", got)
362+ }
363+ select {
364+ case <-interactive.dirty:
365+ case <-time.After(time.Second):
366+ t.Fatal("session scope change did not invalidate the interactive view")
367+ }
368+ if got := len(interactive.activeSubagentActivitySnapshots()); got != 0 {
369+ t.Fatalf("active rows after scope change = %d, want 0", got)
370+ }
371+
372+ interactive.SetSubagentSessionScope("session-a")
373+ if got := len(interactive.activeSubagentActivitySnapshots()); got != 1 {
374+ t.Fatalf("active rows after returning to scope = %d, want 1", got)
375+ }
376+}
377+
378+func plainActivityLines(lines []string) []string {
379+ plain := make([]string, len(lines))
380+ for idx, line := range lines {
381+ plain[idx] = stripANSIBytes(line)
382+ }
383+ return plain
384+}
@@ -0,0 +1,384 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "testing"
8+ "time"
9+ "unicode"
10+
11+ "github.com/bnema/zut/packages/agent/extproto"
12+ "github.com/bnema/zut/packages/agent/subagents"
13+ "github.com/bnema/zut/packages/tui"
14+ "github.com/mattn/go-runewidth"
15+)
16+
17+type sizedSubagentIndicatorTerminal struct {
18+ *alertTestTerminal
19+ cols int
20+ rows int
21+}
22+
23+func (t *sizedSubagentIndicatorTerminal) Size() (int, int) { return t.cols, t.rows }
24+
25+func TestRenderSubagentActivityLinesShowsOnlyActiveTurns(t *testing.T) {
26+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
27+ lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{
28+ {
29+ ID: "design-123",
30+ Subagent: "senior-engineer",
31+ Status: subagents.StatusPending,
32+ ProcessState: subagents.ProcessPending,
33+ TurnState: subagents.TurnQueued,
34+ Activity: "queued",
35+ LastActivity: now.Add(-4 * time.Second),
36+ },
37+ {
38+ ID: "tests-456",
39+ Subagent: "",
40+ Status: subagents.StatusRunning,
41+ ProcessState: subagents.ProcessAlive,
42+ TurnState: subagents.TurnRunning,
43+ Activity: "tool: read",
44+ LastActivity: now.Add(-72 * time.Second),
45+ },
46+ {
47+ ID: "heartbeat-789",
48+ Subagent: "implementer",
49+ Status: subagents.StatusRunning,
50+ ProcessState: subagents.ProcessAlive,
51+ TurnState: subagents.TurnRunning,
52+ Activity: "idle",
53+ LastActivity: now.Add(-5 * time.Second),
54+ },
55+ {
56+ ID: "idle-789",
57+ Subagent: "reviewer",
58+ Status: subagents.StatusRunning,
59+ ProcessState: subagents.ProcessAlive,
60+ TurnState: subagents.TurnIdle,
61+ Activity: "idle",
62+ LastActivity: now.Add(-time.Second),
63+ },
64+ {
65+ ID: "settled-012",
66+ Subagent: "implementer",
67+ Status: subagents.StatusPending,
68+ ProcessState: subagents.ProcessPending,
69+ TurnState: subagents.TurnSucceeded,
70+ Activity: "done",
71+ LastActivity: now.Add(-time.Second),
72+ },
73+ {
74+ ID: "done-345",
75+ Subagent: "implementer",
76+ Status: subagents.StatusDone,
77+ ProcessState: subagents.ProcessExited,
78+ TurnState: subagents.TurnSucceeded,
79+ Activity: "done",
80+ LastActivity: now.Add(-time.Second),
81+ },
82+ {
83+ ID: "detached-678",
84+ Subagent: "reviewer",
85+ Status: subagents.StatusRunning,
86+ ProcessState: subagents.ProcessDetached,
87+ TurnState: subagents.TurnRunning,
88+ Activity: "detached",
89+ LastActivity: now.Add(-time.Second),
90+ },
91+ }, 80, now)
92+
93+ got := plainActivityLines(lines)
94+ want := []string{
95+ " / senior-engineer · queued · 4s",
96+ " / tests-456 · tool: read · 1m12s",
97+ " / implementer · working · 5s",
98+ }
99+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
100+ t.Fatalf("activity lines = %#v, want %#v", got, want)
101+ }
102+}
103+
104+func TestRenderSubagentActivityLinesKeepsNameAndAgeOnNarrowTerminals(t *testing.T) {
105+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
106+ const width = 27
107+ lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{
108+ ID: "review-123",
109+ Subagent: "reviewer",
110+ Status: subagents.StatusRunning,
111+ ProcessState: subagents.ProcessAlive,
112+ TurnState: subagents.TurnRunning,
113+ Activity: "tool: read an exceptionally long filename",
114+ LastActivity: now.Add(-72 * time.Second),
115+ }}, width, now)
116+
117+ if len(lines) != 1 {
118+ t.Fatalf("line count = %d, want 1", len(lines))
119+ }
120+ line := plainActivityLines(lines)[0]
121+ if runewidth.StringWidth(line) > width {
122+ t.Fatalf("line width = %d, want <= %d: %q", runewidth.StringWidth(line), width, line)
123+ }
124+ for _, want := range []string{"reviewer", "1m12s"} {
125+ if !strings.Contains(line, want) {
126+ t.Fatalf("narrow line %q is missing %q", line, want)
127+ }
128+ }
129+}
130+
131+func TestRenderSubagentActivityLinesSanitizesWorkerText(t *testing.T) {
132+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
133+ lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{
134+ ID: "review-123",
135+ Subagent: "reviewer\x1b]52;c;ignored\a",
136+ Status: subagents.StatusRunning,
137+ ProcessState: subagents.ProcessAlive,
138+ TurnState: subagents.TurnRunning,
139+ Activity: "tool:\tread\x1b[2J\rfile",
140+ LastActivity: now.Add(-time.Second),
141+ }}, 80, now)
142+ if len(lines) != 1 {
143+ t.Fatalf("line count = %d, want 1", len(lines))
144+ }
145+ plain := plainActivityLines(lines)[0]
146+ if strings.IndexFunc(plain, unicode.IsControl) >= 0 {
147+ t.Fatalf("indicator retained a control character: %q", plain)
148+ }
149+ for _, want := range []string{"reviewer", "tool: read file"} {
150+ if !strings.Contains(plain, want) {
151+ t.Fatalf("sanitized indicator %q is missing %q", plain, want)
152+ }
153+ }
154+}
155+
156+func TestTruncateSubagentIndicatorTextPreservesGraphemeClusters(t *testing.T) {
157+ if got, want := truncateSubagentIndicatorText("a👩<U+200D>💻abcdef", 6), "a👩<U+200D>💻..."; got != want {
158+ t.Fatalf("truncated grapheme = %q, want %q", got, want)
159+ }
160+}
161+
162+func TestFormatSubagentActivityAgeFallsBackToLifecycleTimes(t *testing.T) {
163+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
164+ if got, want := formatSubagentActivityAge(subagents.AgentSnapshot{UpdatedAt: now.Add(-7 * time.Second)}, now), "7s"; got != want {
165+ t.Fatalf("updated-at age = %q, want %q", got, want)
166+ }
167+ if got, want := formatSubagentActivityAge(subagents.AgentSnapshot{Started: now.Add(-8 * time.Second)}, now), "8s"; got != want {
168+ t.Fatalf("started-at age = %q, want %q", got, want)
169+ }
170+}
171+
172+func TestLimitSubagentActivityLinesSummarizesOmittedWorkers(t *testing.T) {
173+ lines := []string{"one", "two", "three", "four"}
174+ got := plainActivityLines(limitSubagentActivityLines(tui.Dark, lines, 3, 80))
175+ want := []string{"one", "two", " … 2 more active subagents"}
176+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
177+ t.Fatalf("limited lines = %#v, want %#v", got, want)
178+ }
179+}
180+
181+func TestSubagentActivityRowsFitFixedRightBarFrame(t *testing.T) {
182+ release := make(chan struct{})
183+ supervisor := subagents.New(subagents.Config{
184+ Root: t.TempDir(),
185+ RepoRoot: t.TempDir(),
186+ NewRunner: func(*subagents.Agent) subagents.Runner {
187+ return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
188+ sink.Activity("thinking")
189+ select {
190+ case <-release:
191+ return nil
192+ case <-ctx.Done():
193+ return ctx.Err()
194+ }
195+ })
196+ },
197+ })
198+ defer func() {
199+ close(release)
200+ supervisor.StopAll()
201+ }()
202+ for n := 0; n < 7; n++ {
203+ if _, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
204+ Task: "inspect layout",
205+ Subagent: "reviewer",
206+ }); err != nil {
207+ t.Fatal(err)
208+ }
209+ }
210+
211+ term := &sizedSubagentIndicatorTerminal{
212+ alertTestTerminal: &alertTestTerminal{},
213+ cols: 80,
214+ rows: 8,
215+ }
216+ interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
217+ interactive.rend.Resize(80, 8)
218+ interactive.SetWidget("test", "right", extproto.WidgetPositionRightBar, "status", nil)
219+ interactive.redraw()
220+
221+ output := stripANSIBytes(term.String())
222+ if !strings.Contains(output, "▌") {
223+ t.Fatalf("input was pushed out of the fixed frame: %q", output)
224+ }
225+ if !strings.Contains(output, "more active subagents") {
226+ t.Fatalf("omitted subagents were not summarized: %q", output)
227+ }
228+ if strings.Contains(term.String(), "\x1b[-") {
229+ t.Fatalf("renderer received a negative cursor row: %q", term.String())
230+ }
231+}
232+
233+func TestSubagentActivityLinesFollowInputPosition(t *testing.T) {
234+ started := make(chan struct{})
235+ release := make(chan struct{})
236+ var releaseOnce sync.Once
237+ releaseRunner := func() { releaseOnce.Do(func() { close(release) }) }
238+ supervisor := subagents.New(subagents.Config{
239+ Root: t.TempDir(),
240+ RepoRoot: t.TempDir(),
241+ NewRunner: func(*subagents.Agent) subagents.Runner {
242+ return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
243+ sink.Activity("thinking")
244+ close(started)
245+ select {
246+ case <-release:
247+ return nil
248+ case <-ctx.Done():
249+ return ctx.Err()
250+ }
251+ })
252+ },
253+ })
254+ defer func() {
255+ releaseRunner()
256+ supervisor.StopAll()
257+ }()
258+ agent, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
259+ Task: "inspect the input layout",
260+ Subagent: "senior-engineer",
261+ })
262+ if err != nil {
263+ t.Fatal(err)
264+ }
265+ select {
266+ case <-started:
267+ case <-time.After(time.Second):
268+ t.Fatal("subagent runner did not start")
269+ }
270+
271+ assertPosition := func(t *testing.T, position string, wantBelow bool) {
272+ t.Helper()
273+ term := &alertTestTerminal{}
274+ interactive := NewInteractive(InteractiveConfig{
275+ Terminal: term,
276+ Theme: tui.Dark,
277+ Supervisor: supervisor,
278+ TUISubagentPosition: position,
279+ })
280+ interactive.clock = func() time.Time { return time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) }
281+ interactive.rend.Resize(80, 24)
282+ interactive.redraw()
283+
284+ output := stripANSIBytes(term.String())
285+ inputAt := strings.LastIndex(output, "▌")
286+ subagentAt := strings.LastIndex(output, "senior-engineer")
287+ if inputAt < 0 || subagentAt < 0 {
288+ t.Fatalf("rendered input/subagent missing from %q", output)
289+ }
290+ if gotBelow := subagentAt > inputAt; gotBelow != wantBelow {
291+ t.Fatalf("subagent below input = %v, want %v: %q", gotBelow, wantBelow, output)
292+ }
293+ }
294+
295+ t.Run("default below", func(t *testing.T) {
296+ assertPosition(t, "", true)
297+ })
298+ t.Run("configured above", func(t *testing.T) {
299+ assertPosition(t, tui.SubagentPositionAboveInput, false)
300+ })
301+ t.Run("dashboard editor suppresses inline animation", func(t *testing.T) {
302+ term := &alertTestTerminal{}
303+ interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
304+ interactive.rend.Resize(80, 24)
305+ interactive.subagentsDialog.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "")
306+ interactive.subagentsDialog.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
307+ if interactive.subagentsDialog.NeedsTickRefresh() {
308+ t.Fatal("subagent spawn editor should suppress dashboard ticks")
309+ }
310+ interactive.redraw()
311+ if interactive.subagentActivityActive {
312+ t.Fatal("hidden inline indicators should not request animation ticks")
313+ }
314+ })
315+
316+ releaseRunner()
317+ agent.Wait()
318+ term := &alertTestTerminal{}
319+ interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
320+ interactive.rend.Resize(80, 24)
321+ interactive.redraw()
322+ if output := stripANSIBytes(term.String()); strings.Contains(output, "senior-engineer") {
323+ t.Fatalf("finished subagent indicator remained visible: %q", output)
324+ }
325+}
326+
327+func TestSetSubagentSessionScopeRefreshesActivityFilter(t *testing.T) {
328+ release := make(chan struct{})
329+ supervisor := subagents.New(subagents.Config{
330+ Root: t.TempDir(),
331+ RepoRoot: t.TempDir(),
332+ NewRunner: func(*subagents.Agent) subagents.Runner {
333+ return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
334+ select {
335+ case <-release:
336+ return nil
337+ case <-ctx.Done():
338+ return ctx.Err()
339+ }
340+ })
341+ },
342+ })
343+ defer func() {
344+ close(release)
345+ supervisor.StopAll()
346+ }()
347+ supervisor.SetActiveSession("session-a")
348+ if _, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
349+ Task: "inspect scope",
350+ Subagent: "reviewer",
351+ }); err != nil {
352+ t.Fatal(err)
353+ }
354+
355+ interactive := NewInteractive(InteractiveConfig{Supervisor: supervisor})
356+ if got := len(interactive.activeSubagentActivitySnapshots()); got != 1 {
357+ t.Fatalf("active rows in initial scope = %d, want 1", got)
358+ }
359+ interactive.SetSubagentSessionScope("session-b")
360+ if got := supervisor.ActiveSession(); got != "session-b" {
361+ t.Fatalf("active session = %q, want session-b", got)
362+ }
363+ select {
364+ case <-interactive.dirty:
365+ case <-time.After(time.Second):
366+ t.Fatal("session scope change did not invalidate the interactive view")
367+ }
368+ if got := len(interactive.activeSubagentActivitySnapshots()); got != 0 {
369+ t.Fatalf("active rows after scope change = %d, want 0", got)
370+ }
371+
372+ interactive.SetSubagentSessionScope("session-a")
373+ if got := len(interactive.activeSubagentActivitySnapshots()); got != 1 {
374+ t.Fatalf("active rows after returning to scope = %d, want 1", got)
375+ }
376+}
377+
378+func plainActivityLines(lines []string) []string {
379+ plain := make([]string, len(lines))
380+ for idx, line := range lines {
381+ plain[idx] = stripANSIBytes(line)
382+ }
383+ return plain
384+}
M packages/agent/settings_store.go +14 -0
@@ -205,6 +205,20 @@ func (configSettingsStore) SetTUIWorkingPosition(position string) error {
205205 return SaveConfig(cfg)
206206 }
207207
208+func (configSettingsStore) SetTUISubagentPosition(position string) error {
209+ cfg, err := LoadConfig()
210+ if err != nil {
211+ return err
212+ }
213+ position = tui.NormalizeSubagentPosition(position)
214+ if position == tui.SubagentPositionBelowInput {
215+ cfg.TUISubagentPosition = ""
216+ } else {
217+ cfg.TUISubagentPosition = position
218+ }
219+ return SaveConfig(cfg)
220+}
221+
208222 func (configSettingsStore) SetReasoning(level string) error {
209223 cfg, err := LoadConfig()
210224 if err != nil {
@@ -205,6 +205,20 @@ func (configSettingsStore) SetTUIWorkingPosition(position string) error {
205 return SaveConfig(cfg)205 return SaveConfig(cfg)
206 }206 }
207 207
208+func (configSettingsStore) SetTUISubagentPosition(position string) error {
209+ cfg, err := LoadConfig()
210+ if err != nil {
211+ return err
212+ }
213+ position = tui.NormalizeSubagentPosition(position)
214+ if position == tui.SubagentPositionBelowInput {
215+ cfg.TUISubagentPosition = ""
216+ } else {
217+ cfg.TUISubagentPosition = position
218+ }
219+ return SaveConfig(cfg)
220+}
221+
208 func (configSettingsStore) SetReasoning(level string) error {222 func (configSettingsStore) SetReasoning(level string) error {
209 cfg, err := LoadConfig()223 cfg, err := LoadConfig()
210 if err != nil {224 if err != nil {
M packages/agent/settings_store_test.go +32 -0
@@ -162,6 +162,38 @@ func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) {
162162 }
163163 }
164164
165+func TestConfigSettingsStorePersistsSubagentPosition(t *testing.T) {
166+ t.Setenv("ZUT_HOME", t.TempDir())
167+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
168+ t.Fatal(err)
169+ }
170+
171+ if err := (configSettingsStore{}).SetTUISubagentPosition("above_input"); err != nil {
172+ t.Fatal(err)
173+ }
174+ cfg, err := LoadConfig()
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ if cfg.TUISubagentPosition != "above_input" {
179+ t.Fatalf("tui_subagent_position = %q, want above_input", cfg.TUISubagentPosition)
180+ }
181+ if cfg.Theme != "dark" {
182+ t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
183+ }
184+
185+ if err := (configSettingsStore{}).SetTUISubagentPosition("below_input"); err != nil {
186+ t.Fatal(err)
187+ }
188+ cfg, err = LoadConfig()
189+ if err != nil {
190+ t.Fatal(err)
191+ }
192+ if cfg.TUISubagentPosition != "" {
193+ t.Fatalf("default tui_subagent_position = %q, want omitted", cfg.TUISubagentPosition)
194+ }
195+}
196+
165197 func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) {
166198 t.Setenv("ZUT_HOME", t.TempDir())
167199 if err := SaveConfig(Config{Theme: "dark"}); err != nil {
@@ -162,6 +162,38 @@ func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) {
162 }162 }
163 }163 }
164 164
165+func TestConfigSettingsStorePersistsSubagentPosition(t *testing.T) {
166+ t.Setenv("ZUT_HOME", t.TempDir())
167+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
168+ t.Fatal(err)
169+ }
170+
171+ if err := (configSettingsStore{}).SetTUISubagentPosition("above_input"); err != nil {
172+ t.Fatal(err)
173+ }
174+ cfg, err := LoadConfig()
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ if cfg.TUISubagentPosition != "above_input" {
179+ t.Fatalf("tui_subagent_position = %q, want above_input", cfg.TUISubagentPosition)
180+ }
181+ if cfg.Theme != "dark" {
182+ t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
183+ }
184+
185+ if err := (configSettingsStore{}).SetTUISubagentPosition("below_input"); err != nil {
186+ t.Fatal(err)
187+ }
188+ cfg, err = LoadConfig()
189+ if err != nil {
190+ t.Fatal(err)
191+ }
192+ if cfg.TUISubagentPosition != "" {
193+ t.Fatalf("default tui_subagent_position = %q, want omitted", cfg.TUISubagentPosition)
194+ }
195+}
196+
165 func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) {197 func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) {
166 t.Setenv("ZUT_HOME", t.TempDir())198 t.Setenv("ZUT_HOME", t.TempDir())
167 if err := SaveConfig(Config{Theme: "dark"}); err != nil {199 if err := SaveConfig(Config{Theme: "dark"}); err != nil {
M packages/agent/subagents/agent.go +7 -0
@@ -223,6 +223,13 @@ func (a *Agent) LastActivity() time.Time {
223223 return a.lastActivity
224224 }
225225
226+// UpdatedAt returns the last lifecycle-state update time.
227+func (a *Agent) UpdatedAt() time.Time {
228+ a.lifecycleMu.Lock()
229+ defer a.lifecycleMu.Unlock()
230+ return a.updatedAt
231+}
232+
226233 // Result returns a copy of the most recent structured turn result.
227234 func (a *Agent) Result() *TurnResult {
228235 a.lifecycleMu.Lock()
@@ -223,6 +223,13 @@ func (a *Agent) LastActivity() time.Time {
223 return a.lastActivity223 return a.lastActivity
224 }224 }
225 225
226+// UpdatedAt returns the last lifecycle-state update time.
227+func (a *Agent) UpdatedAt() time.Time {
228+ a.lifecycleMu.Lock()
229+ defer a.lifecycleMu.Unlock()
230+ return a.updatedAt
231+}
232+
226 // Result returns a copy of the most recent structured turn result.233 // Result returns a copy of the most recent structured turn result.
227 func (a *Agent) Result() *TurnResult {234 func (a *Agent) Result() *TurnResult {
228 a.lifecycleMu.Lock()235 a.lifecycleMu.Lock()
M packages/tui/layout.go +14 -0
@@ -12,6 +12,9 @@ const (
1212
1313 WorkingPositionAboveInput = "above_input"
1414 WorkingPositionBelowInput = "below_input"
15+
16+ SubagentPositionAboveInput = "above_input"
17+ SubagentPositionBelowInput = "below_input"
1518 )
1619
1720 func NormalizeInputStyle(v string) string {
@@ -43,6 +46,17 @@ func NormalizeWorkingPosition(v string) string {
4346 }
4447 }
4548
49+// NormalizeSubagentPosition returns the placement for live subagent activity.
50+// A missing value keeps activity immediately below the input by default.
51+func NormalizeSubagentPosition(v string) string {
52+ switch strings.ToLower(strings.TrimSpace(v)) {
53+ case SubagentPositionAboveInput, "above", "top":
54+ return SubagentPositionAboveInput
55+ default:
56+ return SubagentPositionBelowInput
57+ }
58+}
59+
4660 func InputLines(th Theme, lines []string, width int) []string {
4761 if width < 1 {
4862 width = 1
@@ -12,6 +12,9 @@ const (
12 12
13 WorkingPositionAboveInput = "above_input"13 WorkingPositionAboveInput = "above_input"
14 WorkingPositionBelowInput = "below_input"14 WorkingPositionBelowInput = "below_input"
15+
16+ SubagentPositionAboveInput = "above_input"
17+ SubagentPositionBelowInput = "below_input"
15 )18 )
16 19
17 func NormalizeInputStyle(v string) string {20 func NormalizeInputStyle(v string) string {
@@ -43,6 +46,17 @@ func NormalizeWorkingPosition(v string) string {
43 }46 }
44 }47 }
45 48
49+// NormalizeSubagentPosition returns the placement for live subagent activity.
50+// A missing value keeps activity immediately below the input by default.
51+func NormalizeSubagentPosition(v string) string {
52+ switch strings.ToLower(strings.TrimSpace(v)) {
53+ case SubagentPositionAboveInput, "above", "top":
54+ return SubagentPositionAboveInput
55+ default:
56+ return SubagentPositionBelowInput
57+ }
58+}
59+
46 func InputLines(th Theme, lines []string, width int) []string {60 func InputLines(th Theme, lines []string, width int) []string {
47 if width < 1 {61 if width < 1 {
48 width = 162 width = 1
A packages/tui/layout_test.go +14 -0
@@ -0,0 +1,14 @@
1+package tui
2+
3+import "testing"
4+
5+func TestNormalizeSubagentPositionDefaultsBelowInput(t *testing.T) {
6+ for _, value := range []string{"", "below", "bottom", "below_input", "unexpected"} {
7+ if got := NormalizeSubagentPosition(value); got != SubagentPositionBelowInput {
8+ t.Fatalf("NormalizeSubagentPosition(%q) = %q, want %q", value, got, SubagentPositionBelowInput)
9+ }
10+ }
11+ if got := NormalizeSubagentPosition("above_input"); got != SubagentPositionAboveInput {
12+ t.Fatalf("NormalizeSubagentPosition(above_input) = %q, want %q", got, SubagentPositionAboveInput)
13+ }
14+}
@@ -0,0 +1,14 @@
1+package tui
2+
3+import "testing"
4+
5+func TestNormalizeSubagentPositionDefaultsBelowInput(t *testing.T) {
6+ for _, value := range []string{"", "below", "bottom", "below_input", "unexpected"} {
7+ if got := NormalizeSubagentPosition(value); got != SubagentPositionBelowInput {
8+ t.Fatalf("NormalizeSubagentPosition(%q) = %q, want %q", value, got, SubagentPositionBelowInput)
9+ }
10+ }
11+ if got := NormalizeSubagentPosition("above_input"); got != SubagentPositionAboveInput {
12+ t.Fatalf("NormalizeSubagentPosition(above_input) = %q, want %q", got, SubagentPositionAboveInput)
13+ }
14+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close