detached 997f27c

feat(subagents): add session-scoped dashboard

commit
997f27c3519c53f62a2bd92bd52a7fa83bfbe377
author
brice · · 2026-08-11 03:28 UTC
parent
94b5351
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +414 -71
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
@@ -1937,6 +1937,15 @@ func (i *Interactive) redraw() {
19371937 i.sessionDialog.MaxRows = avail
19381938 dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
19391939 case i.subagentsDialog.Active():
1940+ // The dashboard is composed into the bottom-sticky dialog band.
1941+ // Reserve its leading gap, frame padding, one chat row, and the
1942+ // renderer margin so a long list or expanded snapshot never pushes
1943+ // its top rows out of the terminal viewport.
1944+ avail := rows - 4
1945+ if avail < 4 {
1946+ avail = 4
1947+ }
1948+ i.subagentsDialog.SetMaxRows(avail)
19401949 dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols)
19411950 case i.jumpDialog.Active():
19421951 dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
@@ -1937,6 +1937,15 @@ func (i *Interactive) redraw() {
1937 i.sessionDialog.MaxRows = avail1937 i.sessionDialog.MaxRows = avail
1938 dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)1938 dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
1939 case i.subagentsDialog.Active():1939 case i.subagentsDialog.Active():
1940+ // The dashboard is composed into the bottom-sticky dialog band.
1941+ // Reserve its leading gap, frame padding, one chat row, and the
1942+ // renderer margin so a long list or expanded snapshot never pushes
1943+ // its top rows out of the terminal viewport.
1944+ avail := rows - 4
1945+ if avail < 4 {
1946+ avail = 4
1947+ }
1948+ i.subagentsDialog.SetMaxRows(avail)
1940 dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols)1949 dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols)
1941 case i.jumpDialog.Active():1950 case i.jumpDialog.Active():
1942 dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)1951 dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
M packages/agent/modes/subagents_dialog.go +187 -13
@@ -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.
@@ -55,6 +60,7 @@ type subagentsDialog struct {
5560
5661 rows []subagents.AgentSnapshot
5762 cursor int
63+ maxRows int // total dialog rows, including its header and frame rule; zero is unbounded
5864 viewing bool // when true, show full transcript of the selected row
5965
6066 // transcriptSpin animates the busy hint shown above the inline
@@ -126,6 +132,22 @@ func (d *subagentsDialog) SetLineInput(enabled bool) {
126132 d.lineInput = enabled
127133 }
128134
135+// SetAllSnapshots supplies the cross-session snapshot source used when the
136+// user presses Tab. Open's snapshot source remains the default session view.
137+func (d *subagentsDialog) SetAllSnapshots(fn func() []subagents.AgentSnapshot) {
138+ d.allSnapshots = fn
139+}
140+
141+// SetMaxRows bounds the complete dashboard before the host composes its
142+// bottom-sticky dialog band. Four rows are needed for the frame, column
143+// heading, and selected agent; a non-positive value leaves it unbounded.
144+func (d *subagentsDialog) SetMaxRows(rows int) {
145+ if rows > 0 && rows < 4 {
146+ rows = 4
147+ }
148+ d.maxRows = rows
149+}
150+
129151 // SetLoadTranscript supplies the optional on-demand durable transcript loader.
130152 func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) {
131153 d.loadTranscript = fn
@@ -180,6 +202,8 @@ func (d *subagentsDialog) Open(
180202 d.active = true
181203 d.cursor = 0
182204 d.viewing = false
205+ d.showAll = false
206+ d.expandedID = ""
183207 d.spawning = false
184208 d.newTaskEd = nil
185209 d.fileSuggest = nil
@@ -467,17 +491,46 @@ func friendlySendErr(id string, err error) string {
467491 }
468492
469493 func (d *subagentsDialog) refresh() {
470- if d.snapshot == nil {
494+ snapshot := d.snapshot
495+ if d.showAll && d.allSnapshots != nil {
496+ snapshot = d.allSnapshots
497+ }
498+ if snapshot == nil {
471499 d.rows = nil
472500 return
473501 }
474- d.rows = d.snapshot()
502+
503+ selectedID := ""
504+ if selected := d.selected(); selected != nil {
505+ selectedID = selected.ID
506+ }
507+ d.rows = snapshot()
508+ if selectedID != "" {
509+ for i, row := range d.rows {
510+ if row.ID == selectedID {
511+ d.cursor = i
512+ break
513+ }
514+ }
515+ }
475516 if d.cursor >= len(d.rows) {
476517 d.cursor = len(d.rows) - 1
477518 }
478519 if d.cursor < 0 {
479520 d.cursor = 0
480521 }
522+ if d.expandedID != "" && d.rowByID(d.expandedID) == nil {
523+ d.expandedID = ""
524+ }
525+}
526+
527+func (d *subagentsDialog) rowByID(id string) *subagents.AgentSnapshot {
528+ for i := range d.rows {
529+ if d.rows[i].ID == id {
530+ return &d.rows[i]
531+ }
532+ }
533+ return nil
481534 }
482535
483536 // HandleKey advances state. Returns (closed, statusMsg, statusErr).
@@ -518,10 +571,22 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
518571 case tui.KeyUp:
519572 if d.cursor > 0 {
520573 d.cursor--
574+ d.expandedID = ""
521575 }
522576 case tui.KeyDown:
523577 if d.cursor < len(d.rows)-1 {
524578 d.cursor++
579+ d.expandedID = ""
580+ }
581+ case tui.KeyTab:
582+ d.showAll = !d.showAll
583+ d.expandedID = ""
584+ d.refresh()
585+ case tui.KeyLeft:
586+ d.collapseSelected()
587+ case tui.KeyRight:
588+ if err := d.expandSelected(); err != nil {
589+ return false, "", "load transcript: " + err.Error()
525590 }
526591 case tui.KeyEnter:
527592 if len(d.rows) > 0 {
@@ -544,6 +609,12 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
544609 case 'q':
545610 d.Close()
546611 return true, "", ""
612+ case 'h':
613+ d.collapseSelected()
614+ case 'l':
615+ if err := d.expandSelected(); err != nil {
616+ return false, "", "load transcript: " + err.Error()
617+ }
547618 case 'n':
548619 if d.spawn != nil {
549620 // Open the task editor directly. The model the new
@@ -604,6 +675,27 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
604675 return false, "", ""
605676 }
606677
678+func (d *subagentsDialog) expandSelected() error {
679+ a := d.selected()
680+ if a == nil {
681+ return nil
682+ }
683+ if d.loadTranscript != nil {
684+ if err := d.loadTranscript(a.ID); err != nil {
685+ return err
686+ }
687+ d.refresh()
688+ }
689+ d.expandedID = a.ID
690+ return nil
691+}
692+
693+func (d *subagentsDialog) collapseSelected() {
694+ if a := d.selected(); a != nil && d.expandedID == a.ID {
695+ d.expandedID = ""
696+ }
697+}
698+
607699 // handleViewingKey routes one keystroke while the transcript view
608700 // is on screen. The transcript view runs /btw-style: an inline
609701 // editor is always pinned at the bottom, the transcript flows in
@@ -1022,7 +1114,11 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
10221114 return d.renderTranscript(th, width)
10231115 }
10241116
1025- out := []string{frameHeader(th, "subagents (n new, p prompt, R resume, ↑/↓ move, enter view, k kill, r remove, esc close)", width)}
1117+ filter := "this session"
1118+ if d.showAll {
1119+ filter = "all sessions"
1120+ }
1121+ out := []string{frameHeader(th, "subagents · "+filter+" (tab filter, ↑/↓ select, ←/→ or h/l snapshot, enter view, esc close)", width)}
10261122 if d.prompting {
10271123 return d.renderPromptEditor(th, width, out)
10281124 }
@@ -1085,16 +1181,80 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
10851181 // Column header for readability.
10861182 header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY")
10871183 out = append(out, th.FGColor(th.Muted, header))
1184+ out = append(out, d.renderVisibleRows(th, width)...)
1185+ out = append(out, frameRule(th, width))
1186+ return out
1187+}
10881188
1089- for i, r := range d.rows {
1189+// renderVisibleRows keeps the dashboard inside the terminal-height budget.
1190+// The selected row is always rendered, which prevents a bottom-sticky dialog
1191+// from clipping the top rows (and leaving arrow-key selection invisible).
1192+func (d *subagentsDialog) renderVisibleRows(th tui.Theme, width int) []string {
1193+ budget := len(d.rows) * (dashboardSnapshotRows + 1)
1194+ if d.maxRows > 0 {
1195+ budget = d.maxRows - 3 // frame header, column heading, frame rule
1196+ if budget < 1 {
1197+ budget = 1
1198+ }
1199+ }
1200+
1201+ out := make([]string, 0, budget)
1202+ // Do not spend the only available body row on the indicator: the selected
1203+ // agent must remain visible even on an extremely short terminal.
1204+ if d.cursor > 0 && budget > 1 {
1205+ out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("↑ %d agents above", d.cursor)))
1206+ }
1207+ nextRow := d.cursor
1208+ for ; nextRow < len(d.rows) && len(out) < budget; nextRow++ {
1209+ r := d.rows[nextRow]
10901210 row := formatSupervisorRow(r, width-2)
1091- if i == d.cursor {
1211+ if nextRow == d.cursor {
10921212 out = append(out, th.PadHighlight(" "+row, width))
10931213 } else {
10941214 out = append(out, " "+th.FGColor(th.FG, row))
10951215 }
1216+ if r.ID == d.expandedID && len(out) < budget {
1217+ out = append(out, d.renderSnapshot(th, width, r, budget-len(out))...)
1218+ }
1219+ }
1220+ if nextRow < len(d.rows) && len(out) < budget {
1221+ out = append(out, " "+th.FGColor(th.Muted, "↓ more agents below"))
1222+ }
1223+ return out
1224+}
1225+
1226+// dashboardSnapshotRows is deliberately bounded so expanding a running agent
1227+// keeps the list usable while still exposing its current user, assistant, and
1228+// tool activity. Enter opens the complete transcript.
1229+const dashboardSnapshotRows = 12
1230+
1231+func (d *subagentsDialog) renderSnapshot(th tui.Theme, width int, a subagents.AgentSnapshot, remaining int) []string {
1232+ raw := a.Lines
1233+ if len(raw) == 0 && a.Tail != "" {
1234+ raw = strings.Split(a.Tail, "\n")
1235+ }
1236+ out := []string{" " + th.FGColor(th.Muted, "└─ live snapshot")}
1237+ if len(raw) == 0 || remaining <= 1 {
1238+ if remaining > 1 {
1239+ out = append(out, " "+th.FGColor(th.Muted, " (no activity yet)"))
1240+ }
1241+ return out
1242+ }
1243+ rows := renderSupervisorTranscriptBlocks(raw, th, width-4, d.compactMode)
1244+ limit := dashboardSnapshotRows
1245+ if limit > remaining-1 {
1246+ limit = remaining - 1
1247+ }
1248+ if len(rows) > limit {
1249+ rows = rows[len(rows)-limit:]
1250+ if limit > 1 {
1251+ out = append(out, " "+th.FGColor(th.Muted, " … earlier activity"))
1252+ rows = rows[1:]
1253+ }
1254+ }
1255+ for _, row := range rows {
1256+ out = append(out, " "+row)
10961257 }
1097- out = append(out, frameRule(th, width))
10981258 return out
10991259 }
11001260
@@ -1273,6 +1433,8 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c
12731433 const (
12741434 kindAssistant blockKind = iota
12751435 kindUser
1436+ kindTool
1437+ kindToolResult
12761438 kindStderr
12771439 kindError
12781440 )
@@ -1286,6 +1448,10 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c
12861448 switch {
12871449 case strings.HasPrefix(s, "user: "):
12881450 return kindUser, strings.TrimPrefix(s, "user: ")
1451+ case strings.HasPrefix(s, "tool: "):
1452+ return kindTool, strings.TrimPrefix(s, "tool: ")
1453+ case strings.HasPrefix(s, "tool result: "):
1454+ return kindToolResult, strings.TrimPrefix(s, "tool result: ")
12891455 case strings.HasPrefix(s, "stderr: "):
12901456 return kindStderr, strings.TrimPrefix(s, "stderr: ")
12911457 case strings.HasPrefix(s, "error: "):
@@ -1339,6 +1505,14 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c
13391505 case kindAssistant:
13401506 text := strings.Join(b.body, "\n")
13411507 out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...)
1508+ case kindTool:
1509+ for _, line := range b.body {
1510+ out = append(out, wrapDialogTextRows(th.FGColor(th.Accent, "⚙ "+line), width)...)
1511+ }
1512+ case kindToolResult:
1513+ for _, line := range b.body {
1514+ out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "↳ "+line), width)...)
1515+ }
13421516 case kindStderr:
13431517 for _, line := range b.body {
13441518 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.
@@ -55,6 +60,7 @@ type subagentsDialog struct {
55 60
56 rows []subagents.AgentSnapshot61 rows []subagents.AgentSnapshot
57 cursor int62 cursor int
63+ maxRows int // total dialog rows, including its header and frame rule; zero is unbounded
58 viewing bool // when true, show full transcript of the selected row64 viewing bool // when true, show full transcript of the selected row
59 65
60 // transcriptSpin animates the busy hint shown above the inline66 // transcriptSpin animates the busy hint shown above the inline
@@ -126,6 +132,22 @@ func (d *subagentsDialog) SetLineInput(enabled bool) {
126 d.lineInput = enabled132 d.lineInput = enabled
127 }133 }
128 134
135+// SetAllSnapshots supplies the cross-session snapshot source used when the
136+// user presses Tab. Open's snapshot source remains the default session view.
137+func (d *subagentsDialog) SetAllSnapshots(fn func() []subagents.AgentSnapshot) {
138+ d.allSnapshots = fn
139+}
140+
141+// SetMaxRows bounds the complete dashboard before the host composes its
142+// bottom-sticky dialog band. Four rows are needed for the frame, column
143+// heading, and selected agent; a non-positive value leaves it unbounded.
144+func (d *subagentsDialog) SetMaxRows(rows int) {
145+ if rows > 0 && rows < 4 {
146+ rows = 4
147+ }
148+ d.maxRows = rows
149+}
150+
129 // SetLoadTranscript supplies the optional on-demand durable transcript loader.151 // SetLoadTranscript supplies the optional on-demand durable transcript loader.
130 func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) {152 func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) {
131 d.loadTranscript = fn153 d.loadTranscript = fn
@@ -180,6 +202,8 @@ func (d *subagentsDialog) Open(
180 d.active = true202 d.active = true
181 d.cursor = 0203 d.cursor = 0
182 d.viewing = false204 d.viewing = false
205+ d.showAll = false
206+ d.expandedID = ""
183 d.spawning = false207 d.spawning = false
184 d.newTaskEd = nil208 d.newTaskEd = nil
185 d.fileSuggest = nil209 d.fileSuggest = nil
@@ -467,17 +491,46 @@ func friendlySendErr(id string, err error) string {
467 }491 }
468 492
469 func (d *subagentsDialog) refresh() {493 func (d *subagentsDialog) refresh() {
470- if d.snapshot == nil {494+ snapshot := d.snapshot
495+ if d.showAll && d.allSnapshots != nil {
496+ snapshot = d.allSnapshots
497+ }
498+ if snapshot == nil {
471 d.rows = nil499 d.rows = nil
472 return500 return
473 }501 }
474- d.rows = d.snapshot()502+
503+ selectedID := ""
504+ if selected := d.selected(); selected != nil {
505+ selectedID = selected.ID
506+ }
507+ d.rows = snapshot()
508+ if selectedID != "" {
509+ for i, row := range d.rows {
510+ if row.ID == selectedID {
511+ d.cursor = i
512+ break
513+ }
514+ }
515+ }
475 if d.cursor >= len(d.rows) {516 if d.cursor >= len(d.rows) {
476 d.cursor = len(d.rows) - 1517 d.cursor = len(d.rows) - 1
477 }518 }
478 if d.cursor < 0 {519 if d.cursor < 0 {
479 d.cursor = 0520 d.cursor = 0
480 }521 }
522+ if d.expandedID != "" && d.rowByID(d.expandedID) == nil {
523+ d.expandedID = ""
524+ }
525+}
526+
527+func (d *subagentsDialog) rowByID(id string) *subagents.AgentSnapshot {
528+ for i := range d.rows {
529+ if d.rows[i].ID == id {
530+ return &d.rows[i]
531+ }
532+ }
533+ return nil
481 }534 }
482 535
483 // HandleKey advances state. Returns (closed, statusMsg, statusErr).536 // HandleKey advances state. Returns (closed, statusMsg, statusErr).
@@ -518,10 +571,22 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
518 case tui.KeyUp:571 case tui.KeyUp:
519 if d.cursor > 0 {572 if d.cursor > 0 {
520 d.cursor--573 d.cursor--
574+ d.expandedID = ""
521 }575 }
522 case tui.KeyDown:576 case tui.KeyDown:
523 if d.cursor < len(d.rows)-1 {577 if d.cursor < len(d.rows)-1 {
524 d.cursor++578 d.cursor++
579+ d.expandedID = ""
580+ }
581+ case tui.KeyTab:
582+ d.showAll = !d.showAll
583+ d.expandedID = ""
584+ d.refresh()
585+ case tui.KeyLeft:
586+ d.collapseSelected()
587+ case tui.KeyRight:
588+ if err := d.expandSelected(); err != nil {
589+ return false, "", "load transcript: " + err.Error()
525 }590 }
526 case tui.KeyEnter:591 case tui.KeyEnter:
527 if len(d.rows) > 0 {592 if len(d.rows) > 0 {
@@ -544,6 +609,12 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
544 case 'q':609 case 'q':
545 d.Close()610 d.Close()
546 return true, "", ""611 return true, "", ""
612+ case 'h':
613+ d.collapseSelected()
614+ case 'l':
615+ if err := d.expandSelected(); err != nil {
616+ return false, "", "load transcript: " + err.Error()
617+ }
547 case 'n':618 case 'n':
548 if d.spawn != nil {619 if d.spawn != nil {
549 // Open the task editor directly. The model the new620 // Open the task editor directly. The model the new
@@ -604,6 +675,27 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string)
604 return false, "", ""675 return false, "", ""
605 }676 }
606 677
678+func (d *subagentsDialog) expandSelected() error {
679+ a := d.selected()
680+ if a == nil {
681+ return nil
682+ }
683+ if d.loadTranscript != nil {
684+ if err := d.loadTranscript(a.ID); err != nil {
685+ return err
686+ }
687+ d.refresh()
688+ }
689+ d.expandedID = a.ID
690+ return nil
691+}
692+
693+func (d *subagentsDialog) collapseSelected() {
694+ if a := d.selected(); a != nil && d.expandedID == a.ID {
695+ d.expandedID = ""
696+ }
697+}
698+
607 // handleViewingKey routes one keystroke while the transcript view699 // handleViewingKey routes one keystroke while the transcript view
608 // is on screen. The transcript view runs /btw-style: an inline700 // is on screen. The transcript view runs /btw-style: an inline
609 // editor is always pinned at the bottom, the transcript flows in701 // editor is always pinned at the bottom, the transcript flows in
@@ -1022,7 +1114,11 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
1022 return d.renderTranscript(th, width)1114 return d.renderTranscript(th, width)
1023 }1115 }
1024 1116
1025- out := []string{frameHeader(th, "subagents (n new, p prompt, R resume, ↑/↓ move, enter view, k kill, r remove, esc close)", width)}1117+ filter := "this session"
1118+ if d.showAll {
1119+ filter = "all sessions"
1120+ }
1121+ out := []string{frameHeader(th, "subagents · "+filter+" (tab filter, ↑/↓ select, ←/→ or h/l snapshot, enter view, esc close)", width)}
1026 if d.prompting {1122 if d.prompting {
1027 return d.renderPromptEditor(th, width, out)1123 return d.renderPromptEditor(th, width, out)
1028 }1124 }
@@ -1085,16 +1181,80 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
1085 // Column header for readability.1181 // Column header for readability.
1086 header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY")1182 header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY")
1087 out = append(out, th.FGColor(th.Muted, header))1183 out = append(out, th.FGColor(th.Muted, header))
1184+ out = append(out, d.renderVisibleRows(th, width)...)
1185+ out = append(out, frameRule(th, width))
1186+ return out
1187+}
1088 1188
1089- for i, r := range d.rows {1189+// renderVisibleRows keeps the dashboard inside the terminal-height budget.
1190+// The selected row is always rendered, which prevents a bottom-sticky dialog
1191+// from clipping the top rows (and leaving arrow-key selection invisible).
1192+func (d *subagentsDialog) renderVisibleRows(th tui.Theme, width int) []string {
1193+ budget := len(d.rows) * (dashboardSnapshotRows + 1)
1194+ if d.maxRows > 0 {
1195+ budget = d.maxRows - 3 // frame header, column heading, frame rule
1196+ if budget < 1 {
1197+ budget = 1
1198+ }
1199+ }
1200+
1201+ out := make([]string, 0, budget)
1202+ // Do not spend the only available body row on the indicator: the selected
1203+ // agent must remain visible even on an extremely short terminal.
1204+ if d.cursor > 0 && budget > 1 {
1205+ out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("↑ %d agents above", d.cursor)))
1206+ }
1207+ nextRow := d.cursor
1208+ for ; nextRow < len(d.rows) && len(out) < budget; nextRow++ {
1209+ r := d.rows[nextRow]
1090 row := formatSupervisorRow(r, width-2)1210 row := formatSupervisorRow(r, width-2)
1091- if i == d.cursor {1211+ if nextRow == d.cursor {
1092 out = append(out, th.PadHighlight(" "+row, width))1212 out = append(out, th.PadHighlight(" "+row, width))
1093 } else {1213 } else {
1094 out = append(out, " "+th.FGColor(th.FG, row))1214 out = append(out, " "+th.FGColor(th.FG, row))
1095 }1215 }
1216+ if r.ID == d.expandedID && len(out) < budget {
1217+ out = append(out, d.renderSnapshot(th, width, r, budget-len(out))...)
1218+ }
1219+ }
1220+ if nextRow < len(d.rows) && len(out) < budget {
1221+ out = append(out, " "+th.FGColor(th.Muted, "↓ more agents below"))
1222+ }
1223+ return out
1224+}
1225+
1226+// dashboardSnapshotRows is deliberately bounded so expanding a running agent
1227+// keeps the list usable while still exposing its current user, assistant, and
1228+// tool activity. Enter opens the complete transcript.
1229+const dashboardSnapshotRows = 12
1230+
1231+func (d *subagentsDialog) renderSnapshot(th tui.Theme, width int, a subagents.AgentSnapshot, remaining int) []string {
1232+ raw := a.Lines
1233+ if len(raw) == 0 && a.Tail != "" {
1234+ raw = strings.Split(a.Tail, "\n")
1235+ }
1236+ out := []string{" " + th.FGColor(th.Muted, "└─ live snapshot")}
1237+ if len(raw) == 0 || remaining <= 1 {
1238+ if remaining > 1 {
1239+ out = append(out, " "+th.FGColor(th.Muted, " (no activity yet)"))
1240+ }
1241+ return out
1242+ }
1243+ rows := renderSupervisorTranscriptBlocks(raw, th, width-4, d.compactMode)
1244+ limit := dashboardSnapshotRows
1245+ if limit > remaining-1 {
1246+ limit = remaining - 1
1247+ }
1248+ if len(rows) > limit {
1249+ rows = rows[len(rows)-limit:]
1250+ if limit > 1 {
1251+ out = append(out, " "+th.FGColor(th.Muted, " … earlier activity"))
1252+ rows = rows[1:]
1253+ }
1254+ }
1255+ for _, row := range rows {
1256+ out = append(out, " "+row)
1096 }1257 }
1097- out = append(out, frameRule(th, width))
1098 return out1258 return out
1099 }1259 }
1100 1260
@@ -1273,6 +1433,8 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c
1273 const (1433 const (
1274 kindAssistant blockKind = iota1434 kindAssistant blockKind = iota
1275 kindUser1435 kindUser
1436+ kindTool
1437+ kindToolResult
1276 kindStderr1438 kindStderr
1277 kindError1439 kindError
1278 )1440 )
@@ -1286,6 +1448,10 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c
1286 switch {1448 switch {
1287 case strings.HasPrefix(s, "user: "):1449 case strings.HasPrefix(s, "user: "):
1288 return kindUser, strings.TrimPrefix(s, "user: ")1450 return kindUser, strings.TrimPrefix(s, "user: ")
1451+ case strings.HasPrefix(s, "tool: "):
1452+ return kindTool, strings.TrimPrefix(s, "tool: ")
1453+ case strings.HasPrefix(s, "tool result: "):
1454+ return kindToolResult, strings.TrimPrefix(s, "tool result: ")
1289 case strings.HasPrefix(s, "stderr: "):1455 case strings.HasPrefix(s, "stderr: "):
1290 return kindStderr, strings.TrimPrefix(s, "stderr: ")1456 return kindStderr, strings.TrimPrefix(s, "stderr: ")
1291 case strings.HasPrefix(s, "error: "):1457 case strings.HasPrefix(s, "error: "):
@@ -1339,6 +1505,14 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c
1339 case kindAssistant:1505 case kindAssistant:
1340 text := strings.Join(b.body, "\n")1506 text := strings.Join(b.body, "\n")
1341 out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...)1507 out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...)
1508+ case kindTool:
1509+ for _, line := range b.body {
1510+ out = append(out, wrapDialogTextRows(th.FGColor(th.Accent, "⚙ "+line), width)...)
1511+ }
1512+ case kindToolResult:
1513+ for _, line := range b.body {
1514+ out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "↳ "+line), width)...)
1515+ }
1342 case kindStderr:1516 case kindStderr:
1343 for _, line := range b.body {1517 for _, line := range b.body {
1344 out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "stderr "+line), width)...)1518 out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "stderr "+line), width)...)
M packages/agent/modes/subagents_dialog_test.go +72 -0
@@ -1319,6 +1319,78 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
13191319 }
13201320 }
13211321
1322+func TestSubagentsDialogTabTogglesSessionScope(t *testing.T) {
1323+ now := time.Now()
1324+ current := staticSnapshots(
1325+ subagents.AgentSnapshot{ID: "current-1", Status: subagents.StatusRunning, Started: now},
1326+ subagents.AgentSnapshot{ID: "current-2", Status: subagents.StatusDone, Started: now},
1327+ )
1328+ all := staticSnapshots(
1329+ subagents.AgentSnapshot{ID: "current-1", Status: subagents.StatusRunning, Started: now},
1330+ subagents.AgentSnapshot{ID: "current-2", Status: subagents.StatusDone, Started: now},
1331+ subagents.AgentSnapshot{ID: "other-session", Status: subagents.StatusDone, Started: now},
1332+ )
1333+ d := newSubagentsDialog()
1334+ d.SetAllSnapshots(all)
1335+ d.Open(current, nil, nil, nil, nil, nil, "")
1336+
1337+ out := strings.Join(d.Render(tui.Theme{}, 100), "\n")
1338+ if strings.Contains(out, "other-session") || !strings.Contains(out, "this session") {
1339+ t.Fatalf("default view is not restricted to the current session:\n%s", out)
1340+ }
1341+
1342+ d.HandleKey(tui.Key{Kind: tui.KeyTab})
1343+ out = strings.Join(d.Render(tui.Theme{}, 100), "\n")
1344+ if !strings.Contains(out, "other-session") || !strings.Contains(out, "all sessions") {
1345+ t.Fatalf("Tab did not switch to the all-sessions view:\n%s", out)
1346+ }
1347+}
1348+
1349+func TestSubagentsDialogNavigatesAndExpandsLiveSnapshotWithinHeight(t *testing.T) {
1350+ now := time.Now()
1351+ rows := []subagents.AgentSnapshot{
1352+ {ID: "first", Status: subagents.StatusDone, Started: now},
1353+ {ID: "active", Status: subagents.StatusRunning, Started: now, Lines: []string{
1354+ "user: inspect the dashboard", "assistant: I am checking the list", "tool: bash", "tool result: dashboard source", "assistant: rendering snapshot",
1355+ }},
1356+ {ID: "third", Status: subagents.StatusDone, Started: now},
1357+ }
1358+ d := newSubagentsDialog()
1359+ d.SetMaxRows(20)
1360+ d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
1361+ _ = d.Render(tui.Theme{}, 100)
1362+
1363+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
1364+ if selected := d.selected(); selected == nil || selected.ID != "active" {
1365+ t.Fatalf("down did not select active agent: %#v", selected)
1366+ }
1367+ d.HandleKey(tui.Key{Kind: tui.KeyRight})
1368+ out := d.Render(tui.Theme{}, 100)
1369+ joined := strings.Join(out, "\n")
1370+ for _, want := range []string{"live snapshot", "inspect the dashboard", "bash", "dashboard source"} {
1371+ if !strings.Contains(joined, want) {
1372+ t.Errorf("expanded snapshot missing %q:\n%s", want, joined)
1373+ }
1374+ }
1375+ if len(out) > 20 {
1376+ t.Fatalf("dashboard rendered %d rows with 20-row budget:\n%s", len(out), joined)
1377+ }
1378+
1379+ d.HandleKey(tui.Key{Kind: tui.KeyLeft})
1380+ out = d.Render(tui.Theme{}, 100)
1381+ if strings.Contains(strings.Join(out, "\n"), "live snapshot") {
1382+ t.Fatalf("left did not collapse selected snapshot:\n%s", strings.Join(out, "\n"))
1383+ }
1384+
1385+ // Even the smallest supported dashboard retains the selected row instead
1386+ // of allowing the bottom-sticky compositor to clip it above the viewport.
1387+ d.SetMaxRows(4)
1388+ out = d.Render(tui.Theme{}, 100)
1389+ if len(out) != 4 || !strings.Contains(strings.Join(out, "\n"), "active") {
1390+ t.Fatalf("short dashboard lost its selected row (%d rows):\n%s", len(out), strings.Join(out, "\n"))
1391+ }
1392+}
1393+
13221394 func TestStatusLabelCoversAll(t *testing.T) {
13231395 for _, s := range []subagents.Status{
13241396 subagents.StatusPending, subagents.StatusRunning,
@@ -1319,6 +1319,78 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
1319 }1319 }
1320 }1320 }
1321 1321
1322+func TestSubagentsDialogTabTogglesSessionScope(t *testing.T) {
1323+ now := time.Now()
1324+ current := staticSnapshots(
1325+ subagents.AgentSnapshot{ID: "current-1", Status: subagents.StatusRunning, Started: now},
1326+ subagents.AgentSnapshot{ID: "current-2", Status: subagents.StatusDone, Started: now},
1327+ )
1328+ all := staticSnapshots(
1329+ subagents.AgentSnapshot{ID: "current-1", Status: subagents.StatusRunning, Started: now},
1330+ subagents.AgentSnapshot{ID: "current-2", Status: subagents.StatusDone, Started: now},
1331+ subagents.AgentSnapshot{ID: "other-session", Status: subagents.StatusDone, Started: now},
1332+ )
1333+ d := newSubagentsDialog()
1334+ d.SetAllSnapshots(all)
1335+ d.Open(current, nil, nil, nil, nil, nil, "")
1336+
1337+ out := strings.Join(d.Render(tui.Theme{}, 100), "\n")
1338+ if strings.Contains(out, "other-session") || !strings.Contains(out, "this session") {
1339+ t.Fatalf("default view is not restricted to the current session:\n%s", out)
1340+ }
1341+
1342+ d.HandleKey(tui.Key{Kind: tui.KeyTab})
1343+ out = strings.Join(d.Render(tui.Theme{}, 100), "\n")
1344+ if !strings.Contains(out, "other-session") || !strings.Contains(out, "all sessions") {
1345+ t.Fatalf("Tab did not switch to the all-sessions view:\n%s", out)
1346+ }
1347+}
1348+
1349+func TestSubagentsDialogNavigatesAndExpandsLiveSnapshotWithinHeight(t *testing.T) {
1350+ now := time.Now()
1351+ rows := []subagents.AgentSnapshot{
1352+ {ID: "first", Status: subagents.StatusDone, Started: now},
1353+ {ID: "active", Status: subagents.StatusRunning, Started: now, Lines: []string{
1354+ "user: inspect the dashboard", "assistant: I am checking the list", "tool: bash", "tool result: dashboard source", "assistant: rendering snapshot",
1355+ }},
1356+ {ID: "third", Status: subagents.StatusDone, Started: now},
1357+ }
1358+ d := newSubagentsDialog()
1359+ d.SetMaxRows(20)
1360+ d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
1361+ _ = d.Render(tui.Theme{}, 100)
1362+
1363+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
1364+ if selected := d.selected(); selected == nil || selected.ID != "active" {
1365+ t.Fatalf("down did not select active agent: %#v", selected)
1366+ }
1367+ d.HandleKey(tui.Key{Kind: tui.KeyRight})
1368+ out := d.Render(tui.Theme{}, 100)
1369+ joined := strings.Join(out, "\n")
1370+ for _, want := range []string{"live snapshot", "inspect the dashboard", "bash", "dashboard source"} {
1371+ if !strings.Contains(joined, want) {
1372+ t.Errorf("expanded snapshot missing %q:\n%s", want, joined)
1373+ }
1374+ }
1375+ if len(out) > 20 {
1376+ t.Fatalf("dashboard rendered %d rows with 20-row budget:\n%s", len(out), joined)
1377+ }
1378+
1379+ d.HandleKey(tui.Key{Kind: tui.KeyLeft})
1380+ out = d.Render(tui.Theme{}, 100)
1381+ if strings.Contains(strings.Join(out, "\n"), "live snapshot") {
1382+ t.Fatalf("left did not collapse selected snapshot:\n%s", strings.Join(out, "\n"))
1383+ }
1384+
1385+ // Even the smallest supported dashboard retains the selected row instead
1386+ // of allowing the bottom-sticky compositor to clip it above the viewport.
1387+ d.SetMaxRows(4)
1388+ out = d.Render(tui.Theme{}, 100)
1389+ if len(out) != 4 || !strings.Contains(strings.Join(out, "\n"), "active") {
1390+ t.Fatalf("short dashboard lost its selected row (%d rows):\n%s", len(out), strings.Join(out, "\n"))
1391+ }
1392+}
1393+
1322 func TestStatusLabelCoversAll(t *testing.T) {1394 func TestStatusLabelCoversAll(t *testing.T) {
1323 for _, s := range []subagents.Status{1395 for _, s := range []subagents.Status{
1324 subagents.StatusPending, subagents.StatusRunning,1396 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 +12 -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,14 @@ 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+ if text := eventContentText(ev.Data); text != "" {
794+ a.appendTranscript("tool result: " + truncate(strings.ReplaceAll(text, "\n", " "), 240))
795+ }
791796 case "stdout":
792797 if txt, _ := ev.Data["text"].(string); txt != "" {
793798 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,14 @@ 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+ if text := eventContentText(ev.Data); text != "" {
794+ a.appendTranscript("tool result: " + truncate(strings.ReplaceAll(text, "\n", " "), 240))
795+ }
791 case "stdout":796 case "stdout":
792 if txt, _ := ev.Data["text"].(string); txt != "" {797 if txt, _ := ev.Data["text"].(string); txt != "" {
793 a.appendTranscript(txt)798 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 +30 -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+ if text := eventContentText(ev.Data); text != "" {
1013+ sink.Transcript("tool result: " + truncate(strings.ReplaceAll(text, "\n", " "), 240))
1014+ }
10111015 sink.Activity("idle")
10121016 case "turn_end", EventTurnResult, EventAgentIdle:
10131017 sink.Activity("idle")
@@ -1024,6 +1028,32 @@ func applyEventToSink(ev Event, sink Sink) {
10241028 }
10251029 }
10261030
1031+// eventContentText extracts the human-readable text carried by a tool-result
1032+// event. The event stream is permissive across provider adapters, so accept
1033+// both the canonical block array and simple string payloads.
1034+func eventContentText(data map[string]any) string {
1035+ for _, key := range []string{"content", "output", "text"} {
1036+ switch value := data[key].(type) {
1037+ case string:
1038+ if value != "" {
1039+ return value
1040+ }
1041+ case []any:
1042+ var text []string
1043+ for _, item := range value {
1044+ block, _ := item.(map[string]any)
1045+ if value, _ := block["text"].(string); value != "" {
1046+ text = append(text, value)
1047+ }
1048+ }
1049+ if len(text) > 0 {
1050+ return strings.Join(text, "\n")
1051+ }
1052+ }
1053+ }
1054+ return ""
1055+}
1056+
10271057 // RunnerFunc adapts a plain function into a Runner. Useful for tests
10281058 // and for callers who don't need their own type.
10291059 type RunnerFunc func(ctx context.Context, sink Sink) error
@@ -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+ if text := eventContentText(ev.Data); text != "" {
1013+ sink.Transcript("tool result: " + truncate(strings.ReplaceAll(text, "\n", " "), 240))
1014+ }
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")
@@ -1024,6 +1028,32 @@ func applyEventToSink(ev Event, sink Sink) {
1024 }1028 }
1025 }1029 }
1026 1030
1031+// eventContentText extracts the human-readable text carried by a tool-result
1032+// event. The event stream is permissive across provider adapters, so accept
1033+// both the canonical block array and simple string payloads.
1034+func eventContentText(data map[string]any) string {
1035+ for _, key := range []string{"content", "output", "text"} {
1036+ switch value := data[key].(type) {
1037+ case string:
1038+ if value != "" {
1039+ return value
1040+ }
1041+ case []any:
1042+ var text []string
1043+ for _, item := range value {
1044+ block, _ := item.(map[string]any)
1045+ if value, _ := block["text"].(string); value != "" {
1046+ text = append(text, value)
1047+ }
1048+ }
1049+ if len(text) > 0 {
1050+ return strings.Join(text, "\n")
1051+ }
1052+ }
1053+ }
1054+ return ""
1055+}
1056+
1027 // RunnerFunc adapts a plain function into a Runner. Useful for tests1057 // RunnerFunc adapts a plain function into a Runner. Useful for tests
1028 // and for callers who don't need their own type.1058 // and for callers who don't need their own type.
1029 type RunnerFunc func(ctx context.Context, sink Sink) error1059 type RunnerFunc func(ctx context.Context, sink Sink) error
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: command output"}
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: command output"}
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