Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d91e6af
docs: Add the implementation plan for agentop tool rows (#1776)
huang195 Nov 11, 2026
04fdb73
docs: Fix the name clash and vague steps in the tool rows plan
huang195 Nov 11, 2026
319bda7
feat: Let a short table row's last cell span the remaining columns
huang195 Nov 11, 2026
3c404ef
docs: Say how agentop measures a tool's duration
huang195 Nov 11, 2026
9f6068f
feat: Fetch the timeline while the events pane shows a session
huang195 Nov 11, 2026
4f78ad9
fix: Drop the old timeline on a session change and on an erase
huang195 Nov 11, 2026
b14da0e
feat: Index timeline tools by the response that asked for them
huang195 Nov 11, 2026
fef0756
feat: List tool calls in the events table with their duration
huang195 Nov 11, 2026
2bc13be
test: Find a tool row by text that no shown column holds
huang195 Nov 11, 2026
1bf5869
test: Add tool rows to the events-table rebuild benchmark
huang195 Nov 11, 2026
09c4e30
fix: Remove control runes from a tool row's name and preview
huang195 Nov 11, 2026
5fe711b
fix: Start the timeline fetch again after Esc from the Agents overlay
huang195 Nov 11, 2026
da39709
refactor: Remove the unread eventRow.parent field
huang195 Nov 11, 2026
501c7bd
fix: Fetch no timeline for a cached-only session after Esc
huang195 Nov 11, 2026
e4e1259
fix: Match a Bash row to its background job by tool ID
huang195 Nov 11, 2026
4da7198
fix: Keep the cursor on a tool row whose ID other rows also use
huang195 Nov 11, 2026
16e5036
docs: Name the DURATION column in the help line for tool rows
huang195 Nov 11, 2026
385456b
fix: Give a Bash job only to the call that started it
huang195 Nov 11, 2026
e18d3ef
fix: Fetch no timeline for a cached-only session from the Timeline pane
huang195 Nov 11, 2026
9330e7b
fix: Keep the timeline tick chain through a list blip; fit the sorted…
huang195 Nov 11, 2026
a6fba43
fix: Fetch a never-fetched timeline on the first tick after its sessi…
huang195 Nov 11, 2026
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
8 changes: 8 additions & 0 deletions cmd/agentop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,14 @@ agentop is for, and the other four are surfaces you visit and leave.
toward. Sorting never changes the `#` numbers or a row's figures. It reorders the
finished rows only.

Each tool call has its own row under the exchange whose response asked for it.
Its `#` cell is `┣⚒` or `┗⚒` (the last tool of the exchange). In a sorted view it
is `↳<n>`, where `<n>` is the `#` of that exchange. From exchange 1000 it is `<n>`
only, because the column has no space for `↳`. The tool's DURATION is measured
from outside the agent: from the reply to the next request of the same agent. So
it includes the client's own delay, and it can miss a tool that started while the
reply still streamed.

The twelve default columns together need about 180 terminal columns, so the table
drops what does not fit and the footer says how many (`→ N more columns`). Columns carry a
keep rank rather than being equally expendable: DIR, DURATION, TOKENS and COST
Expand Down
3 changes: 2 additions & 1 deletion cmd/agentop/tui/agents_pane.go
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,8 @@ func (m *model) leaveAgentsPane() tea.Cmd {
if m.pane == paneUsage {
return m.resumeUsagePolling()
}
if m.pane == paneTimeline {
// The Timeline pane, or the events and detail panes for their tool rows (#1776).
if m.timelineChainLive() {
return m.resumeTimeline()
}
return nil
Expand Down
5 changes: 3 additions & 2 deletions cmd/agentop/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2411,7 +2411,7 @@ func (m *model) handleStreamEvent(ev apiclient.StreamEvent) {
}
stale:
m.sessionsStale = true
if m.pane == paneTimeline && m.timeline.session == e.SessionID {
if m.timelineChainLive() && m.timeline.session == e.SessionID {
m.timeline.stale = true // refetched on the pane's next tick, at most once a second
}
if m.pane == paneEvents && m.selectedSess == e.SessionID {
Expand All @@ -2431,7 +2431,8 @@ stale:
// only opening another session or erasing clears it) or the timeline's
// (openTimeline does not set selectedSess; leaving the timeline pane clears
// it, so a session glanced at once does not keep its stream for the rest of
// the run). This is the retention predicate for streamed events (#1542):
// the run. The events pane sets it only to selectedSess, and erasing clears
// it with selectedSess). This is the retention predicate for streamed events (#1542):
// everything else arrives, updates its summary, and is dropped.
func (m *model) watchingSession(id string) bool {
return m.selectedSess == id || m.timeline.session == id
Expand Down
4 changes: 4 additions & 0 deletions cmd/agentop/tui/clear.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,9 @@ func (m *model) forgetSessions(ids []string) {
m.detailEvent = nil
m.detailPlugin = nil
}
if m.timeline.session == id {
m.setTimelineSession("") // a #1542 retention key, like selectedSess
}
}
m.sessions = slices.DeleteFunc(m.sessions, func(s session.SessionSummary) bool {
return slices.Contains(ids, s.ID)
Expand Down Expand Up @@ -368,6 +371,7 @@ func (m *model) resetAfterClear() {
m.detailEvent = nil
m.detailPlugin = nil
m.selectedSess = ""
m.setTimelineSession("") // a #1542 retention key, like selectedSess
m.selectedEventKey = eventKey{}
m.visibleRows = nil
if m.pane != paneAgents {
Expand Down
73 changes: 72 additions & 1 deletion cmd/agentop/tui/events_columns.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ var eventColumns = []eventColumn{
// 9 is the heading with a sort glyph, which is wider than anything durationCell
// produces ("12.34s", "59m59s").
{id: colDuration, width: 9, defaultOn: true, keep: keepLow, rightAlign: true,
desc: "how long the exchange took",
desc: "how long the exchange took; for a tool: from the reply to the next request of the same agent",
cell: func(c cellContext) string { return durationCell(*answer(c.row)) },
sortKey: durationSortKey},
// On by default since #1309. It was off because only an opaque tunnel's close row had
Expand Down Expand Up @@ -837,3 +837,74 @@ func anyColumnSelected(sel map[eventColumnID]bool) bool {
}
return false
}

// toolCells is the cells of tool row tr (#1776), whose exchange is cc. The columns up to
// DURATION get the tool's own cells. At the first shown column after them, one last cell
// holds the trailing text, and the table spans it to the right edge. sr is the row's search
// row: text that no shown column holds goes in Dropped, so a search still finds it.
func toolCells(cols []eventColumn, cc cellContext, tr eventRow, sorted bool, now time.Time) (table.Row, table.SearchRow) {
t := tr.tool
// The model writes the name and the preview, so a control rune must not reach the terminal.
name, trailing := sanitizeLabel(t.Name), sanitizeLabel(t.trailing(now))
row := make(table.Row, 0, len(cols))
var sr table.SearchRow
named, spanned := false, false
for _, c := range cols {
var v string
switch c.id {
case colIndex:
switch {
case sorted:
if v = c.cell(cc); len(v) < c.width {
v = "↳" + v // the number wins when both do not fit
}
case tr.last:
v = "┗⚒"
default:
v = "┣⚒"
}
case colTime:
v = t.Start.Format("15:04:05.00")
case colDir:
if cc.row.event.Subagent != "" && cc.m.subagentBlocks != nil {
v = "│"
}
case colAction, colPlugin:
case colMethod:
v, named = name, true
case colStatus:
v = t.status()
case colDuration:
v = t.durationCell(now)
default:
row = append(row, trailing)
spanned = true
}
if spanned {
break
}
row = append(row, c.align(v))
}
if !spanned && trailing != "" {
sr.Dropped = append(sr.Dropped, trailing)
}
if !named {
sr.Dropped = append(sr.Dropped, name)
}
return row, sr
}

// toolSortKey is a tool row's sort value on column id (rule 11). A column with no tool value
// gives the zero value, with numeric as the column's other keys have it, so less compares the
// same field on every row.
func toolSortKey(id eventColumnID, t toolRow, numeric bool, now time.Time) sortValue {
switch id {
case colDuration:
return numKey(t.duration(now))
case colTime:
return numKey(t.Start.UnixNano())
case colMethod:
return strKey(t.Name)
}
return sortValue{numeric: numeric}
}
78 changes: 67 additions & 11 deletions cmd/agentop/tui/events_pane.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ type eventKey struct {
direction pipeline.Direction
phase pipeline.SessionPhase
requestID string
// tool, toolAgent and toolSeq pin a tool row: its tool ID, agent and response seq. The seq
// tells apart two calls with one ID: some local models send call_0 on every turn.
tool, toolAgent string
toolSeq uint64
}

func keyOf(e *pipeline.SessionEvent) eventKey {
Expand All @@ -36,10 +40,28 @@ func keyOf(e *pipeline.SessionEvent) eventKey {
return eventKey{at: e.At.UnixNano(), direction: e.Direction, phase: e.Phase, requestID: e.RequestID}
}

// pinKeyOf is the pin key of row er: its tool for a tool row, else its event.
func pinKeyOf(er eventRow) eventKey {
if er.tool != nil {
return eventKey{tool: er.tool.ID, toolAgent: er.tool.Agent, toolSeq: er.tool.Seq}
}
return keyOf(er.event)
}

// selectedPinKey is the pin key of the row under the cursor.
func (m *model) selectedPinKey() eventKey {
return pinKeyOf(m.selectedRowOn(m.eventsTbl.Cursor()))
}

// findByKey is the row that holds the event with key k, by either half (#1681). A pin can
// name a response: a response-only row is keyed by it, and the row merges with its request
// when the older page with the request loads.
func findByKey(rows []eventRow, k eventKey) int {
if k.tool != "" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: A tool with an empty ID cannot be pinned. pinKeyOf gives {tool: "", toolAgent: "main", toolSeq: N}. Here k.tool == "" takes the event branch, which matches nothing, so the next rebuild (any streamed event) moves the cursor to the top and clears the pin. Some OpenAI-compatible and local models send tool calls with no id, and the parsers pass the empty ID through. A probe showed the cursor go from 1 to 0. Testing k.toolAgent != "" (a tool row always has an agent), or adding an explicit isTool flag to eventKey, avoids this.

return slices.IndexFunc(rows, func(r eventRow) bool {
return r.tool != nil && r.tool.ID == k.tool && r.tool.Agent == k.toolAgent && r.tool.Seq == k.toolSeq
})
}
return slices.IndexFunc(rows, func(r eventRow) bool {
return rowHolds(r, func(e *pipeline.SessionEvent) bool { return keyOf(e) == k })
})
Expand Down Expand Up @@ -86,6 +108,10 @@ type eventRow struct {
// pane fetched them for.
raw *pipeline.RawCapture
respRaw *pipeline.RawCapture
// tool is set on a tool row (#1776), which has no event. last is set on the last tool
// of its exchange.
tool *toolRow
last bool
}

// invocations returns every plugin invocation the row's ACTION/PLUGIN cell and the inactive
Expand Down Expand Up @@ -259,6 +285,20 @@ func (m *model) rebuildEventsTable() {
}
}
}
// Tool rows (#1776) join an exchange by its response's seq, only for the session the
// timeline is for: a linked bucket's events carry seqs of another session.
var tools map[uint64][]toolRow
if m.timeline.session == m.selectedSess {
tools = m.timeline.tools
}
toolsOf := func(er eventRow) []toolRow {
resp := exchangeResp(er)
if len(tools) == 0 || resp == nil || resp.Seq == 0 || eventSession(m.selectedSess, resp) != m.selectedSess {
return nil
}
return tools[resp.Seq]
}
now := m.clock()
q := m.searchQuery(paneEvents)
var full []table.SearchRow
var droppedCols []eventColumn
Expand All @@ -271,8 +311,9 @@ func (m *model) rebuildEventsTable() {
// renderEventRow appends one event's cells to every parallel structure the
// pane keeps — the table's rows, visibleRows, and the search rows — so the
// two places a row can render from (its own loop turn, or its block's
// expansion below) cannot drift apart.
renderEventRow := func(j int) {
// expansion below) cannot drift apart. The exchange's tool rows follow it, and it
// returns them.
renderEventRow := func(j int) []toolRow {
cc := ctxs[j]
row := make(table.Row, 0, len(cols))
for _, c := range cols {
Expand All @@ -293,6 +334,17 @@ func (m *model) rebuildEventsTable() {
if q != "" {
full = append(full, eventSearchRow(cols, droppedCols, cc))
}
ts := toolsOf(cc.row)
for i := range ts {
tr := eventRow{tool: &ts[i], last: i == len(ts)-1}
cells, sr := toolCells(cols, cc, tr, sortCol != nil, now)
rows = append(rows, cells)
m.visibleRows = append(m.visibleRows, tr)
if q != "" {
full = append(full, sr)
}
}
return ts
}
for j, cc := range ctxs {
// hideInactive (the `s` toggle) is off by default — every message is
Expand Down Expand Up @@ -347,7 +399,7 @@ func (m *model) rebuildEventsTable() {
continue
}

renderEventRow(j)
ts := renderEventRow(j)
// Keyed from the SAME cellContext that just rendered the row, so the value
// sorted on and the value displayed cannot come apart. cc.width stays zero
// here — render takes its own copy — and no sortKey reads it.
Expand All @@ -361,7 +413,11 @@ func (m *model) rebuildEventsTable() {
// because blocks exist only while no sort is active (see the collection
// above), so a row the sort needs cannot be skipped by a block.
if sortCol != nil {
keys = append(keys, rowKey{val: sortCol.sortKey(cc), exchange: j})
k := sortCol.sortKey(cc)
keys = append(keys, rowKey{val: k, exchange: j})
for i := range ts {
keys = append(keys, rowKey{val: toolSortKey(sortCol.id, ts[i], k.numeric, now), exchange: j})
}
}
}
// Sort the OUTPUT, never the input. buildEventRows' tunnel fold, the backward
Expand Down Expand Up @@ -443,7 +499,7 @@ func (m *model) rebuildEventsTable() {
// zero-valued: a real pin, including one the eviction branch below deliberately
// cleared, must not be overwritten.
if m.sortCol != "" && m.selectedEventKey == (eventKey{}) {
m.selectedEventKey = keyOf(m.selectedEventOn(prevRow))
m.selectedEventKey = pinKeyOf(m.selectedRowOn(prevRow))
}

// An OPENING, not a refresh: the session under the table changed. This is the
Expand Down Expand Up @@ -570,18 +626,18 @@ func sortEventRows(rows []table.Row, visible []eventRow, full []table.SearchRow,
}
}

// selectedEventOn returns the event at an arbitrary row of the rows just built, or
// nil when the row is out of range.
// selectedRowOn returns the row at an arbitrary row of the rows just built, or the zero
// eventRow when the row is out of range.
//
// Distinct from selectedEvent, which reads the TABLE's cursor: during a rebuild the
// Distinct from selectedEventRow, which reads the TABLE's cursor: during a rebuild the
// table has already been handed the new rows but the cursor has not been restored
// yet, so the only meaningful row index is the one carried over from before the
// rebuild. Used to seed the sort pin from that row.
func (m *model) selectedEventOn(row int) *pipeline.SessionEvent {
func (m *model) selectedRowOn(row int) eventRow {
if row < 0 || row >= len(m.visibleRows) {
return nil
return eventRow{}
}
return m.visibleRows[row].event
return m.visibleRows[row]
}

// selectedEvent returns the event at the cursor row, or nil. The cursor
Expand Down
35 changes: 29 additions & 6 deletions cmd/agentop/tui/events_rebuild_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/rossoctl/cortex/core/cost/event"
"github.com/rossoctl/cortex/core/pipeline"
"github.com/rossoctl/cortex/core/session/timeline"
)

// BenchmarkRebuildEventsTable is one rebuild of a 2,000-event session in which every
Expand All @@ -16,18 +17,22 @@ import (
// The cost record is what makes TOKENS and COST the expensive cells — each decodes it —
// so a fixture without one would not show what evaluating them twice per row costs.
// "searched" marks one row in ten, which costs one match per row on top of "plain": a search
// hides nothing, so every row is still rendered.
// hides nothing, so every row is still rendered. Each response also has tool rows from the
// timeline, and "sorted" sorts by DURATION, which ranks the tool rows among the exchanges.
func BenchmarkRebuildEventsTable(b *testing.B) {
for _, bc := range []struct {
name string
search string
sort eventColumnID
}{
{"plain", ""},
{"searched", "h7.example"},
{"plain", "", ""},
{"searched", "h7.example", ""},
{"sorted", "", colDuration},
} {
b.Run(bc.name, func(b *testing.B) {
m := rebuildBenchModel(b, 1000)
m.search = map[paneID]string{paneEvents: bc.search}
m.sortCol = bc.sort
b.ReportAllocs()
for b.Loop() {
m.rebuildEventsTable()
Expand All @@ -38,7 +43,8 @@ func BenchmarkRebuildEventsTable(b *testing.B) {

// rebuildBenchModel is an events pane over n inference exchanges, every response priced and
// every other one carrying an estimated tool-prune saving. Hosts cycle through ten values so
// a search for one of them matches a tenth of the rows.
// a search for one of them matches a tenth of the rows. Each response asks for one tool, and
// every 20th response asks for two parallel tools; each tool ends 1s after its response.
func rebuildBenchModel(b *testing.B, n int) *model {
b.Helper()
plain, err := json.Marshal(event.Event{CostUSD: 0.2767, Settled: true, PromptUSD: 0.2546, OutputUSD: 0.0221})
Expand All @@ -53,6 +59,7 @@ func rebuildBenchModel(b *testing.B, n int) *model {

start := time.Date(2026, 10, 2, 13, 0, 0, 0, time.UTC)
events := make([]pipeline.SessionEvent, 0, 2*n)
tl := &timeline.Timeline{Tools: make([]timeline.Tool, 0, n+n/20)}
for i := range n {
at := start.Add(time.Duration(i) * 3 * time.Second)
host := fmt.Sprintf("h%d.example", i%10)
Expand All @@ -63,25 +70,41 @@ func rebuildBenchModel(b *testing.B, n int) *model {
}
events = append(events, pipeline.SessionEvent{
At: at, SessionID: "s", Direction: pipeline.Outbound, Phase: pipeline.SessionRequest,
Host: host, RequestID: id,
Host: host, RequestID: id, Seq: uint64(2*i + 1),
Inference: &pipeline.InferenceExtension{Model: "claude-sonnet-5"},
}, pipeline.SessionEvent{
At: at.Add(2 * time.Second), SessionID: "s",
Direction: pipeline.Outbound, Phase: pipeline.SessionResponse,
Host: host, StatusCode: 200, Duration: 2 * time.Second, RequestID: id,
Host: host, StatusCode: 200, Duration: 2 * time.Second, RequestID: id, Seq: uint64(2*i + 2),
Inference: &pipeline.InferenceExtension{
Model: "claude-sonnet-5", InputTokens: 200_000 + i*100, OutputTokens: 400,
},
Plugins: map[string]json.RawMessage{event.Key: rec},
})
toolStart := at.Add(2 * time.Second)
toolEnd := toolStart.Add(time.Second)
tools := 1
if i%20 == 0 {
tools = 2
}
for k := range tools {
tl.Tools = append(tl.Tools, timeline.Tool{
Agent: "main", ID: fmt.Sprintf("t%d-%d", i, k), Name: "Bash",
Start: toolStart, End: &toolEnd, Preview: "git -C /repo status", Seq: uint64(2*i + 2),
})
}
}
m := &model{
pane: paneEvents, selectedSess: "s",
width: 200, height: 40, bodyHeight: 30,
events: map[string][]pipeline.SessionEvent{"s": events},
eventColumns: defaultColumnSelection(),
}
m.timeline.session, m.timeline.tl, m.timeline.tools = "s", tl, indexTools(tl)
m.eventsTbl = newEventsTable()
m.rebuildEventsTable()
if got, want := len(m.eventsTbl.Rows()), n+len(tl.Tools); got != want {
b.Fatalf("rows = %d, want %d (each exchange and each tool)", got, want)
}
return m
}
Loading
Loading