diff --git a/README.md b/README.md index a103619..7b464bc 100644 --- a/README.md +++ b/README.md @@ -205,13 +205,15 @@ command and press `⏎`. | `/model [name]` | Switch model (opens a picker with no argument) | | `/thinking [on\|off]` | Toggle extended thinking for the next turn | | `/cancel` | Cancel the running turn | +| `/stop ` | Stop one running sub-agent (bare `/stop` lists them) | +| `/agents` | Sub-agent registry — recent delegated tasks (drawer tab) | | `/attach ` | Stage a file to send with the next prompt (5 MB each, 10 MB total) | | `/unattach [name]` | Drop staged files (all when no name given) | | `/quit` | Exit bodek | ### The management drawer -`/sessions`, `/runs`, `/events`, `/plan`, `/memory`, `/skills`, `/tools`, and +`/sessions`, `/runs`, `/agents`, `/events`, `/plan`, `/memory`, `/skills`, `/tools`, and `/config` all open tabs of **one drawer** with a shared grammar: - `]` / `[` cycle tabs · `1`–`8` jump · `r` refresh · `esc` closes. @@ -303,6 +305,11 @@ collect an approval for a prompt the engine already abandoned. drives live cards — step, tool, iterations, tokens, duration — and terminal status glyphs (`✓` success, `◐` partial, `✗` error, `⊘` cancelled, `⏱` timeout), with a `1/2 agents · 6.3k tok` rollup on the collapsed line. + Framed delegate results render as a structured card — status, summary, + changed files, and usage — while prose results keep the generic preview. + `ctrl+s` (or `/stop `, two-step confirmed) stops one running + sub-agent, and the `/agents` drawer tab lists the serve instance's + registry snapshot — recent delegated tasks with goal, status, and usage. - **Security approvals** — odek's `danger` engine prompts surface as an inline panel; your answer is sent straight back over the socket. - **Live reasoning** — the model's pre-tool thinking streams in dimmed text, diff --git a/internal/client/client.go b/internal/client/client.go index 245ccc0..8587838 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -71,9 +71,11 @@ type Event struct { TimeoutSeconds int `json:"timeout_seconds,omitempty"` // approval_ack (Action echoes the client's reply) / cancelled (Idle is - // true when nothing was running for the target session). - Action string `json:"action"` - Idle bool `json:"idle"` + // true when nothing was running for the target session) / + // subagent_cancelled (Accepted:false is a benign race — task already done). + Action string `json:"action"` + Idle bool `json:"idle"` + Accepted bool `json:"accepted"` // server_info / pong snapshot. T is the pong timestamp (unix ms); the // client measures round-trip latency from its own send clock instead. @@ -267,6 +269,20 @@ func (c *Client) SendCancel(sessionID, authToken string) error { }{Type: "cancel", SessionID: sessionID, AuthToken: authToken}) } +// SendSubagentCancel stops ONE running sub-agent by task id over the +// WebSocket (handled inline by the socket reader, so it works while +// delegate_tasks occupies the prompt processor; session-scoped auth). The +// subagent_cancelled ack replies; the card's terminal state arrives as a +// subagent_state transition. +func (c *Client) SendSubagentCancel(sessionID, authToken, taskID string) error { + return c.send(struct { + Type string `json:"type"` + SessionID string `json:"session_id"` + AuthToken string `json:"auth_token,omitempty"` + TaskID string `json:"task_id"` + }{Type: "subagent_cancel", SessionID: sessionID, AuthToken: authToken, TaskID: taskID}) +} + // SessionSwitch adopts an existing session without sending a prompt: the // connection's agent restores the session's memory buffer and the server // emits the standard `session` event. diff --git a/internal/client/rest.go b/internal/client/rest.go index 67b026f..78ac4ba 100644 --- a/internal/client/rest.go +++ b/internal/client/rest.go @@ -300,6 +300,41 @@ func (c *Client) Cancel(sessionID, token string) error { // ── low-level helpers ──────────────────────────────────────────────────────── +// SubagentEntry is one delegated task's lifecycle record from the serve +// registry snapshot (GET /api/subagents). +type SubagentEntry struct { + TaskID string `json:"task_id"` + RunKey string `json:"run_key"` + Goal string `json:"goal,omitempty"` + Status string `json:"status,omitempty"` + Phase string `json:"phase"` + PID int `json:"pid,omitempty"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at,omitempty"` + Iterations int `json:"iterations,omitempty"` + Step int `json:"step,omitempty"` + LastTool string `json:"last_tool,omitempty"` + DurationSeconds float64 `json:"duration_seconds,omitempty"` + TokensUsed int `json:"tokens_used,omitempty"` +} + +// Subagents fetches the sub-agent registry snapshot, optionally filtered by +// run key. Auth mirrors the other instance-level GETs. +func (c *Client) Subagents(runKey string) ([]SubagentEntry, error) { + u := c.baseURL + "/api/subagents" + if runKey != "" { + u += "?key=" + url.QueryEscape(runKey) + } + var out struct { + Entries []SubagentEntry `json:"entries"` + Count int `json:"count"` + } + if err := c.getJSON(u, "", &out); err != nil { + return nil, err + } + return out.Entries, nil +} + func (c *Client) do(method, u, sessionToken string) (*http.Response, error) { req, err := http.NewRequest(method, u, nil) if err != nil { diff --git a/internal/client/subagent_cancel_test.go b/internal/client/subagent_cancel_test.go new file mode 100644 index 0000000..fe34d05 --- /dev/null +++ b/internal/client/subagent_cancel_test.go @@ -0,0 +1,61 @@ +package client + +import ( + "encoding/json" + "net/http" + "sync" + "testing" + "time" + + ws "golang.org/x/net/websocket" +) + +// TestSendSubagentCancelFrame pins the wire shape of the per-agent stop: +// session-scoped auth plus the task id, mirroring the cancel frame. +func TestSendSubagentCancelFrame(t *testing.T) { + var mu sync.Mutex + var frames []map[string]any + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { + for { + var data []byte + if err := ws.Message.Receive(c, &data); err != nil { + return + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + continue + } + mu.Lock() + frames = append(frames, m) + mu.Unlock() + } + })) + cl, _ := newTestServer(t, mux) + + if err := cl.SendSubagentCancel("s1", "a1", "task-uuid"); err != nil { + t.Fatalf("SendSubagentCancel: %v", err) + } + + deadline := time.After(3 * time.Second) + for { + mu.Lock() + n := len(frames) + mu.Unlock() + if n >= 1 { + break + } + select { + case <-deadline: + t.Fatal("no frame arrived") + case <-time.After(20 * time.Millisecond): + } + } + + mu.Lock() + defer mu.Unlock() + f := frames[0] + if f["type"] != "subagent_cancel" || f["session_id"] != "s1" || f["auth_token"] != "a1" || f["task_id"] != "task-uuid" { + t.Errorf("subagent_cancel frame = %v", f) + } +} diff --git a/internal/client/subagents_rest_test.go b/internal/client/subagents_rest_test.go new file mode 100644 index 0000000..ae560bd --- /dev/null +++ b/internal/client/subagents_rest_test.go @@ -0,0 +1,51 @@ +package client + +import ( + "encoding/json" + "net/http" + "testing" + + ws "golang.org/x/net/websocket" +) + +// TestSubagentsREST pins GET /api/subagents decoding and the ?key= filter. +func TestSubagentsREST(t *testing.T) { + var gotKey string + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { + buf := make([]byte, 512) + for { + if _, err := c.Read(buf); err != nil { + return + } + } + })) + mux.HandleFunc("/api/subagents", func(w http.ResponseWriter, r *http.Request) { + gotKey = r.URL.Query().Get("key") + _ = json.NewEncoder(w).Encode(map[string]any{ + "entries": []map[string]any{ + { + "task_id": "t1", "run_key": "rk1", "goal": "explore the repo", + "phase": "finished", "status": "success", + "iterations": 3, "tokens_used": 1500, "duration_seconds": 4.2, + }, + }, + "count": 1, + }) + }) + cl, _ := newTestServer(t, mux) + + entries, err := cl.Subagents("") + if err != nil { + t.Fatalf("Subagents: %v", err) + } + if len(entries) != 1 || entries[0].TaskID != "t1" || entries[0].Goal != "explore the repo" || entries[0].TokensUsed != 1500 { + t.Fatalf("entries = %+v", entries) + } + if _, err := cl.Subagents("rk1"); err != nil { + t.Fatalf("Subagents(key): %v", err) + } + if gotKey != "rk1" { + t.Errorf("key filter not sent, got %q", gotKey) + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 2f11ac0..6999165 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -105,6 +105,12 @@ func slashCommands() []command { {"cancel", "cancel the running turn", func(m *Model, _ string) tea.Cmd { return m.cancelRun() }}, + {"stop", "stop one sub-agent — /stop ", func(m *Model, args string) tea.Cmd { + return m.stopByLabel(args) + }}, + {"agents", "sub-agent registry — recent delegated tasks", func(m *Model, _ string) tea.Cmd { + return m.openAgents() + }}, {"attach", "stage a file for the next prompt — /attach ", func(m *Model, args string) tea.Cmd { return m.attachFile(args) }}, diff --git a/internal/tui/commands_e2e_test.go b/internal/tui/commands_e2e_test.go index 8ac8b8e..185580c 100644 --- a/internal/tui/commands_e2e_test.go +++ b/internal/tui/commands_e2e_test.go @@ -266,6 +266,19 @@ func TestE2EAllCommands(t *testing.T) { t.Fatal("/cancel did not clear the busy state") } }, + "/stop": func(t *testing.T, m *Model) { + if m.confirm != confirmNone { + t.Fatal("/stop without live agents armed a gate") + } + if !notePresent(m, "no running sub-agents") { + t.Errorf("/stop note missing: %v", m.notices) + } + }, + "/agents": func(t *testing.T, m *Model) { + if m.panel != panelAgents { + t.Fatalf("/agents opened panel %d", m.panel) + } + }, "/attach": func(t *testing.T, m *Model) { if len(m.attachments) != 1 || m.attachments[0].Name != "notes.txt" { t.Fatalf("/attach staged = %+v", m.attachments) diff --git a/internal/tui/drawer.go b/internal/tui/drawer.go index cbaa758..95e0826 100644 --- a/internal/tui/drawer.go +++ b/internal/tui/drawer.go @@ -98,6 +98,31 @@ func (m *Model) handleRunStarted(msg runStartedMsg) tea.Cmd { return tea.Batch(note, m.openRuns()) } +// ── agents tab ────────────────────────────────────────────────────────────── + +// openAgents opens the sub-agent registry tab (GET /api/subagents snapshot). +func (m *Model) openAgents() tea.Cmd { + m.panel = panelAgents + m.panelSel = 0 + m.panelEdit = panelEditNone + m.panelMsg = "loading sub-agents…" + m.relayout() + m.refresh() + return m.fetchAgents() +} + +// fetchAgents refetches the registry snapshot; r re-runs it while open. +func (m *Model) fetchAgents() tea.Cmd { + if m.cl == nil { + return nil + } + cl := m.cl + return func() tea.Msg { + entries, err := cl.Subagents("") + return mgmtMsg{tab: panelAgents, sag: entries, err: err} + } +} + // drawerTab is one tab of the management drawer. type drawerTab struct { name string @@ -112,6 +137,7 @@ func drawerTabs() []drawerTab { return []drawerTab{ {"sessions", panelSessions, func(m *Model) tea.Cmd { return m.openSessions() }}, {"runs", panelRuns, func(m *Model) tea.Cmd { return m.openRuns() }}, + {"agents", panelAgents, func(m *Model) tea.Cmd { return m.openAgents() }}, {"events", panelEvents, func(m *Model) tea.Cmd { return m.openEvents() }}, {"plan", panelPlan, func(m *Model) tea.Cmd { return m.openPlan() }}, {"memory", panelMemory, func(m *Model) tea.Cmd { return m.openMemory() }}, diff --git a/internal/tui/drawer_test.go b/internal/tui/drawer_test.go index 31a1d01..76928d9 100644 --- a/internal/tui/drawer_test.go +++ b/internal/tui/drawer_test.go @@ -71,16 +71,16 @@ func TestDrawerEventsTab(t *testing.T) { } } -// TestDrawerTabCycling verifies ]/[ and digit jumps move between ALL seven +// TestDrawerTabCycling verifies ]/[ and digit jumps move between ALL nine // drawer tabs (management panels included — they are full tabs, not loose // overlays) and esc closes from any of them. func TestDrawerTabCycling(t *testing.T) { m := wired(t) m.Update(exec(m.openRuns())) - // ] walks the full ring: runs → events → plan → memory → skills → - // tools → config → sessions. - want := []panelMode{panelEvents, panelPlan, panelMemory, panelSkills, panelTools, panelConfig, panelSessions} + // ] walks the full ring: runs → agents → events → plan → memory → + // skills → tools → config → sessions. + want := []panelMode{panelAgents, panelEvents, panelPlan, panelMemory, panelSkills, panelTools, panelConfig, panelSessions} for _, w := range want { _, cmd := m.Update(key("]")) m.Update(exec(cmd)) @@ -96,8 +96,9 @@ func TestDrawerTabCycling(t *testing.T) { } // Digits jump straight to any tab. for d, w := range map[string]panelMode{ - "1": panelSessions, "2": panelRuns, "3": panelEvents, "4": panelPlan, - "5": panelMemory, "6": panelSkills, "7": panelTools, "8": panelConfig, + "1": panelSessions, "2": panelRuns, "3": panelAgents, "4": panelEvents, + "5": panelPlan, "6": panelMemory, "7": panelSkills, "8": panelTools, + "9": panelConfig, } { _, cmd := m.Update(key(d)) m.Update(exec(cmd)) @@ -108,13 +109,13 @@ func TestDrawerTabCycling(t *testing.T) { // The strip renders every tab name, and r refreshes a management tab // the same as a core tab (they are drawer tabs now). out := plain(m.View()) - for _, name := range []string{"sessions", "runs", "events", "plan", "memory", "skills", "tools", "config"} { + for _, name := range []string{"sessions", "runs", "agents", "events", "plan", "memory", "skills", "tools", "config"} { if !strings.Contains(out, name) { t.Errorf("tab strip missing %q:\n%s", name, out) } } m.Update(exec(m.fetchSessionsPage("", 0, false))) - _, cmd = m.Update(key("5")) // memory tab (plan is 4 since its insertion) + _, cmd = m.Update(key("6")) // memory tab (agents shifted the digits) m.Update(exec(cmd)) _, cmd = m.Update(key("r")) if cmd == nil { diff --git a/internal/tui/events.go b/internal/tui/events.go index 25c6e71..ef0b309 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -112,6 +112,9 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { steps[j].done = true steps[j].result = resultPreview(ev.Data) steps[j].isErr = looksLikeError(steps[j].result) + if steps[j].subagent { + steps[j].resultCard = parseAgentResult(ev.Data) + } if !steps[j].started.IsZero() { steps[j].dur = time.Since(steps[j].started) } @@ -308,6 +311,14 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { } m.addTransientNote("subagent · " + stateNoticeLine(ev)) + case "subagent_cancelled": + // Stop ack. accepted:false is a benign race — the task already + // finished. The card's terminal state comes exclusively from the + // subagent_state frame; the ack never flips UI state. + if !ev.Accepted { + m.addTransientNote("stop declined · sub-agent already finished") + } + case client.EventDisconnected: m.disconn = true m.busy = false diff --git a/internal/tui/mgmt.go b/internal/tui/mgmt.go index 28fd43c..bb03619 100644 --- a/internal/tui/mgmt.go +++ b/internal/tui/mgmt.go @@ -29,6 +29,7 @@ type mgmtMsg struct { cfg map[string]any usr client.Usage con []client.Connection + sag []client.SubagentEntry err error } @@ -199,6 +200,13 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) { case panelConfig: m.cfgRows = buildCfgRows(msg.cfg, msg.usr, msg.con) m.panelMsg = "" + case panelAgents: + m.agentsReg = msg.sag + if len(msg.sag) == 0 { + m.panelMsg = "no sub-agent activity recorded" + } else { + m.panelMsg = "" + } } if m.panelSel >= m.panelLen() { m.panelSel = max(m.panelLen()-1, 0) @@ -583,6 +591,56 @@ func (m *Model) toolSelected() *toolRow { // mgmtDetailLines renders the selected row's detail block, wrapped to w. // Everything from the wire goes through sanitize(). +// agentRowsRender renders the agents tab: one row per registry entry — +// status glyph, redacted goal, and a compact usage tail. +func (m *Model) agentRowsRender(w int) []string { + th := m.th + rows := make([]string, 0, len(m.agentsReg)) + for i, e := range m.agentsReg { + goal := e.Goal + if goal == "" { + goal = "(no goal recorded)" + } + var detail string + if e.Phase == "finished" { + detail = fmt.Sprintf(" %s · %d it · %s tok", e.Status, e.Iterations, human(e.TokensUsed)) + } else { + detail = fmt.Sprintf(" running · step %d · %s", e.Step, e.LastTool) + } + if e.DurationSeconds > 0 { + detail += fmt.Sprintf(" · %.1fs", e.DurationSeconds) + } + budget := w - 2 - lipgloss.Width(detail) + label := agentStatusGlyph(e.Phase, e.Status) + " " + goal + prefix, lab := " ", th.acItem.Render(truncate(label, budget)) + if i == m.panelSel { + prefix, lab = th.acSel.Render("› "), th.acSel.Render(truncate(label, budget)) + } + rows = append(rows, prefix+lab+th.acDetail.Render(detail)) + } + return rows +} + +// agentStatusGlyph mirrors the live-card glyph set. +func agentStatusGlyph(phase, status string) string { + if phase != "finished" { + return "⟳" + } + switch status { + case "success": + return "✓" + case "partial": + return "◐" + case "error": + return "✗" + case "cancelled": + return "⊘" + case "timeout": + return "⏱" + } + return "•" +} + func (m *Model) mgmtDetailLines(w int) []string { th := m.th var out []string @@ -641,6 +699,26 @@ func (m *Model) mgmtDetailLines(w int) []string { } out = append(out, "") out = append(out, wrapText(sanitize(r.text), w)...) + case panelAgents: + if m.panelSel >= len(m.agentsReg) { + return []string{th.acDim.Render("no entry selected")} + } + e := m.agentsReg[m.panelSel] + out = append(out, th.acSel.Render("› "+agentStatusGlyph(e.Phase, e.Status)+" "+sanitize(e.Goal))) + meta := []string{e.Phase, e.Status, "task " + sanitize(e.TaskID)} + if e.LastTool != "" { + meta = append(meta, "last "+sanitize(e.LastTool)) + } + out = append(out, th.acDetail.Render(strings.Join(meta, " · "))) + out = append(out, "") + out = append(out, th.acDetail.Render(fmt.Sprintf("run %s · %d iterations · %d tokens · %.1fs", + sanitize(e.RunKey), e.Iterations, e.TokensUsed, e.DurationSeconds))) + if !e.StartedAt.IsZero() { + out = append(out, th.acDetail.Render("started "+e.StartedAt.String())) + } + if !e.FinishedAt.IsZero() { + out = append(out, th.acDetail.Render("finished "+e.FinishedAt.String())) + } case panelTools: r := m.toolSelected() if r == nil { diff --git a/internal/tui/model.go b/internal/tui/model.go index 8b2ec98..cddf5d7 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -27,17 +27,18 @@ const ( // step is a single tool invocation within an assistant turn. type step struct { - name string - arg string - result string // sanitized tool output (multi-line); excerpted at render - done bool - isErr bool // the result reads as a failure (tints the status glyph red) - subagent bool // this call delegates to a sub-agent (renders its log tree) - logs []string // nested sub-agent activity, from subagent_log events - agents []*agentCard // live per-task telemetry, from subagent_state frames - expanded bool // user has expanded this step to show full output/logs - started time.Time // when the tool_call arrived; zero for resumed history - dur time.Duration // wall-clock the call took; 0 until the result lands + name string + arg string + result string // sanitized tool output (multi-line); excerpted at render + done bool + isErr bool // the result reads as a failure (tints the status glyph red) + subagent bool // this call delegates to a sub-agent (renders its log tree) + logs []string // nested sub-agent activity, from subagent_log events + agents []*agentCard // live per-task telemetry, from subagent_state frames + resultCard *agentResult // framed result envelope (delegate tools) + expanded bool // user has expanded this step to show full output/logs + started time.Time // when the tool_call arrived; zero for resumed history + dur time.Duration // wall-clock the call took; 0 until the result lands } // stepRef maps a rendered transcript line to a specific step for mouse @@ -207,8 +208,10 @@ type Model struct { panelEdit panelEditMode // text-entry submode while a panel is open panelDraft string // the text being edited (search query / rename) confirm confirmKind // armed destructive action: y fires, any other key disarms + stopTarget string // task_id armed by confirmStopAgent - profiles []client.Profile // built-in model catalog (picker + context gauge) + profiles []client.Profile // built-in model catalog (picker + context gauge) + agentsReg []client.SubagentEntry // agents tab: sub-agent registry snapshot // Drawer state: runs polling + the events feed. runs []client.Run @@ -561,6 +564,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.noticeSweep() + case stopAgentDoneMsg: + if msg.err != nil { + m.addNote("stop failed · " + msg.err.Error()) + m.refresh() + } + return m, m.noticeSweep() + case updateCheckMsg: // Silent on error or when already current: the hint only ever nags // once, at startup, when a newer release is confirmed. @@ -732,6 +742,17 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.refresh() } return m, nil + case "ctrl+s": + // Stop one running sub-agent — a chord, so queue typing is never + // hijacked — behind the same two-step gate as every destructive + // action. /stop targets a specific card. + if m.busy { + if id, label, ok := m.firstLiveAgent(); ok { + return m, m.armStopAgent(id, label) + } + return m, m.transientNoteCmd("no running sub-agents") + } + return m, nil case "ctrl+t": m.thinkOn = !m.thinkOn state := "off" diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 40198f3..f78703c 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -26,6 +26,7 @@ const ( panelSkills panelTools panelConfig + panelAgents ) // panelEditMode is the text-entry submode a panel can capture: `/` search in @@ -50,6 +51,7 @@ const ( confirmFactDelete confirmClear confirmCancel + confirmStopAgent ) // handleConfirmKey resolves an armed delete: y fires it against the @@ -72,6 +74,11 @@ func (m *Model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // cancelRun self-guards: if the run settled while the gate sat // armed, it degrades to the "nothing to cancel" note. return m, m.cancelRun() + case confirmStopAgent: + // stopAgent self-guards: a card that settled while the gate sat + // armed degrades to a notice — the terminal state still comes + // from subagent_state, never from the ack. + return m, m.stopAgent(m.stopTarget) } } m.refresh() @@ -88,6 +95,8 @@ func (m *Model) armConfirm(kind confirmKind, what string) tea.Cmd { verb = "clear " case confirmCancel: verb = "cancel " + case confirmStopAgent: + verb = "stop " } m.panelMsg = verb + what + "? y confirm · any other key cancels" m.refresh() @@ -525,6 +534,8 @@ func (m *Model) panelLen() int { return len(m.toolRows) case panelConfig: return len(m.cfgRows) + case panelAgents: + return len(m.agentsReg) } return 0 } @@ -614,6 +625,13 @@ func (m *Model) panelSelect() tea.Cmd { } case panelEvents: return m.fetchEvents() + case panelAgents: + if m.panelSel < len(m.agentsReg) { + m.panelDetail = true // readable goal text through sanitize() — house grammar + m.detailScroll = 0 + m.refresh() + } + return nil case panelMemory, panelSkills, panelTools, panelConfig: // Enter expands the selected row into its detail view — the // promote/delete gates assume the human can read what they gate. @@ -1099,6 +1117,9 @@ func (m *Model) renderPanel(w, h int) string { case panelConfig: title = "⚙ config" rows = m.cfgRowsRender(w - 6) + case panelAgents: + title = "◈ agents" + rows = m.agentRowsRender(w - 6) } header := th.acTitle.Render(title) diff --git a/internal/tui/subagent_result_test.go b/internal/tui/subagent_result_test.go new file mode 100644 index 0000000..6cea76c --- /dev/null +++ b/internal/tui/subagent_result_test.go @@ -0,0 +1,84 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// TestParseAgentResult pins the tolerant framed-result parse: the envelope +// must carry status AND summary; prose never parses; a trailing artifact +// block after the JSON object is tolerated. +func TestParseAgentResult(t *testing.T) { + r := parseAgentResult(`{"status":"success","summary":"Built user handlers.","files_changed":["a.go","b.go"],"tokens_used":4200,"iterations":5}`) + if r == nil { + t.Fatal("valid envelope did not parse") + } + if r.status != "success" || r.summary != "Built user handlers." || len(r.files) != 2 || r.tokens != 4200 || r.iters != 5 { + t.Fatalf("parsed fields wrong: %+v", r) + } + + if parseAgentResult(`no json here`) != nil { + t.Error("prose parsed as a result card") + } + if parseAgentResult(`{"files_changed":[]}`) != nil { + t.Error("envelope without status/summary parsed") + } + + // JSON object followed by trailing artifact metadata lines. + r = parseAgentResult(`{"status":"error","summary":"boom","files_changed":null,"tokens_used":0,"iterations":0}` + "\n" + `📎 report.md · text/markdown · 48 KiB · a1b2c3d4`) + if r == nil || r.status != "error" || r.summary != "boom" { + t.Fatalf("envelope with trailing lines parsed wrong: %+v", r) + } +} + +// TestAgentResultCard: a framed delegate result renders as a structured +// card; a prose result keeps the generic preview. +func TestAgentResultCard(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "success", TokensUsed: 3200}) + m.handleEvent(client.Event{Type: "tool_result", Name: "delegate_tasks", Data: `{"status":"success","summary":"Built handlers and routing. 3 tests added.","files_changed":["handlers/user.go","routes.go"],"tokens_used":13100,"iterations":7}`}) + s := stateStep(t, m) + if s.resultCard == nil { + t.Fatal("framed result did not attach a result card") + } + if s.resultCard.status != "success" || len(s.resultCard.files) != 2 { + t.Fatalf("result card fields wrong: %+v", s.resultCard) + } + s.expanded = true + out, _ := renderStepsForTest(m, m.msgs[0], 0, 0) + for _, want := range []string{"Built handlers and routing.", "handlers/user.go", "2 files", "13.1k tok"} { + if !strings.Contains(out, want) { + t.Errorf("rendered card missing %q", want) + } + } + + // Prose result: no card, generic preview still lands. + m2 := stateFixture(t) + m2.handleEvent(client.Event{Type: "tool_result", Name: "delegate_tasks", Data: "plain text output\nline two"}) + s2 := stateStep(t, m2) + if s2.resultCard != nil { + t.Fatal("prose result produced a card") + } + if s2.result == "" { + t.Error("prose result lost the generic preview") + } +} + +// TestAgentResultSanitize: every wire-derived string is sanitized before it +// reaches a card line. +func TestAgentResultSanitize(t *testing.T) { + r := parseAgentResult("{\"status\":\"success\",\"summary\":\"bad \\u001b[31mtext\\nleak\",\"files_changed\":[\"x\\u001b.go\"],\"tokens_used\":10,\"iterations\":1}") + if r == nil { + t.Fatal("envelope did not parse") + } + m := stateFixture(t) + m.handleEvent(client.Event{Type: "tool_result", Name: "delegate_tasks", Data: `{"status":"success","summary":"ok","files_changed":["a.go"],"tokens_used":10,"iterations":1}`}) + s := stateStep(t, m) + lines := agentResultLines(m, s.resultCard, 200) + joined := strings.Join(lines, "\n") + if strings.ContainsAny(joined, "\x1b") { + t.Errorf("card lines carry escape bytes: %q", joined) + } +} diff --git a/internal/tui/subagent_stop_test.go b/internal/tui/subagent_stop_test.go new file mode 100644 index 0000000..633f352 --- /dev/null +++ b/internal/tui/subagent_stop_test.go @@ -0,0 +1,127 @@ +package tui + +import ( + "slices" + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// TestStopAgentGate: ctrl+s arms the stop gate on the first live agent, +// any other key disarms, y fires (self-guarded without a connection). +func TestStopAgentGate(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running", Step: 2}) + + m2, _ := m.Update(key("ctrl+s")) + m = m2.(*Model) + if m.confirm != confirmStopAgent { + t.Fatalf("ctrl+s did not arm confirmStopAgent: %v", m.confirm) + } + if !strings.Contains(m.panelMsg, "stop") || !strings.Contains(m.panelMsg, "SA1") { + t.Errorf("gate text missing target: %q", m.panelMsg) + } + + m2, _ = m.Update(key("x")) + m = m2.(*Model) + if m.confirm != confirmNone { + t.Fatalf("non-confirm key did not disarm: %v", m.confirm) + } + + m2, _ = m.Update(key("ctrl+s")) + m = m2.(*Model) + m2, _ = m.Update(key("y")) + m = m2.(*Model) + if m.confirm != confirmNone { + t.Fatalf("gate still armed after firing: %v", m.confirm) + } + if got := strings.Join(m.notices, "\n"); !strings.Contains(got, "nothing to stop") { + t.Errorf("sessionless fire should self-guard, notices=%q", got) + } +} + +// TestStopAgentIdle: with no live cards ctrl+s is a no-op, not a gate. +func TestStopAgentIdle(t *testing.T) { + m := stateFixture(t) // delegate step exists but no state frames → no cards + m2, _ := m.Update(key("ctrl+s")) + m = m2.(*Model) + if m.confirm != confirmNone { + t.Fatalf("ctrl+s without live agents armed a gate: %v", m.confirm) + } +} + +// TestStopCommand: /stop lists live labels bare, resolves , and arms +// the same gate; unknown labels are refused. +func TestStopCommand(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running"}) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t2", TaskIdx: 1, Phase: "active", Status: "running"}) + + if cmd := m.stopByLabel(""); cmd == nil { + t.Fatal("bare /stop should return the notice sweep") + } + if got := strings.Join(m.notices, "\n"); !strings.Contains(got, "SA1") || !strings.Contains(got, "SA2") { + t.Errorf("bare /stop should list live labels, notices=%q", got) + } + + m.confirm = confirmNone + m.panelMsg = "" + if cmd := m.stopByLabel("sa2"); cmd != nil { + t.Fatal("arming should return nil (the gate waits for y)") + } + if m.confirm != confirmStopAgent || m.stopTarget != "t2" { + t.Fatalf("/stop sa2 did not arm the gate for t2: %v %q", m.confirm, m.stopTarget) + } + if !strings.Contains(m.panelMsg, "SA2") { + t.Errorf("gate text missing SA2: %q", m.panelMsg) + } + + m.confirm = confirmNone + if cmd := m.stopByLabel("9"); cmd == nil { + t.Fatal("unknown label should return the notice sweep") + } + if got := strings.Join(m.notices, "\n"); !strings.Contains(got, "SA9") { + t.Errorf("unknown label not surfaced: %q", got) + } + + // The slash registry exposes /stop. + names := make([]string, 0, 8) + for _, c := range slashCommands() { + names = append(names, c.name) + } + if !slices.Contains(names, "stop") { + t.Errorf("/stop not registered: %v", names) + } +} + +// TestStopAck: accepted:true stays silent; accepted:false (benign race) gets +// an explicit notice. Terminal state always comes from subagent_state. +func TestStopAck(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running"}) + m.handleEvent(client.Event{Type: "subagent_cancelled", TaskID: "t1", Accepted: true}) + if got := strings.Join(m.notices, "\n"); got != "" { + t.Errorf("accepted ack should stay silent, notices=%q", got) + } + m.handleEvent(client.Event{Type: "subagent_cancelled", TaskID: "t9", Accepted: false}) + if got := strings.Join(m.notices, "\n"); !strings.Contains(got, "already finished") { + t.Errorf("benign-race ack not surfaced: %q", got) + } +} + +// TestStopSentMarker: the card advertises the in-flight stop until the +// terminal frame replaces the line. +func TestStopSentMarker(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running", Step: 4}) + s := stateStep(t, m) + s.agents[0].stopSent = true + if line := agentCardLine(s.agents[0]); !strings.Contains(line, "stop sent") { + t.Errorf("running line missing stop marker: %q", line) + } + s.agents[0].phase, s.agents[0].status = "finished", "cancelled" + if line := agentCardLine(s.agents[0]); strings.Contains(line, "stop sent") { + t.Errorf("terminal line still shows stop marker: %q", line) + } +} diff --git a/internal/tui/subagents.go b/internal/tui/subagents.go index b55ddc2..62ec761 100644 --- a/internal/tui/subagents.go +++ b/internal/tui/subagents.go @@ -6,24 +6,29 @@ package tui // and sanitized on ingest, and cards render only for tasks that report. import ( + "encoding/json" "fmt" + "strconv" "strings" "time" + tea "github.com/charmbracelet/bubbletea" + "github.com/BackendStack21/bodek/internal/client" ) // agentCard is one delegated task's live telemetry inside a sub-agent step. type agentCard struct { - taskID string - idx int - phase string // started | active | finished - status string // running | success | partial | error | cancelled | timeout - step int - tool string - iters int - tokens int - durS float64 + taskID string + idx int + phase string // started | active | finished + status string // running | success | partial | error | cancelled | timeout + step int + tool string + iters int + tokens int + durS float64 + stopSent bool // subagent_cancel sent; terminal frame still pending } // finished reports whether the card reached a terminal state. @@ -124,6 +129,9 @@ func agentCardLine(a *agentCard) string { } else if a.status != "" && a.status != "success" { b.WriteString(" · " + a.status) } + if !a.finished() && a.stopSent { + b.WriteString(" · stop sent") + } if a.iters > 0 { fmt.Fprintf(&b, " · %d it", a.iters) } @@ -173,3 +181,222 @@ func stateNoticeLine(ev client.Event) string { } return strings.Join(out, " · ") } + +// ── per-agent stop (subagent_cancel) ───────────────────────────────────────── + +// stopAgentDoneMsg reports a failed WS stop so the failure isn't silent; +// success stays silent — the terminal subagent_state settles the card. +type stopAgentDoneMsg struct { + taskID string + err error +} + +// armStopAgent arms the stop gate for one task id. +func (m *Model) armStopAgent(taskID, label string) tea.Cmd { + m.stopTarget = taskID + return m.armConfirm(confirmStopAgent, "sub-agent "+label) +} + +// stopAgent sends the WS subagent_cancel for one task. Self-guarding: a +// card that settled while the gate sat armed degrades to a notice, and the +// terminal state still comes exclusively from subagent_state. +func (m *Model) stopAgent(taskID string) tea.Cmd { + if m.cl == nil || m.sessionID == "" || taskID == "" { + return m.transientNoteCmd("nothing to stop") + } + card := m.liveCard(taskID) + if card == nil { + return m.transientNoteCmd("sub-agent already finished") + } + card.stopSent = true + m.refresh() + cl, sid, tok := m.cl, m.sessionID, m.authToken + return func() tea.Msg { + if err := cl.SendSubagentCancel(sid, tok, taskID); err != nil { + return stopAgentDoneMsg{taskID: taskID, err: err} + } + return nil // the terminal subagent_state settles the card + } +} + +// firstLiveAgent picks the default stop target: the expanded sub-agent +// step's first live card, else the first live card of any in-flight step. +func (m *Model) firstLiveAgent() (id, label string, ok bool) { + if i := m.cur(); i >= 0 { + for pass := 0; pass < 2; pass++ { + for j := range m.msgs[i].steps { + s := &m.msgs[i].steps[j] + if !s.subagent || s.done || (pass == 0 && !s.expanded && !m.expandAll) { + continue + } + for _, a := range s.agents { + if !a.finished() { + return a.taskID, fmt.Sprintf("SA%d", a.idx+1), true + } + } + } + } + } + return "", "", false +} + +// liveAgents collects the live cards of the current turn in card order. +func (m *Model) liveAgents() []*agentCard { + var out []*agentCard + if i := m.cur(); i >= 0 { + for j := range m.msgs[i].steps { + s := &m.msgs[i].steps[j] + if !s.subagent || s.done { + continue + } + for _, a := range s.agents { + if !a.finished() { + out = append(out, a) + } + } + } + } + return out +} + +// liveCard finds an unfinished card by task id across the current turn. +func (m *Model) liveCard(taskID string) *agentCard { + for _, a := range m.liveAgents() { + if a.taskID == taskID { + return a + } + } + return nil +} + +// stopByLabel resolves /stop (or a bare number) to a live card and +// arms the stop gate. With no argument it lists the live labels. +func (m *Model) stopByLabel(args string) tea.Cmd { + live := m.liveAgents() + if len(live) == 0 { + return m.transientNoteCmd("no running sub-agents") + } + ref := strings.ToLower(strings.TrimSpace(args)) + ref = strings.TrimPrefix(ref, "sa") + n := 0 + if v, err := strconv.Atoi(ref); err == nil { + n = v + } + if n == 0 { + labels := make([]string, 0, len(live)) + for _, a := range live { + labels = append(labels, fmt.Sprintf("SA%d", a.idx+1)) + } + return m.transientNoteCmd("running: " + strings.Join(labels, ", ") + " — /stop <#>") + } + for _, a := range live { + if a.idx+1 == n { + return m.armStopAgent(a.taskID, fmt.Sprintf("SA%d", a.idx+1)) + } + } + return m.transientNoteCmd(fmt.Sprintf("SA%d is not running", n)) +} + +// ── structured result card (framed results, odek M0) ───────────────────────── + +// agentResult is the framed result a sub-agent returns inside its parent's +// tool_result (headline capped at 2048 by serve): status, summary, changed +// files, and usage — parsed tolerantly, rendered as a card in the details. +type agentResult struct { + status string + summary string + files []string + tokens int + iters int +} + +// parseAgentResult extracts the framed-result envelope from delegate tool +// output. Tolerant by design: it parses only a JSON object carrying both +// status and summary — anything else (prose, partial JSON, older servers) +// returns nil and the generic preview stays. +func parseAgentResult(data string) *agentResult { + obj := firstJSONObject(data) + if obj == "" { + return nil + } + var env struct { + Status string `json:"status"` + Summary string `json:"summary"` + FilesChanged []string `json:"files_changed"` + TokensUsed int `json:"tokens_used"` + Iterations int `json:"iterations"` + } + if err := json.Unmarshal([]byte(obj), &env); err != nil { + return nil + } + if env.Status == "" || env.Summary == "" { + return nil + } + r := &agentResult{status: env.Status, summary: collapse(env.Summary), tokens: env.TokensUsed, iters: env.Iterations} + for _, f := range env.FilesChanged { + if f = collapse(f); f != "" { + r.files = append(r.files, f) + } + } + return r +} + +// firstJSONObject returns the first balanced top-level JSON object in s — +// the framed result may be followed by artifact metadata lines. +func firstJSONObject(s string) string { + start := strings.IndexByte(s, '{') + if start < 0 { + return "" + } + depth, inStr, esc := 0, false, false + for i := start; i < len(s); i++ { + c := s[i] + if inStr { + switch { + case esc: + esc = false + case c == '\\': + esc = true + case c == '"': + inStr = false + } + continue + } + switch c { + case '"': + inStr = true + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return s[start : i+1] + } + } + } + return "" +} + +// agentResultLines renders the structured result card for the expanded step +// details: summary, status/usage head, then one line per changed file. +func agentResultLines(m *Model, r *agentResult, budget int) []string { + th := m.th + parts := []string{r.status, fmt.Sprintf("%d files", len(r.files))} + if r.iters > 0 { + parts = append(parts, fmt.Sprintf("%d it", r.iters)) + } + if r.tokens > 0 { + parts = append(parts, human(r.tokens)+" tok") + } + head := strings.Join(parts, " · ") + style := th.stepRes + if r.status == "error" { + style = th.stepErr + } + lines := []string{th.stepRes.Render(truncate(r.summary, budget))} + lines = append(lines, style.Render(truncate(head, budget))) + for _, f := range r.files { + lines = append(lines, th.stepRes.Render(truncate("· "+f, budget))) + } + return lines +} diff --git a/internal/tui/subagents_panel_test.go b/internal/tui/subagents_panel_test.go new file mode 100644 index 0000000..5650fc9 --- /dev/null +++ b/internal/tui/subagents_panel_test.go @@ -0,0 +1,76 @@ +package tui + +import ( + "slices" + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// TestAgentsTabFlow: the drawer's agents tab opens, decodes the registry +// snapshot, renders rows, and opens the ⏎ detail. +func TestAgentsTabFlow(t *testing.T) { + m := newTestModel() + if cmd := m.openAgents(); cmd != nil { + t.Fatal("openAgents without a connection should skip the fetch") + } + if m.panel != panelAgents { + t.Fatalf("openAgents did not open the tab: %v", m.panel) + } + + m.handleMgmtMsg(mgmtMsg{tab: panelAgents, sag: []client.SubagentEntry{ + {TaskID: "t1", RunKey: "rk1", Goal: "explore the repo", Phase: "finished", Status: "success", Iterations: 3, TokensUsed: 1500}, + {TaskID: "t2", Phase: "active", Status: "running", Step: 4, LastTool: "read"}, + }}) + if m.panelLen() != 2 { + t.Fatalf("panelLen = %d, want 2", m.panelLen()) + } + rows := m.agentRowsRender(120) + joined := strings.Join(rows, "\n") + for _, want := range []string{"✓", "explore the repo", "1.5k tok", "⟳", "read"} { + if !strings.Contains(joined, want) { + t.Errorf("rows missing %q: %q", want, joined) + } + } + + // ⏎ opens the detail with the full metadata through sanitize(). + m.panelSelect() + if !m.panelDetail { + t.Fatal("enter did not open the detail view") + } + detail := strings.Join(m.mgmtDetailLines(120), "\n") + for _, want := range []string{"explore the repo", "task t1", "rk1"} { + if !strings.Contains(detail, want) { + t.Errorf("detail missing %q: %q", want, detail) + } + } + + // Tab cycling reaches the new tab and resets the detail submode. + m2, _ := m.Update(key("]")) + m = m2.(*Model) + if m.panelDetail { + t.Error("tab switch did not reset the detail submode") + } +} + +// TestAgentsTabCommand: /agents is a registered slash command opening the tab. +func TestAgentsTabCommand(t *testing.T) { + names := make([]string, 0, 12) + for _, c := range slashCommands() { + names = append(names, c.name) + } + if !slices.Contains(names, "agents") { + t.Fatalf("/agents not registered: %v", names) + } + m := newTestModel() + for _, c := range slashCommands() { + if c.name == "agents" { + c.run(m, "") + break + } + } + if m.panel != panelAgents { + t.Fatalf("/agents did not open the tab: %v", m.panel) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 6716188..4c36b08 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -820,7 +820,11 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in details = append(details, th.stepRes.Render(truncate(lg, detailBudget))) } } - details = append(details, stepDetail(s.name, s.result, m.vp.Width, th)...) + if s.resultCard != nil { + details = append(details, agentResultLines(m, s.resultCard, detailBudget)...) + } else { + details = append(details, stepDetail(s.name, s.result, m.vp.Width, th)...) + } if len(details) > 200 { details = details[:200] details = append(details, th.stepArg.Render("… output truncated"))