detached eddc906

feat(tui): show live subagent activity

commit
eddc90651c62c95f7f3b37541c65464e518a1b62
author
brice · · 2026-08-06 07:51 UTC
parent
42a5079
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +945 -48

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
@@ -1827,7 +1827,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
18271827 if subagentsMgr != nil && candidate.session != nil {
18281828 // Keep the dashboard scope in the same commit as the session,
18291829 // agent, usage, and persistence baseline.
1830- subagentsMgr.SetActiveSession(candidate.session.ID)
1830+ if iv != nil {
1831+ iv.SetSubagentSessionScope(candidate.session.ID)
1832+ } else {
1833+ subagentsMgr.SetActiveSession(candidate.session.ID)
1834+ }
18311835 }
18321836 committed = true
18331837 persistMu.Unlock()
@@ -2010,7 +2014,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
20102014
20112015 // Re-scope the subagent dashboard to the new session.
20122016 if subagentsMgr != nil && newSess != nil {
2013- subagentsMgr.SetActiveSession(newSess.ID)
2017+ if iv != nil {
2018+ iv.SetSubagentSessionScope(newSess.ID)
2019+ } else {
2020+ subagentsMgr.SetActiveSession(newSess.ID)
2021+ }
20142022 }
20152023 if newSess != nil {
20162024 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
@@ -2132,6 +2140,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
21322140 TUIInputStyle: initialCfg.TUIInputStyle,
21332141 TUIStatusPosition: initialCfg.TUIStatusPosition,
21342142 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2143+ TUISubagentPosition: initialCfg.TUISubagentPosition,
21352144 ThemeName: initialCfg.Theme,
21362145 FlatTools: initialCfg.FlatToolRender(),
21372146 CompactUser: initialCfg.CompactUserInput(),
@@ -1827,7 +1827,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1827 if subagentsMgr != nil && candidate.session != nil {1827 if subagentsMgr != nil && candidate.session != nil {
1828 // Keep the dashboard scope in the same commit as the session,1828 // Keep the dashboard scope in the same commit as the session,
1829 // agent, usage, and persistence baseline.1829 // agent, usage, and persistence baseline.
1830- subagentsMgr.SetActiveSession(candidate.session.ID)1830+ if iv != nil {
1831+ iv.SetSubagentSessionScope(candidate.session.ID)
1832+ } else {
1833+ subagentsMgr.SetActiveSession(candidate.session.ID)
1834+ }
1831 }1835 }
1832 committed = true1836 committed = true
1833 persistMu.Unlock()1837 persistMu.Unlock()
@@ -2010,7 +2014,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2010 2014
2011 // Re-scope the subagent dashboard to the new session.2015 // Re-scope the subagent dashboard to the new session.
2012 if subagentsMgr != nil && newSess != nil {2016 if subagentsMgr != nil && newSess != nil {
2013- subagentsMgr.SetActiveSession(newSess.ID)2017+ if iv != nil {
2018+ iv.SetSubagentSessionScope(newSess.ID)
2019+ } else {
2020+ subagentsMgr.SetActiveSession(newSess.ID)
2021+ }
2014 }2022 }
2015 if newSess != nil {2023 if newSess != nil {
2016 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)2024 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
@@ -2132,6 +2140,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2132 TUIInputStyle: initialCfg.TUIInputStyle,2140 TUIInputStyle: initialCfg.TUIInputStyle,
2133 TUIStatusPosition: initialCfg.TUIStatusPosition,2141 TUIStatusPosition: initialCfg.TUIStatusPosition,
2134 TUIWorkingPosition: initialCfg.TUIWorkingPosition,2142 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2143+ TUISubagentPosition: initialCfg.TUISubagentPosition,
2135 ThemeName: initialCfg.Theme,2144 ThemeName: initialCfg.Theme,
2136 FlatTools: initialCfg.FlatToolRender(),2145 FlatTools: initialCfg.FlatToolRender(),
2137 CompactUser: initialCfg.CompactUserInput(),2146 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 +161 -45
@@ -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,26 +517,27 @@ 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- pendingIdleWork []func()
528- dirty chan struct{}
529- modelRefresh chan modelRefreshResult
530- modelRefreshing bool
531- startupPreDone chan startupPreResult
532- cancelTurn context.CancelFunc
533- scrollOffset int // rows from the bottom; 0 = pinned to latest
534- 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+ subagentActivityActive bool
533+ pendingIdleWork []func()
534+ dirty chan struct{}
535+ modelRefresh chan modelRefreshResult
536+ modelRefreshing bool
537+ startupPreDone chan startupPreResult
538+ cancelTurn context.CancelFunc
539+ scrollOffset int // rows from the bottom; 0 = pinned to latest
540+ prevScrollOffset int // last value redraw snapped against; tracks intent
535541
536542 // prevChatLen and prevChatCols track the chat buffer's size at the
537543 // last redraw so that when content grows below the user's viewport
@@ -1125,9 +1131,10 @@ func (i *Interactive) Run(ctx context.Context) error {
11251131 // the same way it does inside btw.
11261132 i.mu.Lock()
11271133 busy := i.busy
1134+ subagentActivityActive := i.subagentActivityActive
11281135 sessionLoading := i.sessionDialog.Loading()
11291136 i.mu.Unlock()
1130- if busy || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
1137+ if busy || subagentActivityActive || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
11311138 requestRedraw()
11321139 }
11331140 }
@@ -1818,6 +1825,18 @@ func (i *Interactive) redraw() {
18181825 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
18191826 }
18201827 edLines, curR, curC := i.ed.Render(mainCols)
1828+ dashboardActive := i.subagentsDialog.Active()
1829+ var allSubagentLines []string
1830+ if !dashboardActive {
1831+ now := i.clock()
1832+ allSubagentLines = renderSubagentActivityLines(
1833+ i.cfg.Theme,
1834+ i.spin.FrameAt(now),
1835+ i.activeSubagentActivitySnapshots(),
1836+ mainCols,
1837+ now,
1838+ )
1839+ }
18211840 var workingLines []string
18221841 if busyPrefix != "" && !workingWithStatus {
18231842 workingLines = []string{" " + busyPrefix}
@@ -1865,26 +1884,31 @@ func (i *Interactive) redraw() {
18651884 // content. The status block and editor get their own dedicated
18661885 // blanks so spacing stays consistent whether or not a dialog or
18671886 // popup is showing.
1868- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(edLines)+9)
1869- inputStartRow := -1
1870- if len(dialog) > 0 {
1871- bottom = append(bottom, "")
1872- }
1873- bottom = append(bottom, dialog...)
1874- // The subagent dashboard owns the bottom of the screen while it's
1875- // active: it has its own inline editors for spawn (`n`) and
1876- // prompt (`p`), so the main input would be a confusing second
1877- // caret. The suggest popup, sliding-in queue, status block, and
1878- // main editor are all hidden underneath it. Keystrokes still
1879- // reach handleKey — it routes them to subagentsDialog.HandleKey
1880- // before the editor ever sees them — so the only effect of this
1881- // branch is visual.
1882- if !i.subagentsDialog.Active() {
1887+ composeBottom := func(subagentLines []string) (bottom []string, inputStartRow int) {
1888+ bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(subagentLines)+len(edLines)+9)
1889+ inputStartRow = -1
1890+ if len(dialog) > 0 {
1891+ bottom = append(bottom, "")
1892+ }
1893+ bottom = append(bottom, dialog...)
1894+ // The subagent dashboard owns the bottom of the screen while it's
1895+ // active: it has its own inline editors for spawn (`n`) and
1896+ // prompt (`p`), so the main input would be a confusing second
1897+ // caret. The suggest popup, sliding-in queue, status block, and
1898+ // main editor are all hidden underneath it. Keystrokes still
1899+ // reach handleKey — it routes them to subagentsDialog.HandleKey
1900+ // before the editor ever sees them — so the only effect of this
1901+ // branch is visual.
1902+ if dashboardActive {
1903+ return bottom, inputStartRow
1904+ }
1905+
18831906 bottom = append(bottom, suggest...)
18841907 bottom = append(bottom, queue...)
18851908 lineInput := inputStyle == tui.InputStyleLines
18861909 statusBelow := statusPosition == tui.StatusPositionBelowInput
18871910 workingBelow := workingPosition == tui.WorkingPositionBelowInput
1911+ subagentBelow := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition) == tui.SubagentPositionBelowInput
18881912
18891913 var aboveInput []string
18901914 aboveInput = append(aboveInput, extensionLines...)
@@ -1894,7 +1918,13 @@ func (i *Interactive) redraw() {
18941918 if !workingBelow {
18951919 aboveInput = append(aboveInput, workingLines...)
18961920 }
1921+ if !subagentBelow {
1922+ aboveInput = append(aboveInput, subagentLines...)
1923+ }
18971924 var belowInput []string
1925+ if subagentBelow {
1926+ belowInput = append(belowInput, subagentLines...)
1927+ }
18981928 if workingBelow {
18991929 belowInput = append(belowInput, workingLines...)
19001930 }
@@ -1914,12 +1944,36 @@ func (i *Interactive) redraw() {
19141944 inputStartRow = len(bottom)
19151945 bottom = append(bottom, edLines...)
19161946 if len(belowInput) > 0 {
1917- if !lineInput {
1947+ // Running subagents sit immediately under the prompt by default.
1948+ // Other below-input chrome keeps the existing breathing room.
1949+ if !lineInput && !(subagentBelow && len(subagentLines) > 0) {
19181950 bottom = append(bottom, "")
19191951 }
19201952 bottom = append(bottom, belowInput...)
19211953 }
1954+ return bottom, inputStartRow
1955+ }
1956+
1957+ // Preserve one chat row and the renderer-owned bottom margin. This keeps
1958+ // a bounded set of live indicators from pushing the editor or its cursor
1959+ // out of a fixed right-bar frame on short terminals.
1960+ const rendererBottomMarginRows = 1
1961+ maxBottomRows := rows - rendererBottomMarginRows - 1
1962+ subagentLines := allSubagentLines
1963+ bottom, inputStartRow := composeBottom(subagentLines)
1964+ if !dashboardActive && len(allSubagentLines) > 0 {
1965+ for maxRows := len(allSubagentLines); maxRows >= 0; maxRows-- {
1966+ candidate := limitSubagentActivityLines(i.cfg.Theme, allSubagentLines, maxRows, mainCols)
1967+ candidateBottom, candidateInputStartRow := composeBottom(candidate)
1968+ if len(candidateBottom) <= maxBottomRows || maxRows == 0 {
1969+ subagentLines = candidate
1970+ bottom = candidateBottom
1971+ inputStartRow = candidateInputStartRow
1972+ break
1973+ }
1974+ }
19221975 }
1976+ i.subagentActivityActive = !dashboardActive && len(subagentLines) > 0
19231977
19241978 chatRows := rows - len(bottom)
19251979 if rightBarActive {
@@ -3644,6 +3698,18 @@ func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model stri
36443698 i.invalidate()
36453699 }
36463700
3701+// SetSubagentSessionScope changes the live-worker filter and then requests a
3702+// redraw. Session changes must use this boundary after their host/session
3703+// state commits so input-adjacent activity cannot remain scoped to the prior
3704+// session until another UI event arrives.
3705+func (i *Interactive) SetSubagentSessionScope(sessionID string) {
3706+ if i.cfg.Supervisor == nil {
3707+ return
3708+ }
3709+ i.cfg.Supervisor.SetActiveSession(sessionID)
3710+ i.invalidate()
3711+}
3712+
36473713 // ApplyChangedCWD is called by hosts after a successful /cd hook that do
36483714 // not provide startup context metadata.
36493715 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
@@ -4112,6 +4178,7 @@ func (i *Interactive) openSettingsDialog() {
41124178 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
41134179 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
41144180 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
4181+ subagentPosition := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition)
41154182 quickItems := i.quickModelSettingItems()
41164183
41174184 autoCompactOptions := []settingsOption{
@@ -4193,6 +4260,18 @@ func (i *Interactive) openSettingsDialog() {
41934260 }
41944261 }
41954262
4263+ subagentPositionOptions := []settingsOption{
4264+ {value: tui.SubagentPositionAboveInput, label: "above input", desc: "show running subagents above the input"},
4265+ {value: tui.SubagentPositionBelowInput, label: "below input", desc: "show running subagents immediately below the input"},
4266+ }
4267+ subagentPositionChoice := 0
4268+ for idx, opt := range subagentPositionOptions {
4269+ if opt.value == subagentPosition {
4270+ subagentPositionChoice = idx
4271+ break
4272+ }
4273+ }
4274+
41964275 items := []settingsItem{
41974276 {
41984277 key: "inline_images_enabled",
@@ -4289,15 +4368,8 @@ func (i *Interactive) openSettingsDialog() {
42894368 {
42904369 key: "tui_settings",
42914370 label: "tui settings",
4292- desc: "choose input chrome and where status information appears",
4371+ desc: "choose input chrome in its order around the prompt",
42934372 children: []settingsItem{
4294- {
4295- key: "tui_input_style",
4296- label: "input style",
4297- desc: "choose between the plain prompt, lines, and a block input area",
4298- options: inputStyleOptions,
4299- choice: inputStyleChoice,
4300- },
43014373 {
43024374 key: "tui_status_position",
43034375 label: "status position",
@@ -4312,6 +4384,20 @@ func (i *Interactive) openSettingsDialog() {
43124384 options: workingPositionOptions,
43134385 choice: workingPositionChoice,
43144386 },
4387+ {
4388+ key: "tui_input_style",
4389+ label: "input style",
4390+ desc: "choose between the plain prompt, lines, and a block input area",
4391+ options: inputStyleOptions,
4392+ choice: inputStyleChoice,
4393+ },
4394+ {
4395+ key: "tui_subagent_position",
4396+ label: "running subagent position",
4397+ desc: "place live subagent activity above or below the input",
4398+ options: subagentPositionOptions,
4399+ choice: subagentPositionChoice,
4400+ },
43154401 },
43164402 },
43174403 reasoningItem,
@@ -4350,6 +4436,8 @@ func (i *Interactive) applySettingChange(act settingsAction) {
43504436 i.applyTUIStatusPositionSetting(act.StringValue)
43514437 case act.Key == "tui_working_position":
43524438 i.applyTUIWorkingPositionSetting(act.StringValue)
4439+ case act.Key == "tui_subagent_position":
4440+ i.applyTUISubagentPositionSetting(act.StringValue)
43534441 default:
43544442 i.applySettingToggle(act.Key, act.Value)
43554443 }
@@ -4996,6 +5084,33 @@ func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
49965084 i.mu.Unlock()
49975085 }
49985086
5087+func (i *Interactive) applyTUISubagentPositionSetting(position string) {
5088+ defer func() {
5089+ if i.rend != nil {
5090+ i.rend.Clear()
5091+ }
5092+ i.invalidate()
5093+ }()
5094+ position = tui.NormalizeSubagentPosition(position)
5095+ i.cfg.TUISubagentPosition = position
5096+ if i.cfg.SettingsStore != nil {
5097+ if err := i.cfg.SettingsStore.SetTUISubagentPosition(position); err != nil {
5098+ i.mu.Lock()
5099+ i.statusErr = "settings: " + err.Error()
5100+ i.mu.Unlock()
5101+ return
5102+ }
5103+ }
5104+ i.mu.Lock()
5105+ label := "below input"
5106+ if position == tui.SubagentPositionAboveInput {
5107+ label = "above input"
5108+ }
5109+ i.statusOK = "running subagent position " + label
5110+ i.statusErr = ""
5111+ i.mu.Unlock()
5112+}
5113+
49995114 func (i *Interactive) applyThemeSetting(name string) {
50005115 if i.cfg.SettingsStore != nil {
50015116 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,26 +517,27 @@ 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- pendingIdleWork []func()532+ subagentActivityActive bool
528- dirty chan struct{}533+ pendingIdleWork []func()
529- modelRefresh chan modelRefreshResult534+ dirty chan struct{}
530- modelRefreshing bool535+ modelRefresh chan modelRefreshResult
531- startupPreDone chan startupPreResult536+ modelRefreshing bool
532- cancelTurn context.CancelFunc537+ startupPreDone chan startupPreResult
533- scrollOffset int // rows from the bottom; 0 = pinned to latest538+ cancelTurn context.CancelFunc
534- prevScrollOffset int // last value redraw snapped against; tracks intent539+ scrollOffset int // rows from the bottom; 0 = pinned to latest
540+ prevScrollOffset int // last value redraw snapped against; tracks intent
535 541
536 // prevChatLen and prevChatCols track the chat buffer's size at the542 // prevChatLen and prevChatCols track the chat buffer's size at the
537 // last redraw so that when content grows below the user's viewport543 // last redraw so that when content grows below the user's viewport
@@ -1125,9 +1131,10 @@ func (i *Interactive) Run(ctx context.Context) error {
1125 // the same way it does inside btw.1131 // the same way it does inside btw.
1126 i.mu.Lock()1132 i.mu.Lock()
1127 busy := i.busy1133 busy := i.busy
1134+ subagentActivityActive := i.subagentActivityActive
1128 sessionLoading := i.sessionDialog.Loading()1135 sessionLoading := i.sessionDialog.Loading()
1129 i.mu.Unlock()1136 i.mu.Unlock()
1130- if busy || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {1137+ if busy || subagentActivityActive || sessionLoading || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
1131 requestRedraw()1138 requestRedraw()
1132 }1139 }
1133 }1140 }
@@ -1818,6 +1825,18 @@ func (i *Interactive) redraw() {
1818 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)1825 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
1819 }1826 }
1820 edLines, curR, curC := i.ed.Render(mainCols)1827 edLines, curR, curC := i.ed.Render(mainCols)
1828+ dashboardActive := i.subagentsDialog.Active()
1829+ var allSubagentLines []string
1830+ if !dashboardActive {
1831+ now := i.clock()
1832+ allSubagentLines = renderSubagentActivityLines(
1833+ i.cfg.Theme,
1834+ i.spin.FrameAt(now),
1835+ i.activeSubagentActivitySnapshots(),
1836+ mainCols,
1837+ now,
1838+ )
1839+ }
1821 var workingLines []string1840 var workingLines []string
1822 if busyPrefix != "" && !workingWithStatus {1841 if busyPrefix != "" && !workingWithStatus {
1823 workingLines = []string{" " + busyPrefix}1842 workingLines = []string{" " + busyPrefix}
@@ -1865,26 +1884,31 @@ func (i *Interactive) redraw() {
1865 // content. The status block and editor get their own dedicated1884 // content. The status block and editor get their own dedicated
1866 // blanks so spacing stays consistent whether or not a dialog or1885 // blanks so spacing stays consistent whether or not a dialog or
1867 // popup is showing.1886 // popup is showing.
1868- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(edLines)+9)1887+ composeBottom := func(subagentLines []string) (bottom []string, inputStartRow int) {
1869- inputStartRow := -11888+ bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(subagentLines)+len(edLines)+9)
1870- if len(dialog) > 0 {1889+ inputStartRow = -1
1871- bottom = append(bottom, "")1890+ if len(dialog) > 0 {
1872- }1891+ bottom = append(bottom, "")
1873- bottom = append(bottom, dialog...)1892+ }
1874- // The subagent dashboard owns the bottom of the screen while it's1893+ bottom = append(bottom, dialog...)
1875- // active: it has its own inline editors for spawn (`n`) and1894+ // The subagent dashboard owns the bottom of the screen while it's
1876- // prompt (`p`), so the main input would be a confusing second1895+ // active: it has its own inline editors for spawn (`n`) and
1877- // caret. The suggest popup, sliding-in queue, status block, and1896+ // prompt (`p`), so the main input would be a confusing second
1878- // main editor are all hidden underneath it. Keystrokes still1897+ // caret. The suggest popup, sliding-in queue, status block, and
1879- // reach handleKey — it routes them to subagentsDialog.HandleKey1898+ // main editor are all hidden underneath it. Keystrokes still
1880- // before the editor ever sees them — so the only effect of this1899+ // reach handleKey — it routes them to subagentsDialog.HandleKey
1881- // branch is visual.1900+ // before the editor ever sees them — so the only effect of this
1882- if !i.subagentsDialog.Active() {1901+ // branch is visual.
1902+ if dashboardActive {
1903+ return bottom, inputStartRow
1904+ }
1905+
1883 bottom = append(bottom, suggest...)1906 bottom = append(bottom, suggest...)
1884 bottom = append(bottom, queue...)1907 bottom = append(bottom, queue...)
1885 lineInput := inputStyle == tui.InputStyleLines1908 lineInput := inputStyle == tui.InputStyleLines
1886 statusBelow := statusPosition == tui.StatusPositionBelowInput1909 statusBelow := statusPosition == tui.StatusPositionBelowInput
1887 workingBelow := workingPosition == tui.WorkingPositionBelowInput1910 workingBelow := workingPosition == tui.WorkingPositionBelowInput
1911+ subagentBelow := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition) == tui.SubagentPositionBelowInput
1888 1912
1889 var aboveInput []string1913 var aboveInput []string
1890 aboveInput = append(aboveInput, extensionLines...)1914 aboveInput = append(aboveInput, extensionLines...)
@@ -1894,7 +1918,13 @@ func (i *Interactive) redraw() {
1894 if !workingBelow {1918 if !workingBelow {
1895 aboveInput = append(aboveInput, workingLines...)1919 aboveInput = append(aboveInput, workingLines...)
1896 }1920 }
1921+ if !subagentBelow {
1922+ aboveInput = append(aboveInput, subagentLines...)
1923+ }
1897 var belowInput []string1924 var belowInput []string
1925+ if subagentBelow {
1926+ belowInput = append(belowInput, subagentLines...)
1927+ }
1898 if workingBelow {1928 if workingBelow {
1899 belowInput = append(belowInput, workingLines...)1929 belowInput = append(belowInput, workingLines...)
1900 }1930 }
@@ -1914,12 +1944,36 @@ func (i *Interactive) redraw() {
1914 inputStartRow = len(bottom)1944 inputStartRow = len(bottom)
1915 bottom = append(bottom, edLines...)1945 bottom = append(bottom, edLines...)
1916 if len(belowInput) > 0 {1946 if len(belowInput) > 0 {
1917- if !lineInput {1947+ // Running subagents sit immediately under the prompt by default.
1948+ // Other below-input chrome keeps the existing breathing room.
1949+ if !lineInput && !(subagentBelow && len(subagentLines) > 0) {
1918 bottom = append(bottom, "")1950 bottom = append(bottom, "")
1919 }1951 }
1920 bottom = append(bottom, belowInput...)1952 bottom = append(bottom, belowInput...)
1921 }1953 }
1954+ return bottom, inputStartRow
1955+ }
1956+
1957+ // Preserve one chat row and the renderer-owned bottom margin. This keeps
1958+ // a bounded set of live indicators from pushing the editor or its cursor
1959+ // out of a fixed right-bar frame on short terminals.
1960+ const rendererBottomMarginRows = 1
1961+ maxBottomRows := rows - rendererBottomMarginRows - 1
1962+ subagentLines := allSubagentLines
1963+ bottom, inputStartRow := composeBottom(subagentLines)
1964+ if !dashboardActive && len(allSubagentLines) > 0 {
1965+ for maxRows := len(allSubagentLines); maxRows >= 0; maxRows-- {
1966+ candidate := limitSubagentActivityLines(i.cfg.Theme, allSubagentLines, maxRows, mainCols)
1967+ candidateBottom, candidateInputStartRow := composeBottom(candidate)
1968+ if len(candidateBottom) <= maxBottomRows || maxRows == 0 {
1969+ subagentLines = candidate
1970+ bottom = candidateBottom
1971+ inputStartRow = candidateInputStartRow
1972+ break
1973+ }
1974+ }
1922 }1975 }
1976+ i.subagentActivityActive = !dashboardActive && len(subagentLines) > 0
1923 1977
1924 chatRows := rows - len(bottom)1978 chatRows := rows - len(bottom)
1925 if rightBarActive {1979 if rightBarActive {
@@ -3644,6 +3698,18 @@ func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model stri
3644 i.invalidate()3698 i.invalidate()
3645 }3699 }
3646 3700
3701+// SetSubagentSessionScope changes the live-worker filter and then requests a
3702+// redraw. Session changes must use this boundary after their host/session
3703+// state commits so input-adjacent activity cannot remain scoped to the prior
3704+// session until another UI event arrives.
3705+func (i *Interactive) SetSubagentSessionScope(sessionID string) {
3706+ if i.cfg.Supervisor == nil {
3707+ return
3708+ }
3709+ i.cfg.Supervisor.SetActiveSession(sessionID)
3710+ i.invalidate()
3711+}
3712+
3647 // ApplyChangedCWD is called by hosts after a successful /cd hook that do3713 // ApplyChangedCWD is called by hosts after a successful /cd hook that do
3648 // not provide startup context metadata.3714 // not provide startup context metadata.
3649 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {3715 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
@@ -4112,6 +4178,7 @@ func (i *Interactive) openSettingsDialog() {
4112 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)4178 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
4113 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)4179 statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
4114 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)4180 workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
4181+ subagentPosition := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition)
4115 quickItems := i.quickModelSettingItems()4182 quickItems := i.quickModelSettingItems()
4116 4183
4117 autoCompactOptions := []settingsOption{4184 autoCompactOptions := []settingsOption{
@@ -4193,6 +4260,18 @@ func (i *Interactive) openSettingsDialog() {
4193 }4260 }
4194 }4261 }
4195 4262
4263+ subagentPositionOptions := []settingsOption{
4264+ {value: tui.SubagentPositionAboveInput, label: "above input", desc: "show running subagents above the input"},
4265+ {value: tui.SubagentPositionBelowInput, label: "below input", desc: "show running subagents immediately below the input"},
4266+ }
4267+ subagentPositionChoice := 0
4268+ for idx, opt := range subagentPositionOptions {
4269+ if opt.value == subagentPosition {
4270+ subagentPositionChoice = idx
4271+ break
4272+ }
4273+ }
4274+
4196 items := []settingsItem{4275 items := []settingsItem{
4197 {4276 {
4198 key: "inline_images_enabled",4277 key: "inline_images_enabled",
@@ -4289,15 +4368,8 @@ func (i *Interactive) openSettingsDialog() {
4289 {4368 {
4290 key: "tui_settings",4369 key: "tui_settings",
4291 label: "tui settings",4370 label: "tui settings",
4292- desc: "choose input chrome and where status information appears",4371+ desc: "choose input chrome in its order around the prompt",
4293 children: []settingsItem{4372 children: []settingsItem{
4294- {
4295- key: "tui_input_style",
4296- label: "input style",
4297- desc: "choose between the plain prompt, lines, and a block input area",
4298- options: inputStyleOptions,
4299- choice: inputStyleChoice,
4300- },
4301 {4373 {
4302 key: "tui_status_position",4374 key: "tui_status_position",
4303 label: "status position",4375 label: "status position",
@@ -4312,6 +4384,20 @@ func (i *Interactive) openSettingsDialog() {
4312 options: workingPositionOptions,4384 options: workingPositionOptions,
4313 choice: workingPositionChoice,4385 choice: workingPositionChoice,
4314 },4386 },
4387+ {
4388+ key: "tui_input_style",
4389+ label: "input style",
4390+ desc: "choose between the plain prompt, lines, and a block input area",
4391+ options: inputStyleOptions,
4392+ choice: inputStyleChoice,
4393+ },
4394+ {
4395+ key: "tui_subagent_position",
4396+ label: "running subagent position",
4397+ desc: "place live subagent activity above or below the input",
4398+ options: subagentPositionOptions,
4399+ choice: subagentPositionChoice,
4400+ },
4315 },4401 },
4316 },4402 },
4317 reasoningItem,4403 reasoningItem,
@@ -4350,6 +4436,8 @@ func (i *Interactive) applySettingChange(act settingsAction) {
4350 i.applyTUIStatusPositionSetting(act.StringValue)4436 i.applyTUIStatusPositionSetting(act.StringValue)
4351 case act.Key == "tui_working_position":4437 case act.Key == "tui_working_position":
4352 i.applyTUIWorkingPositionSetting(act.StringValue)4438 i.applyTUIWorkingPositionSetting(act.StringValue)
4439+ case act.Key == "tui_subagent_position":
4440+ i.applyTUISubagentPositionSetting(act.StringValue)
4353 default:4441 default:
4354 i.applySettingToggle(act.Key, act.Value)4442 i.applySettingToggle(act.Key, act.Value)
4355 }4443 }
@@ -4996,6 +5084,33 @@ func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
4996 i.mu.Unlock()5084 i.mu.Unlock()
4997 }5085 }
4998 5086
5087+func (i *Interactive) applyTUISubagentPositionSetting(position string) {
5088+ defer func() {
5089+ if i.rend != nil {
5090+ i.rend.Clear()
5091+ }
5092+ i.invalidate()
5093+ }()
5094+ position = tui.NormalizeSubagentPosition(position)
5095+ i.cfg.TUISubagentPosition = position
5096+ if i.cfg.SettingsStore != nil {
5097+ if err := i.cfg.SettingsStore.SetTUISubagentPosition(position); err != nil {
5098+ i.mu.Lock()
5099+ i.statusErr = "settings: " + err.Error()
5100+ i.mu.Unlock()
5101+ return
5102+ }
5103+ }
5104+ i.mu.Lock()
5105+ label := "below input"
5106+ if position == tui.SubagentPositionAboveInput {
5107+ label = "above input"
5108+ }
5109+ i.statusOK = "running subagent position " + label
5110+ i.statusErr = ""
5111+ i.mu.Unlock()
5112+}
5113+
4999 func (i *Interactive) applyThemeSetting(name string) {5114 func (i *Interactive) applyThemeSetting(name string) {
5000 if i.cfg.SettingsStore != nil {5115 if i.cfg.SettingsStore != nil {
5001 if err := i.cfg.SettingsStore.SetTheme(name); err != nil {5116 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 +18 -0
@@ -90,6 +90,24 @@ func (s *spinner) Frame() string {
9090 return s.frames[idx]
9191 }
9292
93+// FrameAt returns a frame based on an absolute clock. Independent background
94+// activity uses it so its animation does not need to reset or interfere with
95+// the main turn's spinner lifecycle.
96+func (s *spinner) FrameAt(now time.Time) string {
97+ if len(s.frames) == 0 {
98+ return ""
99+ }
100+ if now.IsZero() {
101+ now = time.Now()
102+ }
103+ interval := s.interval
104+ if interval <= 0 {
105+ interval = 80 * time.Millisecond
106+ }
107+ idx := int((now.UnixNano() / int64(interval)) % int64(len(s.frames)))
108+ return s.frames[idx]
109+}
110+
93111 // Message returns the spinner's status text. One random phrase
94112 // per Start call, pinned until the next turn. When the spinner
95113 // was started via StartFixed, the pinned message is returned
@@ -90,6 +90,24 @@ func (s *spinner) Frame() string {
90 return s.frames[idx]90 return s.frames[idx]
91 }91 }
92 92
93+// FrameAt returns a frame based on an absolute clock. Independent background
94+// activity uses it so its animation does not need to reset or interfere with
95+// the main turn's spinner lifecycle.
96+func (s *spinner) FrameAt(now time.Time) string {
97+ if len(s.frames) == 0 {
98+ return ""
99+ }
100+ if now.IsZero() {
101+ now = time.Now()
102+ }
103+ interval := s.interval
104+ if interval <= 0 {
105+ interval = 80 * time.Millisecond
106+ }
107+ idx := int((now.UnixNano() / int64(interval)) % int64(len(s.frames)))
108+ return s.frames[idx]
109+}
110+
93 // Message returns the spinner's status text. One random phrase111 // Message returns the spinner's status text. One random phrase
94 // per Start call, pinned until the next turn. When the spinner112 // per Start call, pinned until the next turn. When the spinner
95 // was started via StartFixed, the pinned message is returned113 // was started via StartFixed, the pinned message is returned
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