feat(tui): show live subagent activity
- commit
- eddc90651c62c95f7f3b37541c65464e518a1b62
- author
- brice · · 2026-08-06 07:51 UTC
- parent
- 42a5079
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 407 | 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 | 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 | 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 | 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 | 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 | 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. | |
| @@ -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 | |||
| 90 | 90 | ||
| 91 | 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 | 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 | 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: | |
| @@ -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 { | |||
| 1827 | 1827 | if subagentsMgr != nil && candidate.session != nil { | |
| 1828 | 1828 | // Keep the dashboard scope in the same commit as the session, | |
| 1829 | 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 | 1836 | committed = true | |
| 1833 | 1837 | persistMu.Unlock() | |
| @@ -2010,7 +2014,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2010 | 2014 | ||
| 2011 | 2015 | // Re-scope the subagent dashboard to the new session. | |
| 2012 | 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 | 2023 | if newSess != nil { | |
| 2016 | 2024 | extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates) | |
| @@ -2132,6 +2140,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 2132 | 2140 | TUIInputStyle: initialCfg.TUIInputStyle, | |
| 2133 | 2141 | TUIStatusPosition: initialCfg.TUIStatusPosition, | |
| 2134 | 2142 | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | |
| 2143 | + | TUISubagentPosition: initialCfg.TUISubagentPosition, | |
| 2135 | 2144 | ThemeName: initialCfg.Theme, | |
| 2136 | 2145 | FlatTools: initialCfg.FlatToolRender(), | |
| 2137 | 2146 | 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 = true | 1836 | 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 { | |||
| 163 | 163 | // (default) and "below_input". | |
| 164 | 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 | 171 | // Insecure skips TLS verification for custom inference endpoints. | |
| 167 | 172 | Insecure bool `json:"insecure,omitempty"` | |
| 168 | 173 | ||
| @@ -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 { | |||
| 144 | 144 | // above or below the main input. | |
| 145 | 145 | 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 | 151 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The | |
| 148 | 152 | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | |
| 149 | 153 | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | |
| @@ -433,6 +437,7 @@ type SettingsStore interface { | |||
| 433 | 437 | SetTUIInputStyle(style string) error | |
| 434 | 438 | SetTUIStatusPosition(position string) error | |
| 435 | 439 | SetTUIWorkingPosition(position string) error | |
| 440 | + | SetTUISubagentPosition(position string) error | |
| 436 | 441 | SetReasoning(level string) error | |
| 437 | 442 | SetTheme(name string) error | |
| 438 | 443 | } | |
| @@ -512,26 +517,27 @@ type Interactive struct { | |||
| 512 | 517 | // it. We snapshot the total expected stream length (already | |
| 513 | 518 | // streamed + still pending) at the moment the tool starts, and | |
| 514 | 519 | // 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 | |
| 535 | 541 | ||
| 536 | 542 | // prevChatLen and prevChatCols track the chat buffer's size at the | |
| 537 | 543 | // 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 | 1131 | // the same way it does inside btw. | |
| 1126 | 1132 | i.mu.Lock() | |
| 1127 | 1133 | busy := i.busy | |
| 1134 | + | subagentActivityActive := i.subagentActivityActive | |
| 1128 | 1135 | sessionLoading := i.sessionDialog.Loading() | |
| 1129 | 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 | 1138 | requestRedraw() | |
| 1132 | 1139 | } | |
| 1133 | 1140 | } | |
| @@ -1818,6 +1825,18 @@ func (i *Interactive) redraw() { | |||
| 1818 | 1825 | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | |
| 1819 | 1826 | } | |
| 1820 | 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 | 1840 | var workingLines []string | |
| 1822 | 1841 | if busyPrefix != "" && !workingWithStatus { | |
| 1823 | 1842 | workingLines = []string{" " + busyPrefix} | |
| @@ -1865,26 +1884,31 @@ func (i *Interactive) redraw() { | |||
| 1865 | 1884 | // content. The status block and editor get their own dedicated | |
| 1866 | 1885 | // blanks so spacing stays consistent whether or not a dialog or | |
| 1867 | 1886 | // 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 | + | ||
| 1883 | 1906 | bottom = append(bottom, suggest...) | |
| 1884 | 1907 | bottom = append(bottom, queue...) | |
| 1885 | 1908 | lineInput := inputStyle == tui.InputStyleLines | |
| 1886 | 1909 | statusBelow := statusPosition == tui.StatusPositionBelowInput | |
| 1887 | 1910 | workingBelow := workingPosition == tui.WorkingPositionBelowInput | |
| 1911 | + | subagentBelow := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition) == tui.SubagentPositionBelowInput | |
| 1888 | 1912 | ||
| 1889 | 1913 | var aboveInput []string | |
| 1890 | 1914 | aboveInput = append(aboveInput, extensionLines...) | |
| @@ -1894,7 +1918,13 @@ func (i *Interactive) redraw() { | |||
| 1894 | 1918 | if !workingBelow { | |
| 1895 | 1919 | aboveInput = append(aboveInput, workingLines...) | |
| 1896 | 1920 | } | |
| 1921 | + | if !subagentBelow { | |
| 1922 | + | aboveInput = append(aboveInput, subagentLines...) | |
| 1923 | + | } | |
| 1897 | 1924 | var belowInput []string | |
| 1925 | + | if subagentBelow { | |
| 1926 | + | belowInput = append(belowInput, subagentLines...) | |
| 1927 | + | } | |
| 1898 | 1928 | if workingBelow { | |
| 1899 | 1929 | belowInput = append(belowInput, workingLines...) | |
| 1900 | 1930 | } | |
| @@ -1914,12 +1944,36 @@ func (i *Interactive) redraw() { | |||
| 1914 | 1944 | inputStartRow = len(bottom) | |
| 1915 | 1945 | bottom = append(bottom, edLines...) | |
| 1916 | 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 | 1950 | bottom = append(bottom, "") | |
| 1919 | 1951 | } | |
| 1920 | 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 | 1978 | chatRows := rows - len(bottom) | |
| 1925 | 1979 | if rightBarActive { | |
| @@ -3644,6 +3698,18 @@ func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model stri | |||
| 3644 | 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 | 3713 | // ApplyChangedCWD is called by hosts after a successful /cd hook that do | |
| 3648 | 3714 | // not provide startup context metadata. | |
| 3649 | 3715 | func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) { | |
| @@ -4112,6 +4178,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 4112 | 4178 | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | |
| 4113 | 4179 | statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition) | |
| 4114 | 4180 | workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition) | |
| 4181 | + | subagentPosition := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition) | |
| 4115 | 4182 | quickItems := i.quickModelSettingItems() | |
| 4116 | 4183 | ||
| 4117 | 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 | 4275 | items := []settingsItem{ | |
| 4197 | 4276 | { | |
| 4198 | 4277 | key: "inline_images_enabled", | |
| @@ -4289,15 +4368,8 @@ func (i *Interactive) openSettingsDialog() { | |||
| 4289 | 4368 | { | |
| 4290 | 4369 | key: "tui_settings", | |
| 4291 | 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 | 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 | 4374 | key: "tui_status_position", | |
| 4303 | 4375 | label: "status position", | |
| @@ -4312,6 +4384,20 @@ func (i *Interactive) openSettingsDialog() { | |||
| 4312 | 4384 | options: workingPositionOptions, | |
| 4313 | 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 | 4403 | reasoningItem, | |
| @@ -4350,6 +4436,8 @@ func (i *Interactive) applySettingChange(act settingsAction) { | |||
| 4350 | 4436 | i.applyTUIStatusPositionSetting(act.StringValue) | |
| 4351 | 4437 | case act.Key == "tui_working_position": | |
| 4352 | 4438 | i.applyTUIWorkingPositionSetting(act.StringValue) | |
| 4439 | + | case act.Key == "tui_subagent_position": | |
| 4440 | + | i.applyTUISubagentPositionSetting(act.StringValue) | |
| 4353 | 4441 | default: | |
| 4354 | 4442 | i.applySettingToggle(act.Key, act.Value) | |
| 4355 | 4443 | } | |
| @@ -4996,6 +5084,33 @@ func (i *Interactive) applyTUIWorkingPositionSetting(position string) { | |||
| 4996 | 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 | 5114 | func (i *Interactive) applyThemeSetting(name string) { | |
| 5000 | 5115 | if i.cfg.SettingsStore != nil { | |
| 5001 | 5116 | if err := i.cfg.SettingsStore.SetTheme(name); err != nil { | |
| @@ -7609,6 +7724,7 @@ func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) { | |||
| 7609 | 7724 | i.subagentWatchMu.Lock() | |
| 7610 | 7725 | i.subagentWatch = append(i.subagentWatch, entry) | |
| 7611 | 7726 | i.subagentWatchMu.Unlock() | |
| 7727 | + | i.invalidate() | |
| 7612 | 7728 | ||
| 7613 | 7729 | a.SetOnTurnEnd(func(step int, errMsg string) { | |
| 7614 | 7730 | 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 string | 145 | 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. The | 151 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The | ||
| 148 | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | 152 | // 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) error | 437 | SetTUIInputStyle(style string) error | ||
| 434 | SetTUIStatusPosition(position string) error | 438 | SetTUIStatusPosition(position string) error | ||
| 435 | SetTUIWorkingPosition(position string) error | 439 | SetTUIWorkingPosition(position string) error | ||
| 440 | + | SetTUISubagentPosition(position string) error | |||
| 436 | SetReasoning(level string) error | 441 | SetReasoning(level string) error | ||
| 437 | SetTheme(name string) error | 442 | SetTheme(name string) error | ||
| 438 | } | 443 | } | ||
| @@ -512,26 +517,27 @@ type Interactive struct { | |||||
| 512 | // it. We snapshot the total expected stream length (already | 517 | // it. We snapshot the total expected stream length (already | ||
| 513 | // streamed + still pending) at the moment the tool starts, and | 518 | // 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]int | 520 | + | toolGate map[string]int |
| 516 | - | statusErr string | 521 | + | statusErr string |
| 517 | - | statusOK string | 522 | + | statusOK string |
| 518 | - | reloadStatusSeq uint64 | 523 | + | reloadStatusSeq uint64 |
| 519 | - | extStatuses map[string]map[string]extensionStatus | 524 | + | extStatuses map[string]map[string]extensionStatus |
| 520 | - | extWidgets map[string]map[string]extensionWidget | 525 | + | extWidgets map[string]map[string]extensionWidget |
| 521 | - | rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible | 526 | + | rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible |
| 522 | - | liveBlock []string // live streaming/tool progress rendered outside scrollback | 527 | + | liveBlock []string // live streaming/tool progress rendered outside scrollback |
| 523 | - | helpBlock []string // rendered above the chat when /help was typed | 528 | + | helpBlock []string // rendered above the chat when /help was typed |
| 524 | - | cumUsage provider.Usage | 529 | + | cumUsage provider.Usage |
| 525 | - | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | 530 | + | lastCtxInput int // input_tokens of the most recent turn — approximates current context size |
| 526 | - | busy bool | 531 | + | busy bool |
| 527 | - | pendingIdleWork []func() | 532 | + | subagentActivityActive bool |
| 528 | - | dirty chan struct{} | 533 | + | pendingIdleWork []func() |
| 529 | - | modelRefresh chan modelRefreshResult | 534 | + | dirty chan struct{} |
| 530 | - | modelRefreshing bool | 535 | + | modelRefresh chan modelRefreshResult |
| 531 | - | startupPreDone chan startupPreResult | 536 | + | modelRefreshing bool |
| 532 | - | cancelTurn context.CancelFunc | 537 | + | startupPreDone chan startupPreResult |
| 533 | - | scrollOffset int // rows from the bottom; 0 = pinned to latest | 538 | + | cancelTurn context.CancelFunc |
| 534 | - | prevScrollOffset int // last value redraw snapped against; tracks intent | 539 | + | 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 the | 542 | // prevChatLen and prevChatCols track the chat buffer's size at the | ||
| 537 | // last redraw so that when content grows below the user's viewport | 543 | // 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.busy | 1133 | 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 []string | 1840 | 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 dedicated | 1884 | // content. The status block and editor get their own dedicated | ||
| 1866 | // blanks so spacing stays consistent whether or not a dialog or | 1885 | // 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 := -1 | 1888 | + | 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's | 1893 | + | bottom = append(bottom, dialog...) |
| 1875 | - | // active: it has its own inline editors for spawn (`n`) and | 1894 | + | // The subagent dashboard owns the bottom of the screen while it's |
| 1876 | - | // prompt (`p`), so the main input would be a confusing second | 1895 | + | // active: it has its own inline editors for spawn (`n`) and |
| 1877 | - | // caret. The suggest popup, sliding-in queue, status block, and | 1896 | + | // prompt (`p`), so the main input would be a confusing second |
| 1878 | - | // main editor are all hidden underneath it. Keystrokes still | 1897 | + | // caret. The suggest popup, sliding-in queue, status block, and |
| 1879 | - | // reach handleKey — it routes them to subagentsDialog.HandleKey | 1898 | + | // main editor are all hidden underneath it. Keystrokes still |
| 1880 | - | // before the editor ever sees them — so the only effect of this | 1899 | + | // 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.InputStyleLines | 1908 | lineInput := inputStyle == tui.InputStyleLines | ||
| 1886 | statusBelow := statusPosition == tui.StatusPositionBelowInput | 1909 | statusBelow := statusPosition == tui.StatusPositionBelowInput | ||
| 1887 | workingBelow := workingPosition == tui.WorkingPositionBelowInput | 1910 | workingBelow := workingPosition == tui.WorkingPositionBelowInput | ||
| 1911 | + | subagentBelow := tui.NormalizeSubagentPosition(i.cfg.TUISubagentPosition) == tui.SubagentPositionBelowInput | |||
| 1888 | 1912 | ||||
| 1889 | var aboveInput []string | 1913 | 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 []string | 1924 | 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 do | 3713 | // 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 { | |||
| 90 | 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 | 111 | // Message returns the spinner's status text. One random phrase | |
| 94 | 112 | // per Start call, pinned until the next turn. When the spinner | |
| 95 | 113 | // 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 phrase | 111 | // Message returns the spinner's status text. One random phrase | ||
| 94 | // per Start call, pinned until the next turn. When the spinner | 112 | // per Start call, pinned until the next turn. When the spinner | ||
| 95 | // was started via StartFixed, the pinned message is returned | 113 | // 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 { | |||
| 205 | 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 | 222 | func (configSettingsStore) SetReasoning(level string) error { | |
| 209 | 223 | cfg, err := LoadConfig() | |
| 210 | 224 | 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) { | |||
| 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 | 197 | func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) { | |
| 166 | 198 | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 167 | 199 | 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 { | |||
| 223 | 223 | 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 | 233 | // Result returns a copy of the most recent structured turn result. | |
| 227 | 234 | func (a *Agent) Result() *TurnResult { | |
| 228 | 235 | a.lifecycleMu.Lock() | |
| @@ -223,6 +223,13 @@ func (a *Agent) LastActivity() time.Time { | |||||
| 223 | return a.lastActivity | 223 | 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 ( | |||
| 12 | 12 | ||
| 13 | 13 | WorkingPositionAboveInput = "above_input" | |
| 14 | 14 | WorkingPositionBelowInput = "below_input" | |
| 15 | + | ||
| 16 | + | SubagentPositionAboveInput = "above_input" | |
| 17 | + | SubagentPositionBelowInput = "below_input" | |
| 15 | 18 | ) | |
| 16 | 19 | ||
| 17 | 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 | 60 | func InputLines(th Theme, lines []string, width int) []string { | |
| 47 | 61 | if width < 1 { | |
| 48 | 62 | 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 = 1 | 62 | 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 | + | } | |||