Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,14 +192,19 @@ own front-end settings are separate; see [Configuration](#configuration).
tests`, `📖 reading client.go`, `🚀 pushing`) with a live elapsed timer.
- **Sub-agents** — delegations are labelled and their `subagent_log` activity
nests beneath the delegating call, so a sub-agent's progress reads as its
own branch of the step tree. Per-task `subagent_state` telemetry
(odek v1.30+) 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. `ctrl+s` (or `/stop <SA#>`,
two-step confirmed) stops one running sub-agent, and the `/agents` drawer
tab lists the serve instance's registry snapshot.
own branch of the step tree. Each card carries its task's goal (parsed
from the `delegate_tasks` argument; tasks the wire hasn't confirmed yet
show as `pending`), live step/tool/iterations/tokens telemetry from
`subagent_state` frames (odek v1.30+), a client-side elapsed timer between
frame bursts, and terminal status glyphs (`✓` success, `◐` partial, `✗`
error, `⊘` cancelled, `⏱` timeout). The collapsed rollup counts failures —
`2/3 · 1 ✗ · 8.1k tok` — a terminal failure sticks to the notice strip
until the turn ends, and every delegating turn closes with a
`swarm: 5 ✓ · 1 ✗ — SA4 error` verdict. A disconnect retires in-flight
cards (`× lost on disconnect`) instead of leaving ghost spinners.
`ctrl+s` (or `/stop <SA#>`, two-step confirmed) stops one running
sub-agent of the current turn; the `/agents` tab's `c` reaches any live
task through the instance registry.
- **Model switcher** (`^O`) — change the model for the next turn. The picker
merges the server's configured model with its built-in profile catalog
(`/api/profiles`), each annotated with its context window.
Expand Down Expand Up @@ -349,7 +354,7 @@ full command and press `⏎`.
| `/thinking [on\|off]` | Toggle extended thinking for the next turn |
| `/cancel` | Cancel the running turn |
| `/stop <SA#>` | Stop one running sub-agent (bare `/stop` lists them) |
| `/agents` | Sub-agent registry — recent delegated tasks (drawer tab) |
| `/agents` | Sub-agent registry — live 3s poll, `c` stop (two-step), `o` jump to transcript |
| `/attach <path>` | 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 |
Expand All @@ -370,6 +375,9 @@ full command and press `⏎`.
`⏎` resume.
- **Runs** — live 3s poll, `A`/`D`/`T` remote approvals, `c` cancel,
`p` refresh pending approvals, `e` drill into the run's event trail.
- **Agents** — the serve instance's sub-agent registry, live-polled every 3s;
`c` stop the highlighted row (two-step, same gate as `/stop`), `o` jump to
the delegating transcript step, `⏎` the full registry record.
- **Events** — the `odek.event/v1` ring: `f` filter to this session, `x`
clear filters (a runs-tab drill-in scopes it to one run).
- **Plan** — the engine's structured task plan (Telegram-parity renderer):
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ func slashCommands() []command {
{"stop", "stop one sub-agent — /stop <SA#>", func(m *Model, args string) tea.Cmd {
return m.stopByLabel(args)
}},
{"agents", "sub-agent registry — recent delegated tasks", func(m *Model, _ string) tea.Cmd {
{"agents", "sub-agent registry — c stop · o jump (live poll)", func(m *Model, _ string) tea.Cmd {
return m.openAgents()
}},
{"attach", "stage a file for the next prompt — /attach <path>", func(m *Model, args string) tea.Cmd {
Expand Down
70 changes: 69 additions & 1 deletion internal/tui/drawer.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ func (m *Model) openAgents() tea.Cmd {
m.panelMsg = "loading sub-agents…"
m.relayout()
m.refresh()
return m.fetchAgents()
if m.cl == nil {
return nil // no connection: nothing to fetch, nothing to poll
}
return tea.Batch(m.fetchAgents(), m.armAgentsPoll())
}

// fetchAgents refetches the registry snapshot; r re-runs it while open.
Expand All @@ -123,6 +126,71 @@ func (m *Model) fetchAgents() tea.Cmd {
}
}

const agentsPollEvery = 3 * time.Second

// agentsTickMsg re-arms the agents-tab poll (runsTickMsg pattern) — the
// registry is a live view while visible, not a stale snapshot.
type agentsTickMsg struct{ seq int }

// armAgentsPoll schedules the next registry refresh while the tab is visible.
func (m *Model) armAgentsPoll() tea.Cmd {
m.agentsSeq++
seq := m.agentsSeq
return tea.Tick(agentsPollEvery, func(time.Time) tea.Msg {
return agentsTickMsg{seq: seq}
})
}

// handleAgentsTick refetches the snapshot only for the newest generation on
// the visible tab — stale ticks and closed tabs drop silently.
func (m *Model) handleAgentsTick(msg agentsTickMsg) tea.Cmd {
if msg.seq != m.agentsSeq || m.panel != panelAgents {
return nil
}
return m.fetchAgents()
}

// stopSelectedAgent arms the two-step stop gate on the highlighted registry
// row — the same confirmStopAgent the transcript's /stop uses, resolved
// through the cross-turn live registry.
func (m *Model) stopSelectedAgent() tea.Cmd {
if m.panelSel >= len(m.agentsReg) {
return m.transientNoteCmd("no sub-agent selected")
}
e := m.agentsReg[m.panelSel]
if e.Phase == "finished" {
return m.transientNoteCmd("sub-agent already finished")
}
label := truncate(collapse(e.Goal), 24)
if label == "" {
label = shortID(e.TaskID)
}
return m.armStopAgent(e.TaskID, label)
}

// jumpToAgentStep closes the drawer onto the transcript step that owns the
// selected task, expanded — the registry jumps to the thing it describes.
func (m *Model) jumpToAgentStep() tea.Cmd {
if m.panelSel >= len(m.agentsReg) {
return nil
}
taskID := m.agentsReg[m.panelSel].TaskID
for i := range m.msgs {
for j := range m.msgs[i].steps {
if m.msgs[i].steps[j].card(taskID) == nil {
continue
}
m.msgs[i].steps[j].expanded = true
m.panel = panelNone
m.relayout()
m.scrollToMessage(i)
m.refresh()
return nil
}
}
return m.transientNoteCmd("no transcript card for this task (resumed or foreign run)")
}

// drawerTab is one tab of the management drawer.
type drawerTab struct {
name string
Expand Down
105 changes: 103 additions & 2 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,13 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
if i := m.cur(); i >= 0 {
m.msgs[i].steps = append(m.msgs[i].steps,
step{name: ev.Name, arg: arg, subagent: isSubagent(ev.Name), started: time.Now()})
m.msgs[i].items = append(m.msgs[i].items, turnItem{stepIdx: len(m.msgs[i].steps) - 1})
last := len(m.msgs[i].steps) - 1
if m.msgs[i].steps[last].subagent {
// Per-task identity (goals, profiles) lives in the parent's
// argument, not on the subagent_state frames.
m.msgs[i].steps[last].manifest = parseDelegateManifest(ev.Data)
}
m.msgs[i].items = append(m.msgs[i].items, turnItem{stepIdx: last})
}
m.lastTool = ev.Name
m.lastArg = arg
Expand Down Expand Up @@ -313,6 +319,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
// frames) fall back to a notice so nothing vanishes silently.
if i := m.cur(); i >= 0 && m.attachSubState(i, ev) {
stream = true // coalesce redraws — state frames arrive in bursts
m.subagentTerminalNote(ev)
break
}
m.addTransientNote("subagent · " + stateNoticeLine(ev))
Expand Down Expand Up @@ -349,6 +356,11 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
if i := m.cur(); i >= 0 {
setTurnMarker(&m.msgs[i], "**Interrupted:** connection lost")
}
if n := m.loseLiveAgents(); n > 0 {
// In-flight cards just became unknowable — say so once instead of
// leaving spinners that will never settle.
m.addNote("sub-agent state lost on disconnect")
}
m.finalize()
m.relayout() // the busy status line is gone with the socket
if cmd := m.scheduleReconnect(0); cmd != nil {
Expand Down Expand Up @@ -555,12 +567,86 @@ func markCancel(msg *message) {
setTurnMarker(msg, "**Cancelled.**")
}

// finalize closes out the streaming assistant message and drops the cursor.
// finalize closes out the streaming assistant message and drops the cursor:
// the swarm verdict (when the turn delegated sub-agents) lands as the turn
// marker, and sticky sub-agent failure notices retire — the verdict line
// supersedes them.
func (m *Model) finalize() {
if i := m.cur(); i >= 0 {
if v := m.swarmVerdict(&m.msgs[i]); v != "" {
setTurnMarker(&m.msgs[i], v)
}
m.closeTurn(&m.msgs[i])
}
m.curIdx = -1
m.clearStickyNotes()
}

// swarmVerdict summarizes a turn's sub-agent outcomes as a turn marker —
// "**swarm: 5 ✓ · 1 ✗ — SA4 error**" — so a failure in a multi-agent turn
// can't scroll by uncounted. "" when the turn delegated nothing.
func (m *Model) swarmVerdict(msg *message) string {
var ok, partial, failed, cancelled, timed, live, lostN int
var bad []string
for j := range msg.steps {
if !msg.steps[j].subagent {
continue
}
for _, a := range msg.steps[j].agents {
switch {
case !a.finished():
if a.lost {
lostN++ // orphaned by a disconnect: neither live nor terminal
} else {
live++
}
case a.status == "success":
ok++
case a.status == "partial":
partial++
case a.status == "error":
failed++
bad = append(bad, fmt.Sprintf("SA%d %s", a.idx+1, a.status))
case a.status == "timeout":
timed++
bad = append(bad, fmt.Sprintf("SA%d %s", a.idx+1, a.status))
case a.status == "cancelled":
cancelled++
bad = append(bad, fmt.Sprintf("SA%d %s", a.idx+1, a.status))
}
}
}
total := ok + partial + failed + cancelled + timed + live + lostN
if total == 0 {
return ""
}
parts := make([]string, 0, 6)
if ok > 0 {
parts = append(parts, fmt.Sprintf("%d ✓", ok))
}
if partial > 0 {
parts = append(parts, fmt.Sprintf("%d ◐", partial))
}
if failed > 0 {
parts = append(parts, fmt.Sprintf("%d ✗", failed))
}
if timed > 0 {
parts = append(parts, fmt.Sprintf("%d ⏱", timed))
}
if cancelled > 0 {
parts = append(parts, fmt.Sprintf("%d ⊘", cancelled))
}
if lostN > 0 {
parts = append(parts, fmt.Sprintf("%d lost", lostN))
}
if live > 0 {
parts = append(parts, fmt.Sprintf("%d live", live))
}
line := "swarm: " + strings.Join(parts, " · ")
if len(bad) > 0 {
line += " — " + strings.Join(bad, ", ")
}
return "**" + line + "**"
}

// closeTurn renders finalized markdown for one assistant turn: each reply
Expand Down Expand Up @@ -631,6 +717,21 @@ func (m *Model) pruneNotices(now time.Time) {
m.noticeExp = keptExp
}

// clearStickyNotes retires zero-expiry notes (sticky sub-agent failures) —
// used when the context that made them sticky is gone (turn finalized).
func (m *Model) clearStickyNotes() {
kept := m.notices[:0]
keptExp := m.noticeExp[:0]
for i, n := range m.notices {
if !m.noticeExp[i].IsZero() {
kept = append(kept, n)
keptExp = append(keptExp, m.noticeExp[i])
}
}
m.notices = kept
m.noticeExp = keptExp
}

// noticeSweep schedules the next expiry sweep at the earliest pending
// notice expiry; nil when the strip has nothing pending. The tick handler
// prunes and re-arms, so expired notes disappear even on an idle TUI and
Expand Down
6 changes: 3 additions & 3 deletions internal/tui/mgmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,8 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) {
m.agentsReg = msg.sag
if len(msg.sag) == 0 {
m.panelMsg = "no sub-agent activity recorded"
} else {
m.panelMsg = ""
} else if m.confirm != confirmStopAgent {
m.panelMsg = "" // keep the armed stop gate's prompt visible
}
}
if m.panelSel >= m.panelLen() {
Expand Down Expand Up @@ -543,7 +543,7 @@ func (m *Model) cfgRowsRender(w int) []string {
// mgmtPanel reports whether p is a management drawer tab.
func mgmtPanel(p panelMode) bool {
switch p {
case panelPlan, panelMemory, panelSkills, panelTools, panelConfig:
case panelAgents, panelPlan, panelMemory, panelSkills, panelTools, panelConfig:
return true
}
return false
Expand Down
31 changes: 31 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type step struct {
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
manifest []taskSlot // delegate arg parsed at tool-call time: per-task identity
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
Expand Down Expand Up @@ -212,6 +213,9 @@ type Model struct {

profiles []client.Profile // built-in model catalog (picker + context gauge)
agentsReg []client.SubagentEntry // agents tab: sub-agent registry snapshot
agentsSeq int // agents-tab poll generation; stale ticks drop

liveTasks map[string]*agentCard // every unfinished card, any turn: stop paths resolve across turns

// Drawer state: runs polling + the events feed.
runs []client.Run
Expand Down Expand Up @@ -502,6 +506,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case runsTickMsg:
return m, m.handleRunsTick(msg)

case agentsTickMsg:
return m, m.handleAgentsTick(msg)

case planMsg:
return m, m.handlePlanMsg(msg)

Expand Down Expand Up @@ -533,6 +540,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case mgmtMsg:
m.handleMgmtMsg(msg)
m.refresh()
if msg.tab == panelAgents && m.panel == panelAgents {
return m, m.armAgentsPoll() // keeps the 3s chain alive while visible
}
return m, nil

case mgmtActionMsg:
Expand Down Expand Up @@ -566,6 +576,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

case stopAgentDoneMsg:
if msg.err != nil {
// The stop never left — un-advertise it so the card doesn't
// claim a stop is in flight.
if a := m.liveCard(msg.taskID); a != nil {
a.stopSent = false
}
m.addNote("stop failed · " + msg.err.Error())
m.refresh()
}
Expand Down Expand Up @@ -1240,6 +1255,22 @@ func (m *Model) focusTurnAt(line int) {
}
}

// scrollToMessage parks the viewport at a message's turn head (one line of
// context above it), bottom when the turn isn't indexed yet (in-flight).
func (m *Model) scrollToMessage(msgIdx int) {
for _, r := range m.turnLineIndex {
if r.msgIdx == msgIdx {
off := r.line
if off > 0 {
off--
}
m.vp.SetYOffset(off)
return
}
}
m.vp.GotoBottom()
}

// turnAtLine maps a viewport content line to a turn head (stepIdx -1).
func (m *Model) turnAtLine(line int) (msgIdx int, ok bool) {
for _, r := range m.turnLineIndex {
Expand Down
8 changes: 8 additions & 0 deletions internal/tui/panels.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,9 @@ func (m *Model) handlePanelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.panel == panelMemory {
return m, m.memConsolidate("user")
}
if m.panel == panelAgents {
return m, m.stopSelectedAgent()
}
case "f", "F":
if m.panel == panelEvents {
return m, m.toggleEventFilter()
Expand Down Expand Up @@ -426,6 +429,11 @@ func (m *Model) handlePanelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.panel == panelMemory {
return m, m.memConsolidate("env")
}
case "o":
if m.panel == panelAgents {
// Jump to the delegating transcript step, expanded.
return m, m.jumpToAgentStep()
}
case "r":
if m.panel == panelSessions {
if m.panelSel < len(m.sessions) {
Expand Down
Loading