detached 75d4151

Merge pull request #111 from bnema/feat/subagent-dashboard-session-filter

feat(subagents): add session-scoped dashboard
commit
75d4151154649e39f76bdd2b4566e3484f79df98
author
bnema · · 2026-08-11 03:57 UTC
committer
GitHub · · 2026-08-11 03:57 UTC
merge of
7028e8c + 765242b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.14.0
10 files · +443 -78
M docs/subagents.md +2 -0
@@ -139,6 +139,8 @@ Every child has independent process and turn state. A process may be `alive` whi
139139
140140 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.
141141
142+`/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript.
143+
142144 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.
143145
144146 A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Every manager call returns immediately, and every worker outcome is delivered asynchronously through `[auto-subagents update]`; required outcome state is also injected as high-priority `[required-subagents update]` context. The primary remains available for coordination and independent work while children run. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references:
@@ -139,6 +139,8 @@ Every child has independent process and turn state. A process may be `alive` whi
139 139
140 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.140 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.
141 141
142+`/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript.
143+
142 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.144 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.
143 145
144 A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Every manager call returns immediately, and every worker outcome is delivered asynchronously through `[auto-subagents update]`; required outcome state is also injected as high-priority `[required-subagents update]` context. The primary remains available for coordination and independent work while children run. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references:146 A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Every manager call returns immediately, and every worker outcome is delivered asynchronously through `[auto-subagents update]`; required outcome state is also injected as high-priority `[required-subagents update]` context. The primary remains available for coordination and independent work while children run. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references:
M packages/agent/modes/interactive.go +9 -0
@@ -1957,6 +1957,15 @@ func (i *Interactive) redraw() {
19571957 i.sessionDialog.MaxRows = avail
19581958 dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
19591959 case i.subagentsDialog.Active():
1960+ // The dashboard is composed into the bottom-sticky dialog band.
1961+ // Reserve its leading gap, frame padding, one chat row, and the
1962+ // renderer margin so a long list or expanded snapshot never pushes
1963+ // its top rows out of the terminal viewport.
1964+ avail := rows - 4
1965+ if avail < 4 {
1966+ avail = 4
1967+ }
1968+ i.subagentsDialog.SetMaxRows(avail)
19601969 dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols)
19611970 case i.jumpDialog.Active():
19621971 dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
@@ -1957,6 +1957,15 @@ func (i *Interactive) redraw() {
1957 i.sessionDialog.MaxRows = avail1957 i.sessionDialog.MaxRows = avail
1958 dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)1958 dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
1959 case i.subagentsDialog.Active():1959 case i.subagentsDialog.Active():
1960+ // The dashboard is composed into the bottom-sticky dialog band.
1961+ // Reserve its leading gap, frame padding, one chat row, and the
1962+ // renderer margin so a long list or expanded snapshot never pushes
1963+ // its top rows out of the terminal viewport.
1964+ avail := rows - 4
1965+ if avail < 4 {
1966+ avail = 4
1967+ }
1968+ i.subagentsDialog.SetMaxRows(avail)
1960 dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols)1969 dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols)
1961 case i.jumpDialog.Active():1970 case i.jumpDialog.Active():
1962 dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)1971 dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
M packages/agent/modes/subagents_dialog.go +244 -20
@@ -21,7 +21,9 @@ import (
2121 // p send a follow-up prompt to the selected agent
2222 // R resume (restart) a detached / finished agent
2323 // ↑/↓ move cursor
24-// enter show transcript tail for the selected agent
24+// tab toggle this-session / all-sessions agents
25+// ←/→ or h/l collapse / expand the selected agent's live snapshot
26+// enter show the full transcript for the selected agent
2527 // k kill (Stop) the selected running agent
2628 // r remove a terminated agent (clears its state)
2729 // esc / q close
@@ -32,12 +34,15 @@ import (
3234 // ↑/↓/PgUp/PgDn/Home/End scroll
3335 // esc / q return to the list
3436 type subagentsDialog struct {
35- active bool
36- compactMode bool
37- lineInput bool
38- snapshot func() []subagents.AgentSnapshot
39- stop func(id string) error
40- remove func(id string) error
37+ active bool
38+ compactMode bool
39+ lineInput bool
40+ snapshot func() []subagents.AgentSnapshot
41+ allSnapshots func() []subagents.AgentSnapshot
42+ showAll bool
43+ expandedID string
44+ stop func(id string) error
45+ remove func(id string) error
4146 // spawn accepts an optional model + provider override (empty
4247 // strings mean "let the child resolve its own default"). The cli
4348 // adapter forwards these to subagents.Supervisor.SpawnReq.
@@ -53,9 +58,11 @@ type subagentsDialog struct {
5358 // its transcript view is opened. Nil preserves snapshot-only behavior.
5459 loadTranscript func(id string) error
5560
56- rows []subagents.AgentSnapshot
57- cursor int
58- viewing bool // when true, show full transcript of the selected row
61+ rows []subagents.AgentSnapshot
62+ cursor int
63+ maxRows int // total dialog rows, including its header and frame rule; zero is unbounded
64+ viewing bool // when true, show full transcript of the selected row
65+ transcriptScroll int // display rows above the newest transcript viewport
5966
6067 // transcriptSpin animates the busy hint shown above the inline
6168 // editor while the agent is mid-turn (status running, activity
@@ -126,6 +133,22 @@ func (d *subagentsDialog) SetLineInput(enabled bool) {
126133 d.lineInput = enabled
127134 }
128135
136+// SetAllSnapshots supplies the cross-session snapshot source used when the
137+// user presses Tab. Open's snapshot source remains the default session view.
138+func (d *subagentsDialog) SetAllSnapshots(fn func() []subagents.AgentSnapshot) {
139+ d.allSnapshots = fn
140+}
141+
142+// SetMaxRows bounds the complete dashboard before the host composes its
143+// bottom-sticky dialog band. Four rows are needed for the frame, column
144+// heading, and selected agent; a non-positive value leaves it unbounded.
145+func (d *subagentsDialog) SetMaxRows(rows int) {
146+ if rows > 0 && rows < 4 {
147+ rows = 4
148+ }
149+ d.maxRows = rows
150+}
151+
129152 // SetLoadTranscript supplies the optional on-demand durable transcript loader.
130153 func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) {
131154 d.loadTranscript = fn
@@ -180,6 +203,8 @@ func (d *subagentsDialog) Open(
180203 d.active = true
181204 d.cursor = 0
182205 d.viewing = false
206+ d.showAll = false
207+ d.expandedID = ""
183208 d.spawning = false
184209 d.newTaskEd = nil
185210 d.fileSuggest = nil
@@ -467,17 +492,46 @@ func friendlySendErr(id string, err error) string {
467492 }
468493
469494 func (d *subagentsDialog) refresh() {
470- if d.snapshot == nil {
495+ snapshot := d.snapshot
496+ if d.showAll && d.allSnapshots != nil {
497+ snapshot = d.allSnapshots
498+ }
499+ if snapshot == nil {
471500 d.rows = nil
472501 return
473502 }
474- d.rows = d.snapshot()
503+
504+ selectedID := ""
505+ if selected := d.selected(); selected != nil {
506+ selectedID = selected.ID
507+ }
508+ d.rows = snapshot()
509+ if selectedID != "" {
510+ for i, row := range d.rows {
511+ if row.ID == selectedID {
512+ d.cursor = i
513+ break
514+ }
515+ }
516+ }
475517 if d.cursor >= len(d.rows) {
476518 d.cursor = len(d.rows) - 1
477519 }
478520 if d.cursor < 0 {
479521 d.cursor = 0
480522 }
523+ if d.expandedID != "" && d.rowByID(d.expandedID) == nil {
524+ d.expandedID = ""
525+ }
526+}
527+
528+func (d *subagentsDialog) rowByID(id string) *subagents.AgentSnapshot {
529+ for i := range d.rows {
530+ if d.rows[i].ID == id {
531+ return &d.rows[i]
532+ }
533+ }
534+ return nil
481535 }
482536
483537 // HandleKey advances state. Returns (closed, statusMsg, statusErr).
@@ -518,10 +572,22 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
518572 case tui.KeyUp:
519573 if d.cursor > 0 {
520574 d.cursor--
575+ d.expandedID = ""
521576 }
522577 case tui.KeyDown:
523578 if d.cursor < len(d.rows)-1 {
524579 d.cursor++
580+ d.expandedID = ""
581+ }
582+ case tui.KeyTab:
583+ d.showAll = !d.showAll
584+ d.expandedID = ""
585+ d.refresh()
586+ case tui.KeyLeft:
587+ d.collapseSelected()
588+ case tui.KeyRight:
589+ if err := d.expandSelected(); err != nil {
590+ return false, "", "load transcript: " + err.Error()
525591 }
526592 case tui.KeyEnter:
527593 if len(d.rows) > 0 {
@@ -544,6 +610,12 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
544610 case 'q':
545611 d.Close()
546612 return true, "", ""
613+ case 'h':
614+ d.collapseSelected()
615+ case 'l':
616+ if err := d.expandSelected(); err != nil {
617+ return false, "", "load transcript: " + err.Error()
618+ }
547619 case 'n':
548620 if d.spawn != nil {
549621 // Open the task editor directly. The model the new
@@ -604,6 +676,27 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
604676 return false, "", ""
605677 }
606678
679+func (d *subagentsDialog) expandSelected() error {
680+ a := d.selected()
681+ if a == nil {
682+ return nil
683+ }
684+ if d.loadTranscript != nil {
685+ if err := d.loadTranscript(a.ID); err != nil {
686+ return err
687+ }
688+ d.refresh()
689+ }
690+ d.expandedID = a.ID
691+ return nil
692+}
693+
694+func (d *subagentsDialog) collapseSelected() {
695+ if a := d.selected(); a != nil && d.expandedID == a.ID {
696+ d.expandedID = ""
697+ }
698+}
699+
607700 // handleViewingKey routes one keystroke while the transcript view
608701 // is on screen. The transcript view runs /btw-style: an inline
609702 // editor is always pinned at the bottom, the transcript flows in
@@ -647,6 +740,34 @@ func (d *subagentsDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg
647740 return false, "", ""
648741 }
649742
743+ // Transcript navigation owns the vertical navigation keys; horizontal
744+ // arrows remain available to the inline editor for caret movement.
745+ switch k.Kind {
746+ case tui.KeyUp:
747+ d.transcriptScroll++
748+ return false, "", ""
749+ case tui.KeyDown:
750+ if d.transcriptScroll > 0 {
751+ d.transcriptScroll--
752+ }
753+ return false, "", ""
754+ case tui.KeyPageUp:
755+ d.transcriptScroll += 10
756+ return false, "", ""
757+ case tui.KeyPageDown:
758+ d.transcriptScroll -= 10
759+ if d.transcriptScroll < 0 {
760+ d.transcriptScroll = 0
761+ }
762+ return false, "", ""
763+ case tui.KeyHome:
764+ d.transcriptScroll = int(^uint(0) >> 1)
765+ return false, "", ""
766+ case tui.KeyEnd:
767+ d.transcriptScroll = 0
768+ return false, "", ""
769+ }
770+
650771 // Everything else — typed runes, Enter, Backspace, arrow keys
651772 // for caret movement, @-file picker — is editor input. If the
652773 // agent isn't running there's no live editor; surface a hint.
@@ -1022,7 +1143,11 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
10221143 return d.renderTranscript(th, width)
10231144 }
10241145
1025- out := []string{frameHeader(th, "subagents (n new, p prompt, R resume, ↑/↓ move, enter view, k kill, r remove, esc close)", width)}
1146+ filter := "this session"
1147+ if d.showAll {
1148+ filter = "all sessions"
1149+ }
1150+ out := []string{frameHeader(th, "subagents · "+filter+" (tab filter, ↑/↓ select, ←/→ or h/l snapshot, enter view, esc close)", width)}
10261151 if d.prompting {
10271152 return d.renderPromptEditor(th, width, out)
10281153 }
@@ -1085,16 +1210,80 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
10851210 // Column header for readability.
10861211 header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY")
10871212 out = append(out, th.FGColor(th.Muted, header))
1213+ out = append(out, d.renderVisibleRows(th, width)...)
1214+ out = append(out, frameRule(th, width))
1215+ return out
1216+}
10881217
1089- for i, r := range d.rows {
1218+// renderVisibleRows keeps the dashboard inside the terminal-height budget.
1219+// The selected row is always rendered, which prevents a bottom-sticky dialog
1220+// from clipping the top rows (and leaving arrow-key selection invisible).
1221+func (d *subagentsDialog) renderVisibleRows(th tui.Theme, width int) []string {
1222+ budget := len(d.rows) * (dashboardSnapshotRows + 1)
1223+ if d.maxRows > 0 {
1224+ budget = d.maxRows - 3 // frame header, column heading, frame rule
1225+ if budget < 1 {
1226+ budget = 1
1227+ }
1228+ }
1229+
1230+ out := make([]string, 0, budget)
1231+ // Do not spend the only available body row on the indicator: the selected
1232+ // agent must remain visible even on an extremely short terminal.
1233+ if d.cursor > 0 && budget > 1 {
1234+ out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("↑ %d agents above", d.cursor)))
1235+ }
1236+ nextRow := d.cursor
1237+ for ; nextRow < len(d.rows) && len(out) < budget; nextRow++ {
1238+ r := d.rows[nextRow]
10901239 row := formatSupervisorRow(r, width-2)
1091- if i == d.cursor {
1240+ if nextRow == d.cursor {
10921241 out = append(out, th.PadHighlight(" "+row, width))
10931242 } else {
10941243 out = append(out, " "+th.FGColor(th.FG, row))
10951244 }
1245+ if r.ID == d.expandedID && len(out) < budget {
1246+ out = append(out, d.renderSnapshot(th, width, r, budget-len(out))...)
1247+ }
1248+ }
1249+ if nextRow < len(d.rows) && len(out) < budget {
1250+ out = append(out, " "+th.FGColor(th.Muted, "↓ more agents below"))
1251+ }
1252+ return out
1253+}
1254+
1255+// dashboardSnapshotRows is deliberately bounded so expanding a running agent
1256+// keeps the list usable while still exposing its current user, assistant, and
1257+// tool activity. Enter opens the complete transcript.
1258+const dashboardSnapshotRows = 12
1259+
1260+func (d *subagentsDialog) renderSnapshot(th tui.Theme, width int, a subagents.AgentSnapshot, remaining int) []string {
1261+ raw := a.Lines
1262+ if len(raw) == 0 && a.Tail != "" {
1263+ raw = strings.Split(a.Tail, "\n")
1264+ }
1265+ out := []string{" " + th.FGColor(th.Muted, "└─ live snapshot")}
1266+ if len(raw) == 0 || remaining <= 1 {
1267+ if remaining > 1 {
1268+ out = append(out, " "+th.FGColor(th.Muted, " (no activity yet)"))
1269+ }
1270+ return out
1271+ }
1272+ rows := renderSupervisorTranscriptBlocks(raw, th, width-4, d.compactMode)
1273+ limit := dashboardSnapshotRows
1274+ if limit > remaining-1 {
1275+ limit = remaining - 1
1276+ }
1277+ if len(rows) > limit {
1278+ rows = rows[len(rows)-limit:]
1279+ if limit > 1 {
1280+ out = append(out, " "+th.FGColor(th.Muted, " … earlier activity"))
1281+ rows = rows[1:]
1282+ }
1283+ }
1284+ for _, row := range rows {
1285+ out = append(out, " "+row)
10961286 }
1097- out = append(out, frameRule(th, width))
10981287 return out
10991288 }
11001289
@@ -1159,15 +1348,36 @@ func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string {
11591348 // more above" cliff).
11601349 body := renderSupervisorTranscriptBlocks(raw, th, width, d.compactMode)
11611350
1162- out := header
1163- out = append(out, body...)
1164-
11651351 // /btw-style inline editor pinned to the bottom of the transcript.
11661352 // Rendered after the body so the editor sits flush above the
11671353 // frame rule. Only shown for running agents (others have no live
11681354 // inbox listener — we surface a hint instead).
1169- out = d.appendTranscriptEditor(out, th, width, a)
1355+ footer := d.appendTranscriptEditor(nil, th, width, a)
1356+ if d.maxRows > 0 {
1357+ bodyRows := d.maxRows - len(header) - len(footer) - 1
1358+ if bodyRows < 1 {
1359+ bodyRows = 1
1360+ }
1361+ maxScroll := len(body) - bodyRows
1362+ if maxScroll < 0 {
1363+ maxScroll = 0
1364+ }
1365+ if d.transcriptScroll > maxScroll {
1366+ d.transcriptScroll = maxScroll
1367+ }
1368+ start := len(body) - bodyRows - d.transcriptScroll
1369+ if start < 0 {
1370+ start = 0
1371+ }
1372+ end := start + bodyRows
1373+ if end > len(body) {
1374+ end = len(body)
1375+ }
1376+ body = body[start:end]
1377+ }
11701378
1379+ out := append(header, body...)
1380+ out = append(out, footer...)
11711381 out = append(out, frameRule(th, width))
11721382 return out
11731383 }
@@ -1273,6 +1483,8 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c
12731483 const (
12741484 kindAssistant blockKind = iota
12751485 kindUser
1486+ kindTool
1487+ kindToolResult
12761488 kindStderr
12771489 kindError
12781490 )
@@ -1286,6 +1498,10 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c
12861498 switch {
12871499 case strings.HasPrefix(s, "user: "):
12881500 return kindUser, strings.TrimPrefix(s, "user: ")
1501+ case strings.HasPrefix(s, "tool: "):
1502+ return kindTool, strings.TrimPrefix(s, "tool: ")
1503+ case strings.HasPrefix(s, "tool result: "):
1504+ return kindToolResult, strings.TrimPrefix(s, "tool result: ")
12891505 case strings.HasPrefix(s, "stderr: "):
12901506 return kindStderr, strings.TrimPrefix(s, "stderr: ")
12911507 case strings.HasPrefix(s, "error: "):
@@ -1339,6 +1555,14 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c
13391555 case kindAssistant:
13401556 text := strings.Join(b.body, "\n")
13411557 out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...)
1558+ case kindTool:
1559+ for _, line := range b.body {
1560+ out = append(out, wrapDialogTextRows(th.FGColor(th.Accent, "⚙ "+line), width)...)
1561+ }
1562+ case kindToolResult:
1563+ for _, line := range b.body {
1564+ out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "↳ "+line), width)...)
1565+ }
13421566 case kindStderr:
13431567 for _, line := range b.body {
13441568 out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "stderr "+line), width)...)
@@ -21,7 +21,9 @@ import (
21 // p send a follow-up prompt to the selected agent21 // p send a follow-up prompt to the selected agent
22 // R resume (restart) a detached / finished agent22 // R resume (restart) a detached / finished agent
23 // ↑/↓ move cursor23 // ↑/↓ move cursor
24-// enter show transcript tail for the selected agent24+// tab toggle this-session / all-sessions agents
25+// ←/→ or h/l collapse / expand the selected agent's live snapshot
26+// enter show the full transcript for the selected agent
25 // k kill (Stop) the selected running agent27 // k kill (Stop) the selected running agent
26 // r remove a terminated agent (clears its state)28 // r remove a terminated agent (clears its state)
27 // esc / q close29 // esc / q close
@@ -32,12 +34,15 @@ import (
32 // ↑/↓/PgUp/PgDn/Home/End scroll34 // ↑/↓/PgUp/PgDn/Home/End scroll
33 // esc / q return to the list35 // esc / q return to the list
34 type subagentsDialog struct {36 type subagentsDialog struct {
35- active bool37+ active bool
36- compactMode bool38+ compactMode bool
37- lineInput bool39+ lineInput bool
38- snapshot func() []subagents.AgentSnapshot40+ snapshot func() []subagents.AgentSnapshot
39- stop func(id string) error41+ allSnapshots func() []subagents.AgentSnapshot
40- remove func(id string) error42+ showAll bool
43+ expandedID string
44+ stop func(id string) error
45+ remove func(id string) error
41 // spawn accepts an optional model + provider override (empty46 // spawn accepts an optional model + provider override (empty
42 // strings mean "let the child resolve its own default"). The cli47 // strings mean "let the child resolve its own default"). The cli
43 // adapter forwards these to subagents.Supervisor.SpawnReq.48 // adapter forwards these to subagents.Supervisor.SpawnReq.
@@ -53,9 +58,11 @@ type subagentsDialog struct {
53 // its transcript view is opened. Nil preserves snapshot-only behavior.58 // its transcript view is opened. Nil preserves snapshot-only behavior.
54 loadTranscript func(id string) error59 loadTranscript func(id string) error
55 60
56- rows []subagents.AgentSnapshot61+ rows []subagents.AgentSnapshot
57- cursor int62+ cursor int
58- viewing bool // when true, show full transcript of the selected row63+ maxRows int // total dialog rows, including its header and frame rule; zero is unbounded
64+ viewing bool // when true, show full transcript of the selected row
65+ transcriptScroll int // display rows above the newest transcript viewport
59 66
60 // transcriptSpin animates the busy hint shown above the inline67 // transcriptSpin animates the busy hint shown above the inline
61 // editor while the agent is mid-turn (status running, activity68 // editor while the agent is mid-turn (status running, activity
@@ -126,6 +133,22 @@ func (d *subagentsDialog) SetLineInput(enabled bool) {
126 d.lineInput = enabled133 d.lineInput = enabled
127 }134 }
128 135
136+// SetAllSnapshots supplies the cross-session snapshot source used when the
137+// user presses Tab. Open's snapshot source remains the default session view.
138+func (d *subagentsDialog) SetAllSnapshots(fn func() []subagents.AgentSnapshot) {
139+ d.allSnapshots = fn
140+}
141+
142+// SetMaxRows bounds the complete dashboard before the host composes its
143+// bottom-sticky dialog band. Four rows are needed for the frame, column
144+// heading, and selected agent; a non-positive value leaves it unbounded.
145+func (d *subagentsDialog) SetMaxRows(rows int) {
146+ if rows > 0 && rows < 4 {
147+ rows = 4
148+ }
149+ d.maxRows = rows
150+}
151+
129 // SetLoadTranscript supplies the optional on-demand durable transcript loader.152 // SetLoadTranscript supplies the optional on-demand durable transcript loader.
130 func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) {153 func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) {
131 d.loadTranscript = fn154 d.loadTranscript = fn
@@ -180,6 +203,8 @@ func (d *subagentsDialog) Open(
180 d.active = true203 d.active = true
181 d.cursor = 0204 d.cursor = 0
182 d.viewing = false205 d.viewing = false
206+ d.showAll = false
207+ d.expandedID = ""
183 d.spawning = false208 d.spawning = false
184 d.newTaskEd = nil209 d.newTaskEd = nil
185 d.fileSuggest = nil210 d.fileSuggest = nil
@@ -467,17 +492,46 @@ func friendlySendErr(id string, err error) string {
467 }492 }
468 493
469 func (d *subagentsDialog) refresh() {494 func (d *subagentsDialog) refresh() {
470- if d.snapshot == nil {495+ snapshot := d.snapshot
496+ if d.showAll && d.allSnapshots != nil {
497+ snapshot = d.allSnapshots
498+ }
499+ if snapshot == nil {
471 d.rows = nil500 d.rows = nil
472 return501 return
473 }502 }
474- d.rows = d.snapshot()503+
504+ selectedID := ""
505+ if selected := d.selected(); selected != nil {
506+ selectedID = selected.ID
507+ }
508+ d.rows = snapshot()
509+ if selectedID != "" {
510+ for i, row := range d.rows {
511+ if row.ID == selectedID {
512+ d.cursor = i
513+ break
514+ }
515+ }
516+ }
475 if d.cursor >= len(d.rows) {517 if d.cursor >= len(d.rows) {
476 d.cursor = len(d.rows) - 1518 d.cursor = len(d.rows) - 1
477 }519 }
478 if d.cursor < 0 {520 if d.cursor < 0 {
479 d.cursor = 0521 d.cursor = 0
480 }522 }
523+ if d.expandedID != "" && d.rowByID(d.expandedID) == nil {
524+ d.expandedID = ""
525+ }
526+}
527+
528+func (d *subagentsDialog) rowByID(id string) *subagents.AgentSnapshot {
529+ for i := range d.rows {
530+ if d.rows[i].ID == id {
531+ return &d.rows[i]
532+ }
533+ }
534+ return nil
481 }535 }
482 536
483 // HandleKey advances state. Returns (closed, statusMsg, statusErr).537 // HandleKey advances state. Returns (closed, statusMsg, statusErr).
@@ -518,10 +572,22 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
518 case tui.KeyUp:572 case tui.KeyUp:
519 if d.cursor > 0 {573 if d.cursor > 0 {
520 d.cursor--574 d.cursor--
575+ d.expandedID = ""
521 }576 }
522 case tui.KeyDown:577 case tui.KeyDown:
523 if d.cursor < len(d.rows)-1 {578 if d.cursor < len(d.rows)-1 {
524 d.cursor++579 d.cursor++
580+ d.expandedID = ""
581+ }
582+ case tui.KeyTab:
583+ d.showAll = !d.showAll
584+ d.expandedID = ""
585+ d.refresh()
586+ case tui.KeyLeft:
587+ d.collapseSelected()
588+ case tui.KeyRight:
589+ if err := d.expandSelected(); err != nil {
590+ return false, "", "load transcript: " + err.Error()
525 }591 }
526 case tui.KeyEnter:592 case tui.KeyEnter:
527 if len(d.rows) > 0 {593 if len(d.rows) > 0 {
@@ -544,6 +610,12 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
544 case 'q':610 case 'q':
545 d.Close()611 d.Close()
546 return true, "", ""612 return true, "", ""
613+ case 'h':
614+ d.collapseSelected()
615+ case 'l':
616+ if err := d.expandSelected(); err != nil {
617+ return false, "", "load transcript: " + err.Error()
618+ }
547 case 'n':619 case 'n':
548 if d.spawn != nil {620 if d.spawn != nil {
549 // Open the task editor directly. The model the new621 // Open the task editor directly. The model the new
@@ -604,6 +676,27 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
604 return false, "", ""676 return false, "", ""
605 }677 }
606 678
679+func (d *subagentsDialog) expandSelected() error {
680+ a := d.selected()
681+ if a == nil {
682+ return nil
683+ }
684+ if d.loadTranscript != nil {
685+ if err := d.loadTranscript(a.ID); err != nil {
686+ return err
687+ }
688+ d.refresh()
689+ }
690+ d.expandedID = a.ID
691+ return nil
692+}
693+
694+func (d *subagentsDialog) collapseSelected() {
695+ if a := d.selected(); a != nil && d.expandedID == a.ID {
696+ d.expandedID = ""
697+ }
698+}
699+
607 // handleViewingKey routes one keystroke while the transcript view700 // handleViewingKey routes one keystroke while the transcript view
608 // is on screen. The transcript view runs /btw-style: an inline701 // is on screen. The transcript view runs /btw-style: an inline
609 // editor is always pinned at the bottom, the transcript flows in702 // editor is always pinned at the bottom, the transcript flows in
@@ -647,6 +740,34 @@ func (d *subagentsDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg
647 return false, "", ""740 return false, "", ""
648 }741 }
649 742
743+ // Transcript navigation owns the vertical navigation keys; horizontal
744+ // arrows remain available to the inline editor for caret movement.
745+ switch k.Kind {
746+ case tui.KeyUp:
747+ d.transcriptScroll++
748+ return false, "", ""
749+ case tui.KeyDown:
750+ if d.transcriptScroll > 0 {
751+ d.transcriptScroll--
752+ }
753+ return false, "", ""
754+ case tui.KeyPageUp:
755+ d.transcriptScroll += 10
756+ return false, "", ""
757+ case tui.KeyPageDown:
758+ d.transcriptScroll -= 10
759+ if d.transcriptScroll < 0 {
760+ d.transcriptScroll = 0
761+ }
762+ return false, "", ""
763+ case tui.KeyHome:
764+ d.transcriptScroll = int(^uint(0) >> 1)
765+ return false, "", ""
766+ case tui.KeyEnd:
767+ d.transcriptScroll = 0
768+ return false, "", ""
769+ }
770+
650 // Everything else — typed runes, Enter, Backspace, arrow keys771 // Everything else — typed runes, Enter, Backspace, arrow keys
651 // for caret movement, @-file picker — is editor input. If the772 // for caret movement, @-file picker — is editor input. If the
652 // agent isn't running there's no live editor; surface a hint.773 // agent isn't running there's no live editor; surface a hint.
@@ -1022,7 +1143,11 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
1022 return d.renderTranscript(th, width)1143 return d.renderTranscript(th, width)
1023 }1144 }
1024 1145
1025- out := []string{frameHeader(th, "subagents (n new, p prompt, R resume, ↑/↓ move, enter view, k kill, r remove, esc close)", width)}1146+ filter := "this session"
1147+ if d.showAll {
1148+ filter = "all sessions"
1149+ }
1150+ out := []string{frameHeader(th, "subagents · "+filter+" (tab filter, ↑/↓ select, ←/→ or h/l snapshot, enter view, esc close)", width)}
1026 if d.prompting {1151 if d.prompting {
1027 return d.renderPromptEditor(th, width, out)1152 return d.renderPromptEditor(th, width, out)
1028 }1153 }
@@ -1085,16 +1210,80 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
1085 // Column header for readability.1210 // Column header for readability.
1086 header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY")1211 header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY")
1087 out = append(out, th.FGColor(th.Muted, header))1212 out = append(out, th.FGColor(th.Muted, header))
1213+ out = append(out, d.renderVisibleRows(th, width)...)
1214+ out = append(out, frameRule(th, width))
1215+ return out
1216+}
1088 1217
1089- for i, r := range d.rows {1218+// renderVisibleRows keeps the dashboard inside the terminal-height budget.
1219+// The selected row is always rendered, which prevents a bottom-sticky dialog
1220+// from clipping the top rows (and leaving arrow-key selection invisible).
1221+func (d *subagentsDialog) renderVisibleRows(th tui.Theme, width int) []string {
1222+ budget := len(d.rows) * (dashboardSnapshotRows + 1)
1223+ if d.maxRows > 0 {
1224+ budget = d.maxRows - 3 // frame header, column heading, frame rule
1225+ if budget < 1 {
1226+ budget = 1
1227+ }
1228+ }
1229+
1230+ out := make([]string, 0, budget)
1231+ // Do not spend the only available body row on the indicator: the selected
1232+ // agent must remain visible even on an extremely short terminal.
1233+ if d.cursor > 0 && budget > 1 {
1234+ out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("↑ %d agents above", d.cursor)))
1235+ }
1236+ nextRow := d.cursor
1237+ for ; nextRow < len(d.rows) && len(out) < budget; nextRow++ {
1238+ r := d.rows[nextRow]
1090 row := formatSupervisorRow(r, width-2)1239 row := formatSupervisorRow(r, width-2)
1091- if i == d.cursor {1240+ if nextRow == d.cursor {
1092 out = append(out, th.PadHighlight(" "+row, width))1241 out = append(out, th.PadHighlight(" "+row, width))
1093 } else {1242 } else {
1094 out = append(out, " "+th.FGColor(th.FG, row))1243 out = append(out, " "+th.FGColor(th.FG, row))
1095 }1244 }
1245+ if r.ID == d.expandedID && len(out) < budget {
1246+ out = append(out, d.renderSnapshot(th, width, r, budget-len(out))...)
1247+ }
1248+ }
1249+ if nextRow < len(d.rows) && len(out) < budget {
1250+ out = append(out, " "+th.FGColor(th.Muted, "↓ more agents below"))
1251+ }
1252+ return out
1253+}
1254+
1255+// dashboardSnapshotRows is deliberately bounded so expanding a running agent
1256+// keeps the list usable while still exposing its current user, assistant, and
1257+// tool activity. Enter opens the complete transcript.
1258+const dashboardSnapshotRows = 12
1259+
1260+func (d *subagentsDialog) renderSnapshot(th tui.Theme, width int, a subagents.AgentSnapshot, remaining int) []string {
1261+ raw := a.Lines
1262+ if len(raw) == 0 && a.Tail != "" {
1263+ raw = strings.Split(a.Tail, "\n")
1264+ }
1265+ out := []string{" " + th.FGColor(th.Muted, "└─ live snapshot")}
1266+ if len(raw) == 0 || remaining <= 1 {
1267+ if remaining > 1 {
1268+ out = append(out, " "+th.FGColor(th.Muted, " (no activity yet)"))
1269+ }
1270+ return out
1271+ }
1272+ rows := renderSupervisorTranscriptBlocks(raw, th, width-4, d.compactMode)
1273+ limit := dashboardSnapshotRows
1274+ if limit > remaining-1 {
1275+ limit = remaining - 1
1276+ }
1277+ if len(rows) > limit {
1278+ rows = rows[len(rows)-limit:]
1279+ if limit > 1 {
1280+ out = append(out, " "+th.FGColor(th.Muted, " … earlier activity"))
1281+ rows = rows[1:]
1282+ }
1283+ }
1284+ for _, row := range rows {
1285+ out = append(out, " "+row)
1096 }1286 }
1097- out = append(out, frameRule(th, width))
1098 return out1287 return out
1099 }1288 }
1100 1289
@@ -1159,15 +1348,36 @@ func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string {
1159 // more above" cliff).1348 // more above" cliff).
1160 body := renderSupervisorTranscriptBlocks(raw, th, width, d.compactMode)1349 body := renderSupervisorTranscriptBlocks(raw, th, width, d.compactMode)
1161 1350
1162- out := header
1163- out = append(out, body...)
1164-
1165 // /btw-style inline editor pinned to the bottom of the transcript.1351 // /btw-style inline editor pinned to the bottom of the transcript.
1166 // Rendered after the body so the editor sits flush above the1352 // Rendered after the body so the editor sits flush above the
1167 // frame rule. Only shown for running agents (others have no live1353 // frame rule. Only shown for running agents (others have no live
1168 // inbox listener — we surface a hint instead).1354 // inbox listener — we surface a hint instead).
1169- out = d.appendTranscriptEditor(out, th, width, a)1355+ footer := d.appendTranscriptEditor(nil, th, width, a)
1356+ if d.maxRows > 0 {
1357+ bodyRows := d.maxRows - len(header) - len(footer) - 1
1358+ if bodyRows < 1 {
1359+ bodyRows = 1
1360+ }
1361+ maxScroll := len(body) - bodyRows
1362+ if maxScroll < 0 {
1363+ maxScroll = 0
1364+ }
1365+ if d.transcriptScroll > maxScroll {
1366+ d.transcriptScroll = maxScroll
1367+ }
1368+ start := len(body) - bodyRows - d.transcriptScroll
1369+ if start < 0 {
1370+ start = 0
1371+ }
1372+ end := start + bodyRows
1373+ if end > len(body) {
1374+ end = len(body)
1375+ }
1376+ body = body[start:end]
1377+ }
1170 1378
1379+ out := append(header, body...)
1380+ out = append(out, footer...)
1171 out = append(out, frameRule(th, width))1381 out = append(out, frameRule(th, width))
1172 return out1382 return out
1173 }1383 }
@@ -1273,6 +1483,8 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c
1273 const (1483 const (
1274 kindAssistant blockKind = iota1484 kindAssistant blockKind = iota
1275 kindUser1485 kindUser
1486+ kindTool
1487+ kindToolResult
1276 kindStderr1488 kindStderr
1277 kindError1489 kindError
1278 )1490 )
@@ -1286,6 +1498,10 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c
1286 switch {1498 switch {
1287 case strings.HasPrefix(s, "user: "):1499 case strings.HasPrefix(s, "user: "):
1288 return kindUser, strings.TrimPrefix(s, "user: ")1500 return kindUser, strings.TrimPrefix(s, "user: ")
1501+ case strings.HasPrefix(s, "tool: "):
1502+ return kindTool, strings.TrimPrefix(s, "tool: ")
1503+ case strings.HasPrefix(s, "tool result: "):
1504+ return kindToolResult, strings.TrimPrefix(s, "tool result: ")
1289 case strings.HasPrefix(s, "stderr: "):1505 case strings.HasPrefix(s, "stderr: "):
1290 return kindStderr, strings.TrimPrefix(s, "stderr: ")1506 return kindStderr, strings.TrimPrefix(s, "stderr: ")
1291 case strings.HasPrefix(s, "error: "):1507 case strings.HasPrefix(s, "error: "):
@@ -1339,6 +1555,14 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c
1339 case kindAssistant:1555 case kindAssistant:
1340 text := strings.Join(b.body, "\n")1556 text := strings.Join(b.body, "\n")
1341 out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...)1557 out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...)
1558+ case kindTool:
1559+ for _, line := range b.body {
1560+ out = append(out, wrapDialogTextRows(th.FGColor(th.Accent, "⚙ "+line), width)...)
1561+ }
1562+ case kindToolResult:
1563+ for _, line := range b.body {
1564+ out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "↳ "+line), width)...)
1565+ }
1342 case kindStderr:1566 case kindStderr:
1343 for _, line := range b.body {1567 for _, line := range b.body {
1344 out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "stderr "+line), width)...)1568 out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "stderr "+line), width)...)
M packages/agent/modes/subagents_dialog_test.go +72 -0
@@ -1325,6 +1325,78 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
13251325 }
13261326 }
13271327
1328+func TestSubagentsDialogTabTogglesSessionScope(t *testing.T) {
1329+ now := time.Now()
1330+ current := staticSnapshots(
1331+ subagents.AgentSnapshot{ID: "current-1", Status: subagents.StatusRunning, Started: now},
1332+ subagents.AgentSnapshot{ID: "current-2", Status: subagents.StatusDone, Started: now},
1333+ )
1334+ all := staticSnapshots(
1335+ subagents.AgentSnapshot{ID: "current-1", Status: subagents.StatusRunning, Started: now},
1336+ subagents.AgentSnapshot{ID: "current-2", Status: subagents.StatusDone, Started: now},
1337+ subagents.AgentSnapshot{ID: "other-session", Status: subagents.StatusDone, Started: now},
1338+ )
1339+ d := newSubagentsDialog()
1340+ d.SetAllSnapshots(all)
1341+ d.Open(current, nil, nil, nil, nil, nil, "")
1342+
1343+ out := strings.Join(d.Render(tui.Theme{}, 100), "\n")
1344+ if strings.Contains(out, "other-session") || !strings.Contains(out, "this session") {
1345+ t.Fatalf("default view is not restricted to the current session:\n%s", out)
1346+ }
1347+
1348+ d.HandleKey(tui.Key{Kind: tui.KeyTab})
1349+ out = strings.Join(d.Render(tui.Theme{}, 100), "\n")
1350+ if !strings.Contains(out, "other-session") || !strings.Contains(out, "all sessions") {
1351+ t.Fatalf("Tab did not switch to the all-sessions view:\n%s", out)
1352+ }
1353+}
1354+
1355+func TestSubagentsDialogNavigatesAndExpandsLiveSnapshotWithinHeight(t *testing.T) {
1356+ now := time.Now()
1357+ rows := []subagents.AgentSnapshot{
1358+ {ID: "first", Status: subagents.StatusDone, Started: now},
1359+ {ID: "active", Status: subagents.StatusRunning, Started: now, Lines: []string{
1360+ "user: inspect the dashboard", "assistant: I am checking the list", "tool: bash", "tool result: dashboard source", "assistant: rendering snapshot",
1361+ }},
1362+ {ID: "third", Status: subagents.StatusDone, Started: now},
1363+ }
1364+ d := newSubagentsDialog()
1365+ d.SetMaxRows(20)
1366+ d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
1367+ _ = d.Render(tui.Theme{}, 100)
1368+
1369+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
1370+ if selected := d.selected(); selected == nil || selected.ID != "active" {
1371+ t.Fatalf("down did not select active agent: %#v", selected)
1372+ }
1373+ d.HandleKey(tui.Key{Kind: tui.KeyRight})
1374+ out := d.Render(tui.Theme{}, 100)
1375+ joined := strings.Join(out, "\n")
1376+ for _, want := range []string{"live snapshot", "inspect the dashboard", "bash", "dashboard source"} {
1377+ if !strings.Contains(joined, want) {
1378+ t.Errorf("expanded snapshot missing %q:\n%s", want, joined)
1379+ }
1380+ }
1381+ if len(out) > 20 {
1382+ t.Fatalf("dashboard rendered %d rows with 20-row budget:\n%s", len(out), joined)
1383+ }
1384+
1385+ d.HandleKey(tui.Key{Kind: tui.KeyLeft})
1386+ out = d.Render(tui.Theme{}, 100)
1387+ if strings.Contains(strings.Join(out, "\n"), "live snapshot") {
1388+ t.Fatalf("left did not collapse selected snapshot:\n%s", strings.Join(out, "\n"))
1389+ }
1390+
1391+ // Even the smallest supported dashboard retains the selected row instead
1392+ // of allowing the bottom-sticky compositor to clip it above the viewport.
1393+ d.SetMaxRows(4)
1394+ out = d.Render(tui.Theme{}, 100)
1395+ if len(out) != 4 || !strings.Contains(strings.Join(out, "\n"), "active") {
1396+ t.Fatalf("short dashboard lost its selected row (%d rows):\n%s", len(out), strings.Join(out, "\n"))
1397+ }
1398+}
1399+
13281400 func TestStatusLabelCoversAll(t *testing.T) {
13291401 for _, s := range []subagents.Status{
13301402 subagents.StatusPending, subagents.StatusRunning,
@@ -1325,6 +1325,78 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
1325 }1325 }
1326 }1326 }
1327 1327
1328+func TestSubagentsDialogTabTogglesSessionScope(t *testing.T) {
1329+ now := time.Now()
1330+ current := staticSnapshots(
1331+ subagents.AgentSnapshot{ID: "current-1", Status: subagents.StatusRunning, Started: now},
1332+ subagents.AgentSnapshot{ID: "current-2", Status: subagents.StatusDone, Started: now},
1333+ )
1334+ all := staticSnapshots(
1335+ subagents.AgentSnapshot{ID: "current-1", Status: subagents.StatusRunning, Started: now},
1336+ subagents.AgentSnapshot{ID: "current-2", Status: subagents.StatusDone, Started: now},
1337+ subagents.AgentSnapshot{ID: "other-session", Status: subagents.StatusDone, Started: now},
1338+ )
1339+ d := newSubagentsDialog()
1340+ d.SetAllSnapshots(all)
1341+ d.Open(current, nil, nil, nil, nil, nil, "")
1342+
1343+ out := strings.Join(d.Render(tui.Theme{}, 100), "\n")
1344+ if strings.Contains(out, "other-session") || !strings.Contains(out, "this session") {
1345+ t.Fatalf("default view is not restricted to the current session:\n%s", out)
1346+ }
1347+
1348+ d.HandleKey(tui.Key{Kind: tui.KeyTab})
1349+ out = strings.Join(d.Render(tui.Theme{}, 100), "\n")
1350+ if !strings.Contains(out, "other-session") || !strings.Contains(out, "all sessions") {
1351+ t.Fatalf("Tab did not switch to the all-sessions view:\n%s", out)
1352+ }
1353+}
1354+
1355+func TestSubagentsDialogNavigatesAndExpandsLiveSnapshotWithinHeight(t *testing.T) {
1356+ now := time.Now()
1357+ rows := []subagents.AgentSnapshot{
1358+ {ID: "first", Status: subagents.StatusDone, Started: now},
1359+ {ID: "active", Status: subagents.StatusRunning, Started: now, Lines: []string{
1360+ "user: inspect the dashboard", "assistant: I am checking the list", "tool: bash", "tool result: dashboard source", "assistant: rendering snapshot",
1361+ }},
1362+ {ID: "third", Status: subagents.StatusDone, Started: now},
1363+ }
1364+ d := newSubagentsDialog()
1365+ d.SetMaxRows(20)
1366+ d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
1367+ _ = d.Render(tui.Theme{}, 100)
1368+
1369+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
1370+ if selected := d.selected(); selected == nil || selected.ID != "active" {
1371+ t.Fatalf("down did not select active agent: %#v", selected)
1372+ }
1373+ d.HandleKey(tui.Key{Kind: tui.KeyRight})
1374+ out := d.Render(tui.Theme{}, 100)
1375+ joined := strings.Join(out, "\n")
1376+ for _, want := range []string{"live snapshot", "inspect the dashboard", "bash", "dashboard source"} {
1377+ if !strings.Contains(joined, want) {
1378+ t.Errorf("expanded snapshot missing %q:\n%s", want, joined)
1379+ }
1380+ }
1381+ if len(out) > 20 {
1382+ t.Fatalf("dashboard rendered %d rows with 20-row budget:\n%s", len(out), joined)
1383+ }
1384+
1385+ d.HandleKey(tui.Key{Kind: tui.KeyLeft})
1386+ out = d.Render(tui.Theme{}, 100)
1387+ if strings.Contains(strings.Join(out, "\n"), "live snapshot") {
1388+ t.Fatalf("left did not collapse selected snapshot:\n%s", strings.Join(out, "\n"))
1389+ }
1390+
1391+ // Even the smallest supported dashboard retains the selected row instead
1392+ // of allowing the bottom-sticky compositor to clip it above the viewport.
1393+ d.SetMaxRows(4)
1394+ out = d.Render(tui.Theme{}, 100)
1395+ if len(out) != 4 || !strings.Contains(strings.Join(out, "\n"), "active") {
1396+ t.Fatalf("short dashboard lost its selected row (%d rows):\n%s", len(out), strings.Join(out, "\n"))
1397+ }
1398+}
1399+
1328 func TestStatusLabelCoversAll(t *testing.T) {1400 func TestStatusLabelCoversAll(t *testing.T) {
1329 for _, s := range []subagents.Status{1401 for _, s := range []subagents.Status{
1330 subagents.StatusPending, subagents.StatusRunning,1402 subagents.StatusPending, subagents.StatusRunning,
M packages/agent/modes/subagents_slash.go +3 -2
@@ -77,6 +77,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
7777 // editor, by typing /model on its own line to pop the picker.
7878 i.subagentsDialog.SetCompactMode(i.compactModeEnabled())
7979 i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
80+ i.subagentsDialog.SetAllSnapshots(i.cfg.Supervisor.SnapshotAllSessions)
8081 i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript)
8182 i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
8283 if i.cfg.LoggedInProviders != nil {
@@ -86,7 +87,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
8687 switch sub {
8788 case "", "list", "ls", "ps":
8889 i.subagentsDialog.Open(
89- i.cfg.Supervisor.SnapshotAll,
90+ i.cfg.Supervisor.SnapshotCurrentSession,
9091 i.cfg.Supervisor.Stop,
9192 i.cfg.Supervisor.Remove,
9293 spawnAdapter,
@@ -272,7 +273,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
272273 case "resume-session", "resume", "reattach", "reopen":
273274 if rest == "" {
274275 count := i.subagentsDialog.OpenForResume(
275- i.cfg.Supervisor.SnapshotAll,
276+ i.cfg.Supervisor.SnapshotCurrentSession,
276277 i.cfg.Supervisor.Stop,
277278 i.cfg.Supervisor.Remove,
278279 spawnAdapter,
@@ -77,6 +77,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
77 // editor, by typing /model on its own line to pop the picker.77 // editor, by typing /model on its own line to pop the picker.
78 i.subagentsDialog.SetCompactMode(i.compactModeEnabled())78 i.subagentsDialog.SetCompactMode(i.compactModeEnabled())
79 i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)79 i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
80+ i.subagentsDialog.SetAllSnapshots(i.cfg.Supervisor.SnapshotAllSessions)
80 i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript)81 i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript)
81 i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)82 i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
82 if i.cfg.LoggedInProviders != nil {83 if i.cfg.LoggedInProviders != nil {
@@ -86,7 +87,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
86 switch sub {87 switch sub {
87 case "", "list", "ls", "ps":88 case "", "list", "ls", "ps":
88 i.subagentsDialog.Open(89 i.subagentsDialog.Open(
89- i.cfg.Supervisor.SnapshotAll,90+ i.cfg.Supervisor.SnapshotCurrentSession,
90 i.cfg.Supervisor.Stop,91 i.cfg.Supervisor.Stop,
91 i.cfg.Supervisor.Remove,92 i.cfg.Supervisor.Remove,
92 spawnAdapter,93 spawnAdapter,
@@ -272,7 +273,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
272 case "resume-session", "resume", "reattach", "reopen":273 case "resume-session", "resume", "reattach", "reopen":
273 if rest == "" {274 if rest == "" {
274 count := i.subagentsDialog.OpenForResume(275 count := i.subagentsDialog.OpenForResume(
275- i.cfg.Supervisor.SnapshotAll,276+ i.cfg.Supervisor.SnapshotCurrentSession,
276 i.cfg.Supervisor.Stop,277 i.cfg.Supervisor.Stop,
277 i.cfg.Supervisor.Remove,278 i.cfg.Supervisor.Remove,
278 spawnAdapter,279 spawnAdapter,
M packages/agent/subagents/persist.go +10 -7
@@ -89,13 +89,10 @@ type agentMeta struct {
8989 ResumePromptID string `json:"resume_prompt_id,omitempty"`
9090 ResumeQueue []queuedResumePrompt `json:"resume_queue,omitempty"`
9191
92- // SessionID, when non-empty, scopes the agent to a particular
93- // host zut session: the dashboard only shows agents whose
94- // SessionID matches the active session. Older meta.json files
95- // (and agents spawned outside of any session, e.g. by tests or
96- // scripted callers that didn't call SetActiveSession) have an
97- // empty SessionID and are visible from every session as a
98- // unscoped-agent fallback.
92+ // SessionID, when non-empty, scopes the agent to a particular host zut
93+ // session. The default dashboard view only shows the active session;
94+ // older unscoped metadata remains reachable through its all-sessions
95+ // filter.
9996 SessionID string `json:"session_id,omitempty"`
10097 }
10198
@@ -788,6 +785,12 @@ func replayEventTranscript(a *Agent, ev Event) {
788785 } else {
789786 a.appendUserMessage(message)
790787 }
788+ case "tool_call", EventToolStarted:
789+ if name, _ := ev.Data["name"].(string); name != "" {
790+ a.appendTranscript("tool: " + name)
791+ }
792+ case "tool_result", EventToolFinished:
793+ a.appendTranscript("tool result: completed")
791794 case "stdout":
792795 if txt, _ := ev.Data["text"].(string); txt != "" {
793796 a.appendTranscript(txt)
@@ -89,13 +89,10 @@ type agentMeta struct {
89 ResumePromptID string `json:"resume_prompt_id,omitempty"`89 ResumePromptID string `json:"resume_prompt_id,omitempty"`
90 ResumeQueue []queuedResumePrompt `json:"resume_queue,omitempty"`90 ResumeQueue []queuedResumePrompt `json:"resume_queue,omitempty"`
91 91
92- // SessionID, when non-empty, scopes the agent to a particular92+ // SessionID, when non-empty, scopes the agent to a particular host zut
93- // host zut session: the dashboard only shows agents whose93+ // session. The default dashboard view only shows the active session;
94- // SessionID matches the active session. Older meta.json files94+ // older unscoped metadata remains reachable through its all-sessions
95- // (and agents spawned outside of any session, e.g. by tests or95+ // filter.
96- // scripted callers that didn't call SetActiveSession) have an
97- // empty SessionID and are visible from every session as a
98- // unscoped-agent fallback.
99 SessionID string `json:"session_id,omitempty"`96 SessionID string `json:"session_id,omitempty"`
100 }97 }
101 98
@@ -788,6 +785,12 @@ func replayEventTranscript(a *Agent, ev Event) {
788 } else {785 } else {
789 a.appendUserMessage(message)786 a.appendUserMessage(message)
790 }787 }
788+ case "tool_call", EventToolStarted:
789+ if name, _ := ev.Data["name"].(string); name != "" {
790+ a.appendTranscript("tool: " + name)
791+ }
792+ case "tool_result", EventToolFinished:
793+ a.appendTranscript("tool result: completed")
791 case "stdout":794 case "stdout":
792 if txt, _ := ev.Data["text"].(string); txt != "" {795 if txt, _ := ev.Data["text"].(string); txt != "" {
793 a.appendTranscript(txt)796 a.appendTranscript(txt)
M packages/agent/subagents/persist_test.go +37 -28
@@ -1411,12 +1411,9 @@ func TestRemoveReloadedAgentKeepsSiblingState(t *testing.T) {
14111411 }
14121412 }
14131413
1414-// TestActiveSessionScopesSnapshotAll proves the session filter the
1415-// user asked for: a single Supervisor with agents spawned under two
1416-// different active sessions only surfaces the agents matching the
1417-// currently-active session via SnapshotAll. Switching the active
1418-// session re-narrows the view without touching agent state.
1419-func TestActiveSessionScopesSnapshotAll(t *testing.T) {
1414+// TestActiveSessionScopesSnapshotCurrentSession proves the dashboard's
1415+// default session filter. SnapshotAll remains the explicit cross-session view.
1416+func TestActiveSessionScopesSnapshotCurrentSession(t *testing.T) {
14201417 root := t.TempDir()
14211418 f := New(Config{
14221419 Root: root, RepoRoot: root,
@@ -1447,23 +1444,29 @@ func TestActiveSessionScopesSnapshotAll(t *testing.T) {
14471444 }
14481445
14491446 // Active session B → only aB visible.
1450- only := snapshotIDs(f.SnapshotAll())
1447+ only := snapshotIDs(f.SnapshotCurrentSession())
14511448 if len(only) != 1 || only[0] != aB.ID {
14521449 t.Errorf("scoped to sess-B, snapshot ids = %v; want [%s]", only, aB.ID)
14531450 }
14541451
14551452 // Switch back to A → only aA visible.
14561453 f.SetActiveSession("sess-A")
1457- only = snapshotIDs(f.SnapshotAll())
1454+ only = snapshotIDs(f.SnapshotCurrentSession())
14581455 if len(only) != 1 || only[0] != aA.ID {
14591456 t.Errorf("scoped to sess-A, snapshot ids = %v; want [%s]", only, aA.ID)
14601457 }
14611458
1462- // Clear the scope → both visible.
1463- f.SetActiveSession("")
1464- all := snapshotIDs(f.SnapshotAll())
1459+ // The All filter is independent of the active session.
1460+ all := snapshotIDs(f.SnapshotAllSessions())
14651461 if len(all) != 2 {
1466- t.Errorf("unscoped snapshot ids = %v; want both agents", all)
1462+ t.Errorf("all-session snapshot ids = %v; want both agents", all)
1463+ }
1464+
1465+ // Clear the scope → both visible from the default view too.
1466+ f.SetActiveSession("")
1467+ only = snapshotIDs(f.SnapshotCurrentSession())
1468+ if len(only) != 2 {
1469+ t.Errorf("unscoped current-session ids = %v; want both agents", only)
14671470 }
14681471
14691472 // Cleanup.
@@ -1505,26 +1508,26 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) {
15051508 t.Fatalf("reload loaded=%d errs=%v; want 1 / no errs", loaded, errs)
15061509 }
15071510 g.SetActiveSession("sess-keep")
1508- got := snapshotIDs(g.SnapshotAll())
1511+ got := snapshotIDs(g.SnapshotCurrentSession())
15091512 if len(got) != 1 || got[0] != a.ID {
15101513 t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID)
15111514 }
15121515
1513- // A terminal persisted agent remains visible from another host session
1514- // so a normal restart does not hide durable recovery state.
1516+ // A terminal persisted agent is hidden by the default session view, but
1517+ // remains available from the explicit all-sessions filter.
15151518 g.SetActiveSession("sess-other")
1516- if got := snapshotIDs(g.SnapshotAll()); len(got) != 1 || got[0] != a.ID {
1517- t.Errorf("scoped to other session, ids = %v; want [%s]", got, a.ID)
1519+ if got := snapshotIDs(g.SnapshotCurrentSession()); len(got) != 0 {
1520+ t.Errorf("current session scoped to other session, ids = %v; want none", got)
1521+ }
1522+ if got := snapshotIDs(g.SnapshotAllSessions()); len(got) != 1 || got[0] != a.ID {
1523+ t.Errorf("all sessions ids = %v; want [%s]", got, a.ID)
15181524 }
15191525 }
15201526
1521-// TestEmptySessionIDIsVisibleFromAnyScope pins the unscoped-agent
1522-// rule: agents whose meta.json predates the SessionID field (or who
1523-// were spawned without an active session, e.g. via a test rig or
1524-// scripted caller) carry SessionID == "" and remain visible from
1525-// every scope. Otherwise the schema bump would orphan every
1526-// pre-existing agent the moment a user upgraded zut.
1527-func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) {
1527+// TestEmptySessionIDIsHiddenFromScopedViews keeps historical unscoped
1528+// agents out of a session-specific dashboard while retaining them in the
1529+// explicit all-sessions view.
1530+func TestEmptySessionIDIsHiddenFromScopedViews(t *testing.T) {
15281531 root := t.TempDir()
15291532 f := New(Config{
15301533 Root: root, RepoRoot: root,
@@ -1541,11 +1544,17 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) {
15411544 t.Fatalf("unscoped spawn produced SessionID %q; want empty", a.SessionID)
15421545 }
15431546
1544- for _, scope := range []string{"", "any-session", "some-other"} {
1547+ f.SetActiveSession("")
1548+ if got := snapshotIDs(f.SnapshotCurrentSession()); len(got) != 1 || got[0] != a.ID {
1549+ t.Errorf("unscoped view ids = %v; want [%s]", got, a.ID)
1550+ }
1551+ for _, scope := range []string{"any-session", "some-other"} {
15451552 f.SetActiveSession(scope)
1546- got := snapshotIDs(f.SnapshotAll())
1547- if len(got) != 1 || got[0] != a.ID {
1548- t.Errorf("scope=%q: ids = %v; want unscoped agent visible", scope, got)
1553+ if got := snapshotIDs(f.SnapshotCurrentSession()); len(got) != 0 {
1554+ t.Errorf("scope=%q: ids = %v; want none", scope, got)
1555+ }
1556+ if got := snapshotIDs(f.SnapshotAllSessions()); len(got) != 1 || got[0] != a.ID {
1557+ t.Errorf("all sessions for scope=%q: ids = %v; want [%s]", scope, got, a.ID)
15491558 }
15501559 }
15511560
@@ -1411,12 +1411,9 @@ func TestRemoveReloadedAgentKeepsSiblingState(t *testing.T) {
1411 }1411 }
1412 }1412 }
1413 1413
1414-// TestActiveSessionScopesSnapshotAll proves the session filter the1414+// TestActiveSessionScopesSnapshotCurrentSession proves the dashboard's
1415-// user asked for: a single Supervisor with agents spawned under two1415+// default session filter. SnapshotAll remains the explicit cross-session view.
1416-// different active sessions only surfaces the agents matching the1416+func TestActiveSessionScopesSnapshotCurrentSession(t *testing.T) {
1417-// currently-active session via SnapshotAll. Switching the active
1418-// session re-narrows the view without touching agent state.
1419-func TestActiveSessionScopesSnapshotAll(t *testing.T) {
1420 root := t.TempDir()1417 root := t.TempDir()
1421 f := New(Config{1418 f := New(Config{
1422 Root: root, RepoRoot: root,1419 Root: root, RepoRoot: root,
@@ -1447,23 +1444,29 @@ func TestActiveSessionScopesSnapshotAll(t *testing.T) {
1447 }1444 }
1448 1445
1449 // Active session B → only aB visible.1446 // Active session B → only aB visible.
1450- only := snapshotIDs(f.SnapshotAll())1447+ only := snapshotIDs(f.SnapshotCurrentSession())
1451 if len(only) != 1 || only[0] != aB.ID {1448 if len(only) != 1 || only[0] != aB.ID {
1452 t.Errorf("scoped to sess-B, snapshot ids = %v; want [%s]", only, aB.ID)1449 t.Errorf("scoped to sess-B, snapshot ids = %v; want [%s]", only, aB.ID)
1453 }1450 }
1454 1451
1455 // Switch back to A → only aA visible.1452 // Switch back to A → only aA visible.
1456 f.SetActiveSession("sess-A")1453 f.SetActiveSession("sess-A")
1457- only = snapshotIDs(f.SnapshotAll())1454+ only = snapshotIDs(f.SnapshotCurrentSession())
1458 if len(only) != 1 || only[0] != aA.ID {1455 if len(only) != 1 || only[0] != aA.ID {
1459 t.Errorf("scoped to sess-A, snapshot ids = %v; want [%s]", only, aA.ID)1456 t.Errorf("scoped to sess-A, snapshot ids = %v; want [%s]", only, aA.ID)
1460 }1457 }
1461 1458
1462- // Clear the scope → both visible.1459+ // The All filter is independent of the active session.
1463- f.SetActiveSession("")1460+ all := snapshotIDs(f.SnapshotAllSessions())
1464- all := snapshotIDs(f.SnapshotAll())
1465 if len(all) != 2 {1461 if len(all) != 2 {
1466- t.Errorf("unscoped snapshot ids = %v; want both agents", all)1462+ t.Errorf("all-session snapshot ids = %v; want both agents", all)
1463+ }
1464+
1465+ // Clear the scope → both visible from the default view too.
1466+ f.SetActiveSession("")
1467+ only = snapshotIDs(f.SnapshotCurrentSession())
1468+ if len(only) != 2 {
1469+ t.Errorf("unscoped current-session ids = %v; want both agents", only)
1467 }1470 }
1468 1471
1469 // Cleanup.1472 // Cleanup.
@@ -1505,26 +1508,26 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) {
1505 t.Fatalf("reload loaded=%d errs=%v; want 1 / no errs", loaded, errs)1508 t.Fatalf("reload loaded=%d errs=%v; want 1 / no errs", loaded, errs)
1506 }1509 }
1507 g.SetActiveSession("sess-keep")1510 g.SetActiveSession("sess-keep")
1508- got := snapshotIDs(g.SnapshotAll())1511+ got := snapshotIDs(g.SnapshotCurrentSession())
1509 if len(got) != 1 || got[0] != a.ID {1512 if len(got) != 1 || got[0] != a.ID {
1510 t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID)1513 t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID)
1511 }1514 }
1512 1515
1513- // A terminal persisted agent remains visible from another host session1516+ // A terminal persisted agent is hidden by the default session view, but
1514- // so a normal restart does not hide durable recovery state.1517+ // remains available from the explicit all-sessions filter.
1515 g.SetActiveSession("sess-other")1518 g.SetActiveSession("sess-other")
1516- if got := snapshotIDs(g.SnapshotAll()); len(got) != 1 || got[0] != a.ID {1519+ if got := snapshotIDs(g.SnapshotCurrentSession()); len(got) != 0 {
1517- t.Errorf("scoped to other session, ids = %v; want [%s]", got, a.ID)1520+ t.Errorf("current session scoped to other session, ids = %v; want none", got)
1521+ }
1522+ if got := snapshotIDs(g.SnapshotAllSessions()); len(got) != 1 || got[0] != a.ID {
1523+ t.Errorf("all sessions ids = %v; want [%s]", got, a.ID)
1518 }1524 }
1519 }1525 }
1520 1526
1521-// TestEmptySessionIDIsVisibleFromAnyScope pins the unscoped-agent1527+// TestEmptySessionIDIsHiddenFromScopedViews keeps historical unscoped
1522-// rule: agents whose meta.json predates the SessionID field (or who1528+// agents out of a session-specific dashboard while retaining them in the
1523-// were spawned without an active session, e.g. via a test rig or1529+// explicit all-sessions view.
1524-// scripted caller) carry SessionID == "" and remain visible from1530+func TestEmptySessionIDIsHiddenFromScopedViews(t *testing.T) {
1525-// every scope. Otherwise the schema bump would orphan every
1526-// pre-existing agent the moment a user upgraded zut.
1527-func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) {
1528 root := t.TempDir()1531 root := t.TempDir()
1529 f := New(Config{1532 f := New(Config{
1530 Root: root, RepoRoot: root,1533 Root: root, RepoRoot: root,
@@ -1541,11 +1544,17 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) {
1541 t.Fatalf("unscoped spawn produced SessionID %q; want empty", a.SessionID)1544 t.Fatalf("unscoped spawn produced SessionID %q; want empty", a.SessionID)
1542 }1545 }
1543 1546
1544- for _, scope := range []string{"", "any-session", "some-other"} {1547+ f.SetActiveSession("")
1548+ if got := snapshotIDs(f.SnapshotCurrentSession()); len(got) != 1 || got[0] != a.ID {
1549+ t.Errorf("unscoped view ids = %v; want [%s]", got, a.ID)
1550+ }
1551+ for _, scope := range []string{"any-session", "some-other"} {
1545 f.SetActiveSession(scope)1552 f.SetActiveSession(scope)
1546- got := snapshotIDs(f.SnapshotAll())1553+ if got := snapshotIDs(f.SnapshotCurrentSession()); len(got) != 0 {
1547- if len(got) != 1 || got[0] != a.ID {1554+ t.Errorf("scope=%q: ids = %v; want none", scope, got)
1548- t.Errorf("scope=%q: ids = %v; want unscoped agent visible", scope, got)1555+ }
1556+ if got := snapshotIDs(f.SnapshotAllSessions()); len(got) != 1 || got[0] != a.ID {
1557+ t.Errorf("all sessions for scope=%q: ids = %v; want [%s]", scope, got, a.ID)
1549 }1558 }
1550 }1559 }
1551 1560
M packages/agent/subagents/runner.go +4 -0
@@ -1006,8 +1006,12 @@ func applyEventToSink(ev Event, sink Sink) {
10061006 case "tool_call", EventToolStarted:
10071007 if name, _ := ev.Data["name"].(string); name != "" {
10081008 sink.Activity("tool: " + truncate(name, 60))
1009+ sink.Transcript("tool: " + name)
10091010 }
10101011 case "tool_result", EventToolFinished:
1012+ // Tool output can contain credentials, terminal controls, and arbitrary
1013+ // binary text. The dashboard only needs to show that the call completed.
1014+ sink.Transcript("tool result: completed")
10111015 sink.Activity("idle")
10121016 case "turn_end", EventTurnResult, EventAgentIdle:
10131017 sink.Activity("idle")
@@ -1006,8 +1006,12 @@ func applyEventToSink(ev Event, sink Sink) {
1006 case "tool_call", EventToolStarted:1006 case "tool_call", EventToolStarted:
1007 if name, _ := ev.Data["name"].(string); name != "" {1007 if name, _ := ev.Data["name"].(string); name != "" {
1008 sink.Activity("tool: " + truncate(name, 60))1008 sink.Activity("tool: " + truncate(name, 60))
1009+ sink.Transcript("tool: " + name)
1009 }1010 }
1010 case "tool_result", EventToolFinished:1011 case "tool_result", EventToolFinished:
1012+ // Tool output can contain credentials, terminal controls, and arbitrary
1013+ // binary text. The dashboard only needs to show that the call completed.
1014+ sink.Transcript("tool result: completed")
1011 sink.Activity("idle")1015 sink.Activity("idle")
1012 case "turn_end", EventTurnResult, EventAgentIdle:1016 case "turn_end", EventTurnResult, EventAgentIdle:
1013 sink.Activity("idle")1017 sink.Activity("idle")
M packages/agent/subagents/runner_test.go +27 -0
@@ -8,6 +8,7 @@ import (
88 "os/exec"
99 "path/filepath"
1010 "runtime"
11+ "slices"
1112 "strings"
1213 "testing"
1314 "time"
@@ -800,6 +801,32 @@ func TestEventCounterRejectsNegativeFractionalAndOverflowValues(t *testing.T) {
800801 }
801802 }
802803
804+func TestApplyEventToSinkProjectsToolActivityIntoTranscript(t *testing.T) {
805+ events := []Event{
806+ {Type: EventToolStarted, Data: map[string]any{"name": "bash"}},
807+ {Type: EventToolFinished, Data: map[string]any{
808+ "content": []any{map[string]any{"text": "command output"}},
809+ }},
810+ }
811+ want := []string{"tool: bash", "tool result: completed"}
812+
813+ live := &Agent{}
814+ for _, event := range events {
815+ applyEventToSink(event, agentSink{a: live})
816+ }
817+ if got := live.Transcript(); !slices.Equal(got, want) {
818+ t.Fatalf("live tool transcript = %#v; want %#v", got, want)
819+ }
820+
821+ replayed := &Agent{}
822+ for _, event := range events {
823+ replayEventTranscript(replayed, event)
824+ }
825+ if got := replayed.Transcript(); !slices.Equal(got, want) {
826+ t.Fatalf("replayed tool transcript = %#v; want %#v", got, want)
827+ }
828+}
829+
803830 func indexOf(xs []string, want string) int {
804831 for i, x := range xs {
805832 if x == want {
@@ -8,6 +8,7 @@ import (
8 "os/exec"8 "os/exec"
9 "path/filepath"9 "path/filepath"
10 "runtime"10 "runtime"
11+ "slices"
11 "strings"12 "strings"
12 "testing"13 "testing"
13 "time"14 "time"
@@ -800,6 +801,32 @@ func TestEventCounterRejectsNegativeFractionalAndOverflowValues(t *testing.T) {
800 }801 }
801 }802 }
802 803
804+func TestApplyEventToSinkProjectsToolActivityIntoTranscript(t *testing.T) {
805+ events := []Event{
806+ {Type: EventToolStarted, Data: map[string]any{"name": "bash"}},
807+ {Type: EventToolFinished, Data: map[string]any{
808+ "content": []any{map[string]any{"text": "command output"}},
809+ }},
810+ }
811+ want := []string{"tool: bash", "tool result: completed"}
812+
813+ live := &Agent{}
814+ for _, event := range events {
815+ applyEventToSink(event, agentSink{a: live})
816+ }
817+ if got := live.Transcript(); !slices.Equal(got, want) {
818+ t.Fatalf("live tool transcript = %#v; want %#v", got, want)
819+ }
820+
821+ replayed := &Agent{}
822+ for _, event := range events {
823+ replayEventTranscript(replayed, event)
824+ }
825+ if got := replayed.Transcript(); !slices.Equal(got, want) {
826+ t.Fatalf("replayed tool transcript = %#v; want %#v", got, want)
827+ }
828+}
829+
803 func indexOf(xs []string, want string) int {830 func indexOf(xs []string, want string) int {
804 for i, x := range xs {831 for i, x := range xs {
805 if x == want {832 if x == want {
M packages/agent/subagents/supervisor.go +35 -21
@@ -1202,34 +1202,47 @@ func (a *Agent) Snapshot() AgentSnapshot {
12021202 }
12031203 }
12041204
1205-// SnapshotAll returns snapshots of every agent in creation order,
1206-// scoped to the active session when one is set.
1207-//
1208-// Scoping rules:
1209-// - activeSession == "": no filter, every agent is returned
1210-// (historical behaviour; used by tests and scripted callers).
1211-// - activeSession != "": include live agents whose SessionID matches
1212-// activeSession, plus unscoped agents. Detached and terminal
1213-// agents from another host session remain visible so restart/recovery
1214-// does not hide durable work.
1205+// SnapshotAll returns the supervisor's established status view: live agents
1206+// are scoped to the active session, while durable terminal entries remain
1207+// discoverable for recovery callers.
12151208 func (f *Supervisor) SnapshotAll() []AgentSnapshot {
1216- agents := f.List()
12171209 f.mu.Lock()
12181210 active := f.activeSession
12191211 f.mu.Unlock()
1212+ return f.snapshotMatching(func(a *Agent) bool {
1213+ if active == "" || a.SessionID == "" || a.SessionID == active {
1214+ return true
1215+ }
1216+ status := a.Status()
1217+ return status != StatusRunning && status != StatusPending
1218+ })
1219+}
1220+
1221+// SnapshotAllSessions returns every agent, regardless of host session. It is
1222+// the explicit cross-session view used by the dashboard's All filter.
1223+func (f *Supervisor) SnapshotAllSessions() []AgentSnapshot {
1224+ return f.snapshotMatching(func(*Agent) bool { return true })
1225+}
12201226
1227+// SnapshotCurrentSession returns only agents owned by the active host
1228+// session. When no active session is set, it includes every agent for
1229+// command-line and test callers that do not establish a session boundary.
1230+func (f *Supervisor) SnapshotCurrentSession() []AgentSnapshot {
1231+ f.mu.Lock()
1232+ active := f.activeSession
1233+ f.mu.Unlock()
1234+ return f.snapshotMatching(func(a *Agent) bool {
1235+ return active == "" || a.SessionID == active
1236+ })
1237+}
1238+
1239+func (f *Supervisor) snapshotMatching(include func(*Agent) bool) []AgentSnapshot {
1240+ agents := f.List()
12211241 out := make([]AgentSnapshot, 0, len(agents))
12221242 for _, a := range agents {
1223- if active != "" && a.SessionID != "" && a.SessionID != active {
1224- // Keep detached/terminal entries visible after a normal host
1225- // restart so durable recovery remains discoverable. Hide only
1226- // live work belonging to another in-process host session.
1227- status := a.Status()
1228- if status == StatusRunning || status == StatusPending {
1229- continue
1230- }
1243+ if include(a) {
1244+ out = append(out, a.Snapshot())
12311245 }
1232- out = append(out, a.Snapshot())
12331246 }
12341247 // Sort by start time for a stable, deterministic listing.
12351248 sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) })
@@ -1241,7 +1254,8 @@ type agentSink struct{ a *Agent }
12411254
12421255 func (s agentSink) Activity(msg string) { s.a.setActivity(msg) }
12431256 func (s agentSink) Transcript(chunk string) {
1244- if strings.HasPrefix(chunk, "stderr: ") || strings.HasPrefix(chunk, "error: ") {
1257+ if strings.HasPrefix(chunk, "stderr: ") || strings.HasPrefix(chunk, "error: ") ||
1258+ strings.HasPrefix(chunk, "tool: ") || strings.HasPrefix(chunk, "tool result: ") {
12451259 s.a.appendTranscript(chunk)
12461260 return
12471261 }
@@ -1202,34 +1202,47 @@ func (a *Agent) Snapshot() AgentSnapshot {
1202 }1202 }
1203 }1203 }
1204 1204
1205-// SnapshotAll returns snapshots of every agent in creation order,1205+// SnapshotAll returns the supervisor's established status view: live agents
1206-// scoped to the active session when one is set.1206+// are scoped to the active session, while durable terminal entries remain
1207-//1207+// discoverable for recovery callers.
1208-// Scoping rules:
1209-// - activeSession == "": no filter, every agent is returned
1210-// (historical behaviour; used by tests and scripted callers).
1211-// - activeSession != "": include live agents whose SessionID matches
1212-// activeSession, plus unscoped agents. Detached and terminal
1213-// agents from another host session remain visible so restart/recovery
1214-// does not hide durable work.
1215 func (f *Supervisor) SnapshotAll() []AgentSnapshot {1208 func (f *Supervisor) SnapshotAll() []AgentSnapshot {
1216- agents := f.List()
1217 f.mu.Lock()1209 f.mu.Lock()
1218 active := f.activeSession1210 active := f.activeSession
1219 f.mu.Unlock()1211 f.mu.Unlock()
1212+ return f.snapshotMatching(func(a *Agent) bool {
1213+ if active == "" || a.SessionID == "" || a.SessionID == active {
1214+ return true
1215+ }
1216+ status := a.Status()
1217+ return status != StatusRunning && status != StatusPending
1218+ })
1219+}
1220+
1221+// SnapshotAllSessions returns every agent, regardless of host session. It is
1222+// the explicit cross-session view used by the dashboard's All filter.
1223+func (f *Supervisor) SnapshotAllSessions() []AgentSnapshot {
1224+ return f.snapshotMatching(func(*Agent) bool { return true })
1225+}
1220 1226
1227+// SnapshotCurrentSession returns only agents owned by the active host
1228+// session. When no active session is set, it includes every agent for
1229+// command-line and test callers that do not establish a session boundary.
1230+func (f *Supervisor) SnapshotCurrentSession() []AgentSnapshot {
1231+ f.mu.Lock()
1232+ active := f.activeSession
1233+ f.mu.Unlock()
1234+ return f.snapshotMatching(func(a *Agent) bool {
1235+ return active == "" || a.SessionID == active
1236+ })
1237+}
1238+
1239+func (f *Supervisor) snapshotMatching(include func(*Agent) bool) []AgentSnapshot {
1240+ agents := f.List()
1221 out := make([]AgentSnapshot, 0, len(agents))1241 out := make([]AgentSnapshot, 0, len(agents))
1222 for _, a := range agents {1242 for _, a := range agents {
1223- if active != "" && a.SessionID != "" && a.SessionID != active {1243+ if include(a) {
1224- // Keep detached/terminal entries visible after a normal host1244+ out = append(out, a.Snapshot())
1225- // restart so durable recovery remains discoverable. Hide only
1226- // live work belonging to another in-process host session.
1227- status := a.Status()
1228- if status == StatusRunning || status == StatusPending {
1229- continue
1230- }
1231 }1245 }
1232- out = append(out, a.Snapshot())
1233 }1246 }
1234 // Sort by start time for a stable, deterministic listing.1247 // Sort by start time for a stable, deterministic listing.
1235 sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) })1248 sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) })
@@ -1241,7 +1254,8 @@ type agentSink struct{ a *Agent }
1241 1254
1242 func (s agentSink) Activity(msg string) { s.a.setActivity(msg) }1255 func (s agentSink) Activity(msg string) { s.a.setActivity(msg) }
1243 func (s agentSink) Transcript(chunk string) {1256 func (s agentSink) Transcript(chunk string) {
1244- if strings.HasPrefix(chunk, "stderr: ") || strings.HasPrefix(chunk, "error: ") {1257+ if strings.HasPrefix(chunk, "stderr: ") || strings.HasPrefix(chunk, "error: ") ||
1258+ strings.HasPrefix(chunk, "tool: ") || strings.HasPrefix(chunk, "tool result: ") {
1245 s.a.appendTranscript(chunk)1259 s.a.appendTranscript(chunk)
1246 return1260 return
1247 }1261 }

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close