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
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,16 @@ modifier routes to the composer), and `Alt+A`/`Alt+D`/`Alt+T`
`kickJobsFetch()` for an immediate snapshot, watcher tick as fallback;
`bg_wake` frames become transient notes. Generation counters
(`jobsSeq`/`jobsWatchSeq`) drop stale ticks — keep both chains
generation-guarded when touching the cadence.
generation-guarded when touching the cadence. The same push-beats-poll
pattern drives the other tabs: `subagent_state` and `memory_event` frames
set `kickAgents`/`kickMemory` flags that `flushKicks()` drains into ONE
fetch per burst (per-event cmds would starve inside `ingestWireBatch`);
the events tab polls the runtime ring at 3s while visible with seq-stamped
fetches (`eventsMsg.seq` — a late landing never clobbers a filter change,
and open/toggle/clear bump `eventsTabSeq`). Agents-tab rows prefer live
card telemetry over the REST snapshot (skipping lost cards), and drawer
selections anchor by identity (fact text / TaskID) across rebuilds so a
refresh never silently retargets the detail view or the stop gate.
- The narration-line plan strip (`planStripLabel` in `plan.go`) patches
on `plan` tool_call (`applyPlanMutation`) so the count moves on that
frame. REST (`GET /api/sessions/{id}/plan`) confirms after
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ full command and press `⏎`.
| `/sessions` | Browse, search, pin, rename, export & resume sessions |
| `/runs` | Headless REST runs — live status, remote approvals, cancel |
| `/run <prompt>` | Start a headless run (fresh session) and watch it in the runs tab |
| `/events` | The `odek.event/v1` runtime feed |
| `/events` | The `odek.event/v1` runtime feed (live 3s refresh while the tab is open) |
| `/jobs` | Background jobs — live status, output viewer, `s` stop (requires odek ≥ v1.38) |
| `/plan` | Structured task plan of this session (live status) |
| `/memory` | Facts by target, pending-episode promote, consolidate |
Expand Down Expand Up @@ -522,7 +522,9 @@ shared grammar:
`⏎` 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;
- **Agents** — the serve instance's sub-agent registry, live-polled every 3s
and refreshed immediately on every sub-agent state frame; rows prefer the
live telemetry (tool, step, tokens, cost, duration) over the REST snapshot;
`c` stop the highlighted row (two-step, same gate as `/stop`), `o` jump to
the delegating transcript step, `⏎` the full registry record — trust,
budget, cost, and artifact lines included.
Expand Down
57 changes: 55 additions & 2 deletions internal/tui/drawer.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,12 @@ type runsMsg struct {
err error
}

// eventsMsg carries an events fetch.
// eventsMsg carries an events fetch; seq stamps the eventsTabSeq it was
// armed under so a late landing cannot clobber a newer filter state.
type eventsMsg struct {
events []client.RuntimeEvent
err error
seq int
}

// runActionMsg reports a run cancel / approval-answer outcome.
Expand Down Expand Up @@ -131,6 +133,16 @@ func (m *Model) fetchAgents() tea.Cmd {

const agentsPollEvery = 3 * time.Second

// kickAgentsFetch refreshes the open agents tab immediately on a wire
// subagent_state frame — the same push-beats-poll pattern kickJobsFetch
// uses for bg_job frames. The 3s chain stays as the fallback.
func (m *Model) kickAgentsFetch() tea.Cmd {
if m.panel != panelAgents || m.cl == nil {
return nil
}
return m.fetchAgents()
}

// 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 }
Expand Down Expand Up @@ -462,13 +474,47 @@ func (m *Model) renderRunStatus(i int, label string) string {

// ── events tab ──────────────────────────────────────────────────────────────

// eventsPollEvery is the events-tab refresh cadence while visible — the
// ring is live server-side, so the view must not be a frozen slice.
const eventsPollEvery = 3 * time.Second

// eventsTickMsg re-arms the events-tab poll (runsTickMsg pattern).
type eventsTickMsg struct{ seq int }

// armEventsPoll schedules the next events refresh while the tab is visible.
func (m *Model) armEventsPoll() tea.Cmd {
m.eventsTabSeq++
seq := m.eventsTabSeq
return tea.Tick(eventsPollEvery, func(time.Time) tea.Msg {
return eventsTickMsg{seq: seq}
})
}

// handleEventsTick refetches the ring only for the newest generation on the
// visible tab — stale ticks and closed tabs drop silently.
func (m *Model) handleEventsTick(msg eventsTickMsg) tea.Cmd {
if msg.seq != m.eventsTabSeq || m.panel != panelEvents {
return nil
}
if m.cl == nil {
return nil // no connection: nothing to poll
}
return m.fetchEvents()
}

func (m *Model) openEvents() tea.Cmd {
m.panel = panelEvents
m.panelSel = 0
m.panelEdit = panelEditNone
m.panelMsg = "loading events…"
m.eventsTabSeq++ // in-flight fetches from a previous view state are stale
m.relayout()
m.refresh()
if m.cl == nil {
return nil // no connection: nothing to fetch, nothing to poll
}
// The fetch stays the single returned cmd so exec()-style tests keep
// working; the first eventsMsg arms the poll chain (see Update).
return m.fetchEvents()
}

Expand All @@ -480,9 +526,10 @@ func (m *Model) fetchEvents() tea.Cmd {
} else if m.evSessionFilter {
sid = m.sessionID
}
seq := m.eventsTabSeq // stamp: a late landing must not clobber a newer filter state
return func() tea.Msg {
evs, err := cl.RuntimeEvents(100, rid, sid)
return eventsMsg{events: evs, err: err}
return eventsMsg{events: evs, err: err, seq: seq}
}
}

Expand All @@ -493,13 +540,15 @@ func (m *Model) toggleEventFilter() tea.Cmd {
if m.evSessionFilter {
m.evRunFilter = ""
}
m.eventsTabSeq++ // the in-flight unfiltered poll must not clobber the new filter
return m.fetchEvents()
}

// clearEventFilters drops every events filter and refetches the whole ring.
func (m *Model) clearEventFilters() tea.Cmd {
m.evRunFilter = ""
m.evSessionFilter = false
m.eventsTabSeq++
return m.fetchEvents()
}

Expand All @@ -516,11 +565,15 @@ func (m *Model) drillIntoRunEvents() tea.Cmd {
}

func (m *Model) handleEventsMsg(msg eventsMsg) {
if msg.seq != 0 && msg.seq != m.eventsTabSeq {
return // a fetch armed before a filter change / reopen — stale
}
if msg.err != nil {
m.panelMsg = "error: " + msg.err.Error()
return
}
m.feed = msg.events
m.panelSel = max(0, min(m.panelSel, len(m.feed)-1)) // shrink clamps the selection
if len(m.feed) == 0 {
m.panelMsg = "no runtime events yet — every WS prompt and REST run feeds this ring"
} else {
Expand Down
39 changes: 37 additions & 2 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,16 @@ func (m *Model) ingestWireBatch(events []client.Event) (tea.Model, tea.Cmd) {
}
}
pm := model.(*Model)
// Kicks ride as model flags so a burst coalesces into ONE fetch per
// kind — a swarm of state frames must not flood the registry endpoint.
return pm, tea.Batch(
listen(pm.events),
pm.rearmRenderFlush(),
pm.noticeSweep(),
pm.approvalSweep(),
pm.sendQueued(),
pm.planFollowup(),
pm.flushKicks(),
)
}

Expand Down Expand Up @@ -440,6 +443,10 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
m.addTransientNote("skill · " + strings.TrimSpace(ev.SubType+" "+ev.SkillName) + eventTail(ev))
case "memory_event":
m.addTransientNote("memory · " + strings.TrimSpace(ev.SubType+" "+ev.Target) + eventTail(ev))
// Facts just changed server-side: refresh the open memory tab so
// the list never lies about what exists. The flag flushes as ONE
// coalesced fetch (bursty writers must not flood the endpoint).
m.kickMemory = true
case "agent_signal":
if silentAgentSignal(ev.SubType) {
// Engine housekeeping: context trimming and tool-running
Expand Down Expand Up @@ -476,6 +483,11 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
// The finished frame's final cost banks first — spent is spent
// even when the frame has no step left to attach to.
m.recordSubCost(ev)
// Progress moved: refresh the open agents tab now instead of
// waiting for the 3s poll. The flag flushes as ONE coalesced fetch
// per burst; the live card below also feeds the renderer between
// polls.
m.kickAgents = true
if i := m.cur(); i >= 0 && m.attachSubState(i, ev) {
stream = true // coalesce redraws — state frames arrive in bursts
m.subagentTerminalNote(ev)
Expand Down Expand Up @@ -573,11 +585,34 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
}

if stream {
return m, tea.Batch(listen(m.events), m.noticeSweep(), m.approvalSweep(), m.queueRender())
return m, tea.Batch(listen(m.events), m.noticeSweep(), m.approvalSweep(), m.queueRender(), m.flushKicks())
}
m.refresh()
// A turn that just ended (done / error) drains the next queued prompt.
return m, tea.Batch(listen(m.events), m.noticeSweep(), m.approvalSweep(), m.sendQueued(), m.planFollowup(), attn)
return m, tea.Batch(listen(m.events), m.noticeSweep(), m.approvalSweep(), m.sendQueued(), m.planFollowup(), attn, m.flushKicks())
}

// flushKicks drains the pending open-tab refresh flags into ONE fetch per
// kind — bursty wire frames coalesce instead of flooding the endpoints,
// and the flags survive batch ingestion (a per-event cmd would not).
func (m *Model) flushKicks() tea.Cmd {
var cmds []tea.Cmd
if m.kickAgents {
m.kickAgents = false
if cmd := m.kickAgentsFetch(); cmd != nil {
cmds = append(cmds, cmd)
}
}
if m.kickMemory {
m.kickMemory = false
if cmd := m.kickMemoryFetch(); cmd != nil {
cmds = append(cmds, cmd)
}
}
if len(cmds) == 0 {
return nil
}
return tea.Batch(cmds...)
}

// openWakeTurn opens a streaming assistant card for a server-initiated turn
Expand Down
101 changes: 88 additions & 13 deletions internal/tui/mgmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@ func (m *Model) openMemory() tea.Cmd {
m.relayout()
m.refresh()
cl := m.cl
if cl == nil {
return nil
}
return func() tea.Msg {
mem, err := cl.Memory()
return mgmtMsg{tab: panelMemory, mem: mem, err: err}
}
}

// kickMemoryFetch refreshes the open memory tab on a memory_event wire
// frame (kick pattern, jobs-tab) so facts written mid-session appear.
func (m *Model) kickMemoryFetch() tea.Cmd {
if m.panel != panelMemory || m.cl == nil {
return nil
}
cl := m.cl
return func() tea.Msg {
mem, err := cl.Memory()
return mgmtMsg{tab: panelMemory, mem: mem, err: err}
Expand Down Expand Up @@ -176,8 +192,15 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) {
}
switch msg.tab {
case panelMemory:
// Anchor the selection by identity across rebuilds: a fact inserted
// above the selection must not silently swap the open detail.
var sel string
if m.panelSel >= 0 && m.panelSel < len(m.memRows) {
sel = m.memRows[m.panelSel].text
}
m.memView = msg.mem
m.memRows = buildMemRows(msg.mem)
m.panelSel = anchorRow(m.panelSel, m.memRows, sel)
if len(m.memRows) == 0 {
m.panelMsg = "no facts or pending episodes"
} else {
Expand All @@ -201,7 +224,19 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) {
m.cfgRows = buildCfgRows(msg.cfg, msg.usr, msg.con)
m.panelMsg = ""
case panelAgents:
// Anchor the selection by TaskID across rebuilds: a registry insert
// above must not silently re-target the stop gate.
var selTask string
if m.panelSel >= 0 && m.panelSel < len(m.agentsReg) {
selTask = m.agentsReg[m.panelSel].TaskID
}
m.agentsReg = msg.sag
for i := range m.agentsReg {
if m.agentsReg[i].TaskID == selTask {
m.panelSel = i
break
}
}
if len(msg.sag) == 0 {
m.panelMsg = "no sub-agent activity recorded"
} else if m.confirm != confirmStopAgent {
Expand All @@ -213,6 +248,20 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) {
}
}

// anchorRow re-locates a selection by its row text after a rebuild: an
// insert above must not silently change what the selection points at. Falls
// back to clamping when the row vanished.
func anchorRow(prev int, rows []memRow, text string) int {
if text != "" {
for i := range rows {
if rows[i].text == text {
return i
}
}
}
return max(0, min(prev, len(rows)-1))
}

func buildMemRows(v client.MemoryView) []memRow {
var rows []memRow
for _, target := range []string{"user", "env"} {
Expand Down Expand Up @@ -616,26 +665,52 @@ func (m *Model) agentRowsRender(w int) []string {
sa = a.idx + 1
}
var detail string
if e.Phase == "finished" {
detail = fmt.Sprintf(" %s · %d it · %s tok", collapse(e.Status), e.Iterations, human(e.TokensUsed))
// Prefer the live card's telemetry — it moves on every wire state
// frame while the REST row lags up to a poll period behind. Lost
// cards (socket dropped mid-run) are skipped: their frozen values
// would lie forever. A finished card always supplies the FINAL
// telemetry (the wire carries it at finish; discarding it would roll
// the numbers back for a poll period), and cost comes from the card
// too so the row never pairs fresh tokens with a stale dollar figure.
phase, status, step, tool := e.Phase, e.Status, e.Step, e.LastTool
iters, tokens, durS, cost := e.Iterations, e.TokensUsed, e.DurationSeconds, e.CostUSD
if card := m.liveCard(e.TaskID); card != nil && !card.lost {
phase, status = card.phase, card.status
step, tool = card.step, card.tool
iters, tokens, durS = card.iters, card.tokens, card.durS
if card.costUSD > 0 {
cost = card.costUSD
}
} else if card := m.cardByTask(e.TaskID); card != nil && !card.lost {
phase, status = card.phase, card.status
step, tool = card.step, card.tool
iters, tokens, durS = card.iters, card.tokens, card.durS
if card.costUSD > 0 {
cost = card.costUSD
}
}
if phase == "finished" {
detail = fmt.Sprintf(" %s · %d it · %s tok", collapse(status), iters, human(tokens))
} else {
tool := collapse(e.LastTool)
if tool == "" && e.Step > 0 {
tool = fmt.Sprintf("step %d", e.Step)
t := collapse(tool)
if t == "" && step > 0 {
t = fmt.Sprintf("step %d", step)
}
if tool == "" {
tool = "running"
if t == "" {
t = "running"
}
detail = " running · " + tool
detail = " running · " + t
}
if e.DurationSeconds > 0 {
detail += fmt.Sprintf(" · %.1fs", e.DurationSeconds)
if durS > 0 {
detail += fmt.Sprintf(" · %.1fs", durS)
}
if e.CostUSD > 0 {
detail += " · " + fmtCost(e.CostUSD)
if cost > 0 {
detail += " · " + fmtCost(cost)
}
budget := w - 2 - lipgloss.Width(detail)
label := agentStatusGlyph(e.Phase, e.Status) + fmt.Sprintf(" SA%d ", sa) + goal
// The glyph reads the merged phase/status so a row never shows a
// spinning glyph beside a finished summary (or the reverse).
label := agentStatusGlyph(phase, status) + fmt.Sprintf(" SA%d ", sa) + goal
prefix, lab := " ", th.acItem.Render(truncate(label, budget))
if i == m.panelSel {
prefix, lab = th.acSel.Render("› "), th.acSel.Render(truncate(label, budget))
Expand Down
Loading