Skip to content

Commit ac49bcb

Browse files
wesmclaude
andcommitted
feat: add agentsview cost estimate to roborev queue view
internal/tokens now prefers `agentsview session usage <id> --format json` on agentsview >= 0.30.0 and falls back to the deprecated `token-use` on 0.15.0-0.29.x, selected via a capability probe. It parses the new cost_usd/has_cost fields into Usage. Usage exit codes 2 (session not found) and 3 (no token/cost data) are treated as "no usage" rather than errors; the legacy token-use exit-1 empty-output case is still handled for older agentsview. FormatSummary appends "· ~$0.42" when a cost estimate is present (the review detail view picks this up automatically); FormatCost returns the cost alone. The TUI queue gains a default-visible "Cost" column showing the per-job estimate, blank when unpriced or not yet fetched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a7078db commit ac49bcb

4 files changed

Lines changed: 444 additions & 176 deletions

File tree

cmd/roborev/tui/queue_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,53 @@ func TestTUIJobCellsReviewTypeTag(t *testing.T) {
631631
}
632632
}
633633

634+
func TestTUIJobCellsCost(t *testing.T) {
635+
m := model{width: 200}
636+
// cells[k] maps to logical column colRef+k (see jobCells copy),
637+
// so the cost cell is at colCost-colRef.
638+
costIdx := colCost - colRef
639+
640+
t.Run("priced cost renders", func(t *testing.T) {
641+
job := makeJob(1)
642+
job.TokenUsage = `{"total_output_tokens":28800,` +
643+
`"peak_context_tokens":118000,"cost_usd":0.42,"has_cost":true}`
644+
cells := m.jobCells(job)
645+
assert.Equal(t, "~$0.42", cells[costIdx])
646+
})
647+
648+
t.Run("no usage blank", func(t *testing.T) {
649+
cells := m.jobCells(makeJob(1))
650+
assert.Empty(t, cells[costIdx])
651+
})
652+
653+
t.Run("unpriced tokens blank", func(t *testing.T) {
654+
job := makeJob(1)
655+
job.TokenUsage = `{"total_output_tokens":28800,` +
656+
`"peak_context_tokens":118000,"has_cost":false}`
657+
cells := m.jobCells(job)
658+
assert.Empty(t, cells[costIdx])
659+
})
660+
}
661+
662+
func TestTUIQueueShowsCostColumnByDefault(t *testing.T) {
663+
m := newModel(localhostEndpoint, withExternalIODisabled())
664+
m.width = 200
665+
m.height = 30
666+
job := makeJob(1, withRef("abc1234"),
667+
withRepoName("repo"), withAgent("test"))
668+
job.TokenUsage = `{"total_output_tokens":28800,` +
669+
`"peak_context_tokens":118000,"cost_usd":0.42,"has_cost":true}`
670+
m.jobs = []storage.ReviewJob{job}
671+
m.selectedIdx = 0
672+
m.selectedJobID = 1
673+
674+
out := stripTestANSI(m.renderQueueView())
675+
assert.Contains(t, out, "Cost",
676+
"Cost header should be visible by default")
677+
assert.Contains(t, out, "~$0.42",
678+
"cost value should render in the row")
679+
}
680+
634681
func TestTUIQueueTableRendersWithinWidth(t *testing.T) {
635682

636683
widths := []int{80, 100, 120, 200}

cmd/roborev/tui/render_queue.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"go.kenn.io/roborev/internal/agent"
1414
"go.kenn.io/roborev/internal/config"
1515
"go.kenn.io/roborev/internal/storage"
16+
"go.kenn.io/roborev/internal/tokens"
1617
"go.kenn.io/roborev/internal/version"
1718
)
1819

@@ -119,6 +120,7 @@ const (
119120
colSessionID // Session ID
120121
colRequestedModel // Explicitly requested model
121122
colRequestedProvider // Explicitly requested provider
123+
colCost // Cost estimate (USD)
122124
colCount // total number of columns
123125
)
124126

@@ -268,7 +270,7 @@ func (m model) renderQueueView() string {
268270
visCols := m.visibleColumns()
269271

270272
// Compute per-column max content widths, using cache when data hasn't changed.
271-
allHeaders := [colCount]string{"", "JobID", "Ref", "Branch", "Repo", "Agent", "Queued", "Elapsed", "Status", "P/F", "Closed", "Session", "Req Model", "Req Provider"}
273+
allHeaders := [colCount]string{"", "JobID", "Ref", "Branch", "Repo", "Agent", "Queued", "Elapsed", "Status", "P/F", "Closed", "Session", "Req Model", "Req Provider", "Cost"}
272274
allFullRows := make([][]string, len(visibleJobList))
273275
for i, job := range visibleJobList {
274276
cells := m.jobCells(job)
@@ -327,6 +329,7 @@ func (m model) renderQueueView() string {
327329
colSessionID: min(max(contentWidth[colSessionID], 7), 12), // "Session" header = 7, cap at 12
328330
colRequestedModel: min(max(contentWidth[colRequestedModel], 9), 24), // "Req Model" header = 9
329331
colRequestedProvider: min(max(contentWidth[colRequestedProvider], 12), 24), // "Req Provider" header = 12
332+
colCost: max(contentWidth[colCost], 4), // "Cost" header = 4
330333
}
331334

332335
// Flexible columns absorb excess space
@@ -613,7 +616,7 @@ func (m model) renderQueueView() string {
613616

614617
// jobCells returns plain text cell values for a job row.
615618
// Order: ref, branch, repo, agent, queued, elapsed, status, pf, handled,
616-
// session, requested model, requested provider.
619+
// session, requested model, requested provider, cost.
617620
func (m model) jobCells(job storage.ReviewJob) []string {
618621
ref := shortJobRef(job)
619622
if !config.IsDefaultReviewType(job.ReviewType) {
@@ -667,7 +670,15 @@ func (m model) jobCells(job storage.ReviewJob) []string {
667670
requestedModel := stripControlChars(job.RequestedModel)
668671
requestedProvider := stripControlChars(job.RequestedProvider)
669672

670-
return []string{ref, branch, repo, agentName, enqueued, elapsed, status, verdict, handled, sessionID, requestedModel, requestedProvider}
673+
// Cost estimate from the stored agentsview usage blob; blank when
674+
// no priced estimate is available (old agentsview, unpriced model,
675+
// or usage not yet fetched for a running/queued job).
676+
cost := ""
677+
if tu := tokens.ParseJSON(job.TokenUsage); tu != nil {
678+
cost = tu.FormatCost()
679+
}
680+
681+
return []string{ref, branch, repo, agentName, enqueued, elapsed, status, verdict, handled, sessionID, requestedModel, requestedProvider, cost}
671682
}
672683

673684
// statusLabel returns a capitalized display label for the job status.
@@ -840,7 +851,7 @@ func migrateColumnConfig(cfg *config.Config) bool {
840851

841852
// toggleableColumns is the ordered list of columns the user can show/hide.
842853
// colSel and colJobID are always visible and not included here.
843-
var toggleableColumns = []int{colRef, colBranch, colRepo, colAgent, colQueued, colElapsed, colStatus, colPF, colHandled, colSessionID, colRequestedModel, colRequestedProvider}
854+
var toggleableColumns = []int{colRef, colBranch, colRepo, colAgent, colQueued, colElapsed, colStatus, colPF, colHandled, colCost, colSessionID, colRequestedModel, colRequestedProvider}
844855

845856
// columnNames maps column constants to display names.
846857
var columnNames = map[int]string{
@@ -856,6 +867,7 @@ var columnNames = map[int]string{
856867
colSessionID: "Session",
857868
colRequestedModel: "Req Model",
858869
colRequestedProvider: "Req Provider",
870+
colCost: "Cost",
859871
}
860872

861873
// columnConfigNames maps column constants to config file names (lowercase).
@@ -872,6 +884,7 @@ var columnConfigNames = map[int]string{
872884
colSessionID: "session_id",
873885
colRequestedModel: "requested_model",
874886
colRequestedProvider: "requested_provider",
887+
colCost: "cost",
875888
}
876889

877890
// drainFlexOverflow reduces flex column widths to absorb overflow,

0 commit comments

Comments
 (0)