detached d7aa01a

Merge pull request #137 from bnema/feat/cache-usage-summaries

feat(tui): show cache usage for agents
commit
d7aa01a3dd89902b3a8a0b314aa3f8b79b62c0bc
author
bnema · · 2026-08-15 13:21 UTC
committer
GitHub · · 2026-08-15 13:21 UTC
merge of
f294dc4 + eb05ca1 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.19.0
16 files · +549 -80
M packages/agent/modes/resident_subagent_indicator.go +33 -7
@@ -20,7 +20,7 @@ var residentProviderWaitingSpinner = tui.NewStringSpinner(
2020 // child is waiting for a resident scheduler slot; only running children use
2121 // the animated glyph.
2222 func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {
23- lines := make([]string, 0, len(snapshots))
23+ lines := make([]string, 0, len(snapshots)*2)
2424 for _, snapshot := range snapshots {
2525 // Foreign journals remain discoverable through /subagents, but their
2626 // activity belongs to another zut host and must not occupy this
@@ -58,10 +58,24 @@ func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, s
5858 continue
5959 }
6060 lines = append(lines, " "+theme.FGColor(color, plain))
61+ if metadata := residentUsageMetadata(snapshot); metadata != "" {
62+ metadata = truncateResidentSubagentIndicator(metadata, width-4)
63+ if metadata != "" {
64+ lines = append(lines, " "+theme.FGColor(theme.Muted, metadata))
65+ }
66+ }
6167 }
6268 return lines
6369 }
6470
71+func residentUsageMetadata(snapshot subagents.ResidentSnapshot) string {
72+ parts := tui.UsageStatsParts(tui.UsageStatsParams{Usage: snapshot.Usage, Subscription: snapshot.Subscription})
73+ if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" {
74+ parts = append(parts, context)
75+ }
76+ return strings.Join(parts, " ")
77+}
78+
6579 func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time {
6680 if !snapshot.TurnStartedAt.IsZero() {
6781 return snapshot.TurnStartedAt
@@ -104,18 +118,30 @@ func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden,
104118 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
105119 return nil
106120 }
121+ groups := make([][]string, 0, len(lines))
122+ for _, line := range lines {
123+ if strings.HasPrefix(line, " ") && len(groups) > 0 {
124+ groups[len(groups)-1] = append(groups[len(groups)-1], line)
125+ continue
126+ }
127+ groups = append(groups, []string{line})
128+ }
107129 if hidden == 0 && len(lines) <= maxRows {
108130 return lines
109131 }
110132 if maxRows == 1 {
111- return []string{residentSubagentActivityOverflowLine(theme, len(lines)+hidden, width)}
133+ return []string{residentSubagentActivityOverflowLine(theme, len(groups)+hidden, width)}
112134 }
113- visible := len(lines)
114- if visible > maxRows-1 {
115- visible = maxRows - 1
135+ out := make([]string, 0, maxRows)
136+ visibleGroups := 0
137+ for _, group := range groups {
138+ if len(out)+len(group) > maxRows-1 {
139+ break
140+ }
141+ out = append(out, group...)
142+ visibleGroups++
116143 }
117- out := append([]string(nil), lines[:visible]...)
118- return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(lines)-visible, width))
144+ return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(groups)-visibleGroups, width))
119145 }
120146
121147 func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {
@@ -20,7 +20,7 @@ var residentProviderWaitingSpinner = tui.NewStringSpinner(
20 // child is waiting for a resident scheduler slot; only running children use20 // child is waiting for a resident scheduler slot; only running children use
21 // the animated glyph.21 // the animated glyph.
22 func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {22 func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {
23- lines := make([]string, 0, len(snapshots))23+ lines := make([]string, 0, len(snapshots)*2)
24 for _, snapshot := range snapshots {24 for _, snapshot := range snapshots {
25 // Foreign journals remain discoverable through /subagents, but their25 // Foreign journals remain discoverable through /subagents, but their
26 // activity belongs to another zut host and must not occupy this26 // activity belongs to another zut host and must not occupy this
@@ -58,10 +58,24 @@ func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, s
58 continue58 continue
59 }59 }
60 lines = append(lines, " "+theme.FGColor(color, plain))60 lines = append(lines, " "+theme.FGColor(color, plain))
61+ if metadata := residentUsageMetadata(snapshot); metadata != "" {
62+ metadata = truncateResidentSubagentIndicator(metadata, width-4)
63+ if metadata != "" {
64+ lines = append(lines, " "+theme.FGColor(theme.Muted, metadata))
65+ }
66+ }
61 }67 }
62 return lines68 return lines
63 }69 }
64 70
71+func residentUsageMetadata(snapshot subagents.ResidentSnapshot) string {
72+ parts := tui.UsageStatsParts(tui.UsageStatsParams{Usage: snapshot.Usage, Subscription: snapshot.Subscription})
73+ if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" {
74+ parts = append(parts, context)
75+ }
76+ return strings.Join(parts, " ")
77+}
78+
65 func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time {79 func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time {
66 if !snapshot.TurnStartedAt.IsZero() {80 if !snapshot.TurnStartedAt.IsZero() {
67 return snapshot.TurnStartedAt81 return snapshot.TurnStartedAt
@@ -104,18 +118,30 @@ func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden,
104 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {118 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
105 return nil119 return nil
106 }120 }
121+ groups := make([][]string, 0, len(lines))
122+ for _, line := range lines {
123+ if strings.HasPrefix(line, " ") && len(groups) > 0 {
124+ groups[len(groups)-1] = append(groups[len(groups)-1], line)
125+ continue
126+ }
127+ groups = append(groups, []string{line})
128+ }
107 if hidden == 0 && len(lines) <= maxRows {129 if hidden == 0 && len(lines) <= maxRows {
108 return lines130 return lines
109 }131 }
110 if maxRows == 1 {132 if maxRows == 1 {
111- return []string{residentSubagentActivityOverflowLine(theme, len(lines)+hidden, width)}133+ return []string{residentSubagentActivityOverflowLine(theme, len(groups)+hidden, width)}
112 }134 }
113- visible := len(lines)135+ out := make([]string, 0, maxRows)
114- if visible > maxRows-1 {136+ visibleGroups := 0
115- visible = maxRows - 1137+ for _, group := range groups {
138+ if len(out)+len(group) > maxRows-1 {
139+ break
140+ }
141+ out = append(out, group...)
142+ visibleGroups++
116 }143 }
117- out := append([]string(nil), lines[:visible]...)144+ return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(groups)-visibleGroups, width))
118- return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(lines)-visible, width))
119 }145 }
120 146
121 func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {147 func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {
M packages/agent/modes/resident_subagent_indicator_test.go +23 -0
@@ -6,6 +6,7 @@ import (
66 "time"
77
88 "github.com/bnema/zut/packages/agent/subagents"
9+ "github.com/bnema/zut/packages/provider"
910 "github.com/bnema/zut/packages/tui"
1011 )
1112
@@ -26,6 +27,19 @@ func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T
2627 }
2728 }
2829
30+func TestRenderResidentSubagentActivityLinesShowsUsageMetadata(t *testing.T) {
31+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
32+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
33+ ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
34+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525},
35+ ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
36+ }}, 100, now)
37+ got := strings.Join(plainResidentIndicatorLines(lines), "\n")
38+ if !strings.Contains(got, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 16.6%/272k") {
39+ t.Fatalf("activity lines = %q, want usage metadata line", got)
40+ }
41+}
42+
2943 func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) {
3044 started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
3145 now := started.Add(2 * time.Second)
@@ -59,6 +73,15 @@ func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
5973 }
6074 }
6175
76+func TestLimitResidentSubagentActivityLinesKeepsMetadataWithItsAgent(t *testing.T) {
77+ lines := []string{" agent one", " usage one", " agent two", " usage two"}
78+ got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80))
79+ want := []string{" agent one", " usage one", " … 1 more active subagents"}
80+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
81+ t.Fatalf("limited lines = %#v, want %#v", got, want)
82+ }
83+}
84+
6285 func TestLimitResidentSubagentActivityLinesIncludesAlreadyHiddenChildren(t *testing.T) {
6386 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 4, 2, 80))
6487 want := []string{"one", " … 6 more active subagents"}
@@ -6,6 +6,7 @@ import (
6 "time"6 "time"
7 7
8 "github.com/bnema/zut/packages/agent/subagents"8 "github.com/bnema/zut/packages/agent/subagents"
9+ "github.com/bnema/zut/packages/provider"
9 "github.com/bnema/zut/packages/tui"10 "github.com/bnema/zut/packages/tui"
10 )11 )
11 12
@@ -26,6 +27,19 @@ func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T
26 }27 }
27 }28 }
28 29
30+func TestRenderResidentSubagentActivityLinesShowsUsageMetadata(t *testing.T) {
31+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
32+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
33+ ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
34+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525},
35+ ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
36+ }}, 100, now)
37+ got := strings.Join(plainResidentIndicatorLines(lines), "\n")
38+ if !strings.Contains(got, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 16.6%/272k") {
39+ t.Fatalf("activity lines = %q, want usage metadata line", got)
40+ }
41+}
42+
29 func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) {43 func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) {
30 started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)44 started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
31 now := started.Add(2 * time.Second)45 now := started.Add(2 * time.Second)
@@ -59,6 +73,15 @@ func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
59 }73 }
60 }74 }
61 75
76+func TestLimitResidentSubagentActivityLinesKeepsMetadataWithItsAgent(t *testing.T) {
77+ lines := []string{" agent one", " usage one", " agent two", " usage two"}
78+ got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80))
79+ want := []string{" agent one", " usage one", " … 1 more active subagents"}
80+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
81+ t.Fatalf("limited lines = %#v, want %#v", got, want)
82+ }
83+}
84+
62 func TestLimitResidentSubagentActivityLinesIncludesAlreadyHiddenChildren(t *testing.T) {85 func TestLimitResidentSubagentActivityLinesIncludesAlreadyHiddenChildren(t *testing.T) {
63 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 4, 2, 80))86 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 4, 2, 80))
64 want := []string{"one", " … 6 more active subagents"}87 want := []string{"one", " … 6 more active subagents"}
M packages/agent/modes/resident_subagents_dialog.go +37 -7
@@ -43,8 +43,8 @@ func (d *residentSubagentsDialog) refresh(limit int) {
4343 d.total = 0
4444 return
4545 }
46- if limit < 1 {
47- limit = 1
46+ if limit < 0 {
47+ limit = 0
4848 }
4949 _, total := d.manager.SnapshotPage(0, 0)
5050 d.total = total
@@ -54,6 +54,10 @@ func (d *residentSubagentsDialog) refresh(limit int) {
5454 if d.selected < 0 || d.total == 0 {
5555 d.selected = 0
5656 }
57+ if limit == 0 {
58+ d.rows = nil
59+ return
60+ }
5761 if d.selected < d.offset {
5862 d.offset = d.selected
5963 }
@@ -91,14 +95,24 @@ func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []s
9195 if d == nil {
9296 return nil
9397 }
94- maxRows := height - 3 // title, hint, and page indicator/empty state
95- if maxRows < 1 {
96- maxRows = 1
98+ // Reserve two lines per child so usage metadata never pushes the dialog
99+ // beyond its content height. Children without usage simply consume less.
100+ maxRows := (height - 3) / 2 // title, hint, and page indicator/empty state
101+ if maxRows < 0 {
102+ maxRows = 0
97103 }
98104 d.refresh(maxRows)
99105 lines := []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"}
100106 if len(d.rows) == 0 {
101- return append(lines, "", " No resident subagents.")
107+ if d.total > 0 {
108+ return limitResidentSubagentsDialogHeight(lines, height)
109+ }
110+ if height == len(lines)+1 {
111+ lines = append(lines, " No resident subagents.")
112+ } else {
113+ lines = append(lines, "", " No resident subagents.")
114+ }
115+ return limitResidentSubagentsDialogHeight(lines, height)
102116 }
103117 for index, row := range d.rows {
104118 marker := " "
@@ -114,11 +128,27 @@ func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []s
114128 label += " required"
115129 }
116130 lines = append(lines, marker+truncateResidentSubagentIndicator(label, width-2))
131+ if metadata := residentUsageMetadata(row); metadata != "" {
132+ metadata = truncateResidentSubagentIndicator(metadata, width-4)
133+ if metadata != "" {
134+ lines = append(lines, " "+metadata)
135+ }
136+ }
117137 }
118138 if d.total > len(d.rows) {
119139 lines = append(lines, fmt.Sprintf(" %d-%d of %d", d.offset+1, d.offset+len(d.rows), d.total))
120140 }
121- return lines
141+ return limitResidentSubagentsDialogHeight(lines, height)
142+}
143+
144+func limitResidentSubagentsDialogHeight(lines []string, height int) []string {
145+ if height <= 0 {
146+ return nil
147+ }
148+ if len(lines) <= height {
149+ return lines
150+ }
151+ return lines[:height]
122152 }
123153
124154 func residentDisplayName(row subagents.ResidentSnapshot) string {
@@ -43,8 +43,8 @@ func (d *residentSubagentsDialog) refresh(limit int) {
43 d.total = 043 d.total = 0
44 return44 return
45 }45 }
46- if limit < 1 {46+ if limit < 0 {
47- limit = 147+ limit = 0
48 }48 }
49 _, total := d.manager.SnapshotPage(0, 0)49 _, total := d.manager.SnapshotPage(0, 0)
50 d.total = total50 d.total = total
@@ -54,6 +54,10 @@ func (d *residentSubagentsDialog) refresh(limit int) {
54 if d.selected < 0 || d.total == 0 {54 if d.selected < 0 || d.total == 0 {
55 d.selected = 055 d.selected = 0
56 }56 }
57+ if limit == 0 {
58+ d.rows = nil
59+ return
60+ }
57 if d.selected < d.offset {61 if d.selected < d.offset {
58 d.offset = d.selected62 d.offset = d.selected
59 }63 }
@@ -91,14 +95,24 @@ func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []s
91 if d == nil {95 if d == nil {
92 return nil96 return nil
93 }97 }
94- maxRows := height - 3 // title, hint, and page indicator/empty state98+ // Reserve two lines per child so usage metadata never pushes the dialog
95- if maxRows < 1 {99+ // beyond its content height. Children without usage simply consume less.
96- maxRows = 1100+ maxRows := (height - 3) / 2 // title, hint, and page indicator/empty state
101+ if maxRows < 0 {
102+ maxRows = 0
97 }103 }
98 d.refresh(maxRows)104 d.refresh(maxRows)
99 lines := []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"}105 lines := []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"}
100 if len(d.rows) == 0 {106 if len(d.rows) == 0 {
101- return append(lines, "", " No resident subagents.")107+ if d.total > 0 {
108+ return limitResidentSubagentsDialogHeight(lines, height)
109+ }
110+ if height == len(lines)+1 {
111+ lines = append(lines, " No resident subagents.")
112+ } else {
113+ lines = append(lines, "", " No resident subagents.")
114+ }
115+ return limitResidentSubagentsDialogHeight(lines, height)
102 }116 }
103 for index, row := range d.rows {117 for index, row := range d.rows {
104 marker := " "118 marker := " "
@@ -114,11 +128,27 @@ func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []s
114 label += " required"128 label += " required"
115 }129 }
116 lines = append(lines, marker+truncateResidentSubagentIndicator(label, width-2))130 lines = append(lines, marker+truncateResidentSubagentIndicator(label, width-2))
131+ if metadata := residentUsageMetadata(row); metadata != "" {
132+ metadata = truncateResidentSubagentIndicator(metadata, width-4)
133+ if metadata != "" {
134+ lines = append(lines, " "+metadata)
135+ }
136+ }
117 }137 }
118 if d.total > len(d.rows) {138 if d.total > len(d.rows) {
119 lines = append(lines, fmt.Sprintf(" %d-%d of %d", d.offset+1, d.offset+len(d.rows), d.total))139 lines = append(lines, fmt.Sprintf(" %d-%d of %d", d.offset+1, d.offset+len(d.rows), d.total))
120 }140 }
121- return lines141+ return limitResidentSubagentsDialogHeight(lines, height)
142+}
143+
144+func limitResidentSubagentsDialogHeight(lines []string, height int) []string {
145+ if height <= 0 {
146+ return nil
147+ }
148+ if len(lines) <= height {
149+ return lines
150+ }
151+ return lines[:height]
122 }152 }
123 153
124 func residentDisplayName(row subagents.ResidentSnapshot) string {154 func residentDisplayName(row subagents.ResidentSnapshot) string {
M packages/agent/modes/resident_subagents_dialog_test.go +70 -0
@@ -1,10 +1,17 @@
11 package modes
22
33 import (
4+ "context"
5+ "fmt"
6+ "slices"
7+ "strings"
48 "testing"
59 "time"
610
711 "github.com/bnema/zut/packages/agent/subagents"
12+ "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/provider"
14+ "github.com/bnema/zut/packages/tui"
815 )
916
1017 func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testing.T) {
@@ -23,6 +30,69 @@ func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testi
2330 }
2431 }
2532
33+func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {
34+ completed := make(chan struct{}, 2)
35+ manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
36+ journal.ConfigureUsage(272_000, true)
37+ return func(context.Context, string) error {
38+ usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525}
39+ return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage})
40+ }, nil
41+ })
42+ manager.SetCompletionObserver(func(subagents.ResidentCompletion) { completed <- struct{}{} })
43+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
44+ for index := range 2 {
45+ id := fmt.Sprintf("usage-child-%d", index)
46+ if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: id, SessionID: "session-" + id, Profile: "reviewer", Provider: "openai-codex", Model: "gpt-test"}, "task"); err != nil {
47+ t.Fatal(err)
48+ }
49+ }
50+ for range 2 {
51+ select {
52+ case <-completed:
53+ case <-time.After(time.Second):
54+ t.Fatal("resident child did not complete")
55+ }
56+ }
57+ dialog := newResidentSubagentsDialog()
58+ dialog.Open(manager)
59+ plain := strings.Join(plainResidentIndicatorLines(dialog.Render(tui.Dark, 100, 10)), "\n")
60+ if !strings.Contains(plain, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 76.1%/272k") {
61+ t.Fatalf("dialog = %q, want usage metadata line", plain)
62+ }
63+ for _, height := range []int{3, 4} {
64+ if lines := dialog.Render(tui.Dark, 100, height); len(lines) > height {
65+ t.Fatalf("height %d rendered %d lines: %q", height, len(lines), plainResidentIndicatorLines(lines))
66+ }
67+ }
68+ for width := 1; width <= 3; width++ {
69+ for _, line := range plainResidentIndicatorLines(dialog.Render(tui.Dark, width, 10)) {
70+ if line == " " {
71+ t.Fatalf("width %d rendered an empty metadata row", width)
72+ }
73+ }
74+ }
75+}
76+
77+func TestResidentSubagentsDialogBoundsEmptyStateByHeight(t *testing.T) {
78+ dialog := newResidentSubagentsDialog()
79+ dialog.Open(nil)
80+ tests := []struct {
81+ height int
82+ want []string
83+ }{
84+ {height: 0, want: nil},
85+ {height: 1, want: []string{" Resident subagents"}},
86+ {height: 2, want: []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"}},
87+ {height: 3, want: []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn", " No resident subagents."}},
88+ }
89+ for _, test := range tests {
90+ if got := plainResidentIndicatorLines(dialog.Render(tui.Dark, 100, test.height)); !slices.Equal(got, test.want) {
91+ t.Errorf("height %d rendered %q, want %q", test.height, got, test.want)
92+ }
93+ }
94+}
95+
2696 func TestResidentResultStatusIncludesFinalSummary(t *testing.T) {
2797 got := residentResultStatus("child", subagents.ResidentResult{State: subagents.ResidentIdle, Summary: "the child answer"})
2898 if got != "completed result child: the child answer" {
@@ -1,10 +1,17 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "context"
5+ "fmt"
6+ "slices"
7+ "strings"
4 "testing"8 "testing"
5 "time"9 "time"
6 10
7 "github.com/bnema/zut/packages/agent/subagents"11 "github.com/bnema/zut/packages/agent/subagents"
12+ "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/provider"
14+ "github.com/bnema/zut/packages/tui"
8 )15 )
9 16
10 func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testing.T) {17 func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testing.T) {
@@ -23,6 +30,69 @@ func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testi
23 }30 }
24 }31 }
25 32
33+func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {
34+ completed := make(chan struct{}, 2)
35+ manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
36+ journal.ConfigureUsage(272_000, true)
37+ return func(context.Context, string) error {
38+ usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525}
39+ return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage})
40+ }, nil
41+ })
42+ manager.SetCompletionObserver(func(subagents.ResidentCompletion) { completed <- struct{}{} })
43+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
44+ for index := range 2 {
45+ id := fmt.Sprintf("usage-child-%d", index)
46+ if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: id, SessionID: "session-" + id, Profile: "reviewer", Provider: "openai-codex", Model: "gpt-test"}, "task"); err != nil {
47+ t.Fatal(err)
48+ }
49+ }
50+ for range 2 {
51+ select {
52+ case <-completed:
53+ case <-time.After(time.Second):
54+ t.Fatal("resident child did not complete")
55+ }
56+ }
57+ dialog := newResidentSubagentsDialog()
58+ dialog.Open(manager)
59+ plain := strings.Join(plainResidentIndicatorLines(dialog.Render(tui.Dark, 100, 10)), "\n")
60+ if !strings.Contains(plain, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 76.1%/272k") {
61+ t.Fatalf("dialog = %q, want usage metadata line", plain)
62+ }
63+ for _, height := range []int{3, 4} {
64+ if lines := dialog.Render(tui.Dark, 100, height); len(lines) > height {
65+ t.Fatalf("height %d rendered %d lines: %q", height, len(lines), plainResidentIndicatorLines(lines))
66+ }
67+ }
68+ for width := 1; width <= 3; width++ {
69+ for _, line := range plainResidentIndicatorLines(dialog.Render(tui.Dark, width, 10)) {
70+ if line == " " {
71+ t.Fatalf("width %d rendered an empty metadata row", width)
72+ }
73+ }
74+ }
75+}
76+
77+func TestResidentSubagentsDialogBoundsEmptyStateByHeight(t *testing.T) {
78+ dialog := newResidentSubagentsDialog()
79+ dialog.Open(nil)
80+ tests := []struct {
81+ height int
82+ want []string
83+ }{
84+ {height: 0, want: nil},
85+ {height: 1, want: []string{" Resident subagents"}},
86+ {height: 2, want: []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"}},
87+ {height: 3, want: []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn", " No resident subagents."}},
88+ }
89+ for _, test := range tests {
90+ if got := plainResidentIndicatorLines(dialog.Render(tui.Dark, 100, test.height)); !slices.Equal(got, test.want) {
91+ t.Errorf("height %d rendered %q, want %q", test.height, got, test.want)
92+ }
93+ }
94+}
95+
26 func TestResidentResultStatusIncludesFinalSummary(t *testing.T) {96 func TestResidentResultStatusIncludesFinalSummary(t *testing.T) {
27 got := residentResultStatus("child", subagents.ResidentResult{State: subagents.ResidentIdle, Summary: "the child answer"})97 got := residentResultStatus("child", subagents.ResidentResult{State: subagents.ResidentIdle, Summary: "the child answer"})
28 if got != "completed result child: the child answer" {98 if got != "completed result child: the child answer" {
M packages/agent/subagent_factory.go +4 -0
@@ -43,6 +43,10 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
4343 agent.Reasoning = resolved.Reasoning
4444 agent.Temperature = resolved.Temperature
4545 agent.FastMode = resolved.FastMode
46+ if journal != nil {
47+ baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
48+ agent.SeedCost(baseline.Usage)
49+ }
4650 if err := agent.BindSessionID(spec.SessionID); err != nil {
4751 return nil, fmt.Errorf("bind resident child %q session: %w", spec.ID, err)
4852 }
@@ -43,6 +43,10 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
43 agent.Reasoning = resolved.Reasoning43 agent.Reasoning = resolved.Reasoning
44 agent.Temperature = resolved.Temperature44 agent.Temperature = resolved.Temperature
45 agent.FastMode = resolved.FastMode45 agent.FastMode = resolved.FastMode
46+ if journal != nil {
47+ baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
48+ agent.SeedCost(baseline.Usage)
49+ }
46 if err := agent.BindSessionID(spec.SessionID); err != nil {50 if err := agent.BindSessionID(spec.SessionID); err != nil {
47 return nil, fmt.Errorf("bind resident child %q session: %w", spec.ID, err)51 return nil, fmt.Errorf("bind resident child %q session: %w", spec.ID, err)
48 }52 }
M packages/agent/subagents/resident_child.go +1 -0
@@ -101,6 +101,7 @@ func newResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJour
101101 live: newResidentLiveProjection(),
102102 }
103103 if journal != nil {
104+ child.live.SeedUsage(journal.usageSnapshot())
104105 journal.SetEventObserver(func(event core.AgentEvent) {
105106 child.live.Apply(event)
106107 child.recordActivity()
@@ -101,6 +101,7 @@ func newResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJour
101 live: newResidentLiveProjection(),101 live: newResidentLiveProjection(),
102 }102 }
103 if journal != nil {103 if journal != nil {
104+ child.live.SeedUsage(journal.usageSnapshot())
104 journal.SetEventObserver(func(event core.AgentEvent) {105 journal.SetEventObserver(func(event core.AgentEvent) {
105 child.live.Apply(event)106 child.live.Apply(event)
106 child.recordActivity()107 child.recordActivity()
M packages/agent/subagents/resident_journal.go +127 -24
@@ -33,6 +33,7 @@ const (
3333 residentRecordAssistant = "message.assistant"
3434 residentRecordToolCall = "tool.call"
3535 residentRecordToolResult = "tool.result"
36+ residentRecordUsage = "usage"
3637 residentJournalVersion = 1
3738 residentInterruptedText = "tool interrupted by resident host restart"
3839 residentMaxRecordBytes = 2 << 20
@@ -90,19 +91,23 @@ type ResidentChildSpec struct {
9091 }
9192
9293 type residentRecord struct {
93- Version int `json:"version"`
94- Type string `json:"type"`
95- Time time.Time `json:"time"`
96- Spec *ResidentChildSpec `json:"spec,omitempty"`
97- TurnID string `json:"turn_id,omitempty"`
98- Prompt string `json:"prompt,omitempty"`
99- Outcome string `json:"outcome,omitempty"`
100- Message json.RawMessage `json:"message,omitempty"`
101- ToolID string `json:"tool_id,omitempty"`
102- ToolName string `json:"tool_name,omitempty"`
103- ToolArgs json.RawMessage `json:"tool_args,omitempty"`
104- ToolResult json.RawMessage `json:"tool_result,omitempty"`
105- raw json.RawMessage `json:"-"`
94+ Version int `json:"version"`
95+ Type string `json:"type"`
96+ Time time.Time `json:"time"`
97+ Spec *ResidentChildSpec `json:"spec,omitempty"`
98+ TurnID string `json:"turn_id,omitempty"`
99+ Prompt string `json:"prompt,omitempty"`
100+ Outcome string `json:"outcome,omitempty"`
101+ Message json.RawMessage `json:"message,omitempty"`
102+ ToolID string `json:"tool_id,omitempty"`
103+ ToolName string `json:"tool_name,omitempty"`
104+ ToolArgs json.RawMessage `json:"tool_args,omitempty"`
105+ ToolResult json.RawMessage `json:"tool_result,omitempty"`
106+ Usage *provider.Usage `json:"usage,omitempty"`
107+ ContextUsed int `json:"context_used,omitempty"`
108+ ContextMax int `json:"context_max,omitempty"`
109+ Subscription bool `json:"subscription,omitempty"`
110+ raw json.RawMessage `json:"-"`
106111 }
107112
108113 // RecordAgentEvent persists finalized provider-neutral transcript events. It
@@ -135,6 +140,15 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error {
135140 return fmt.Errorf("resident journal encode tool result: %w", err)
136141 }
137142 record = residentRecord{Type: residentRecordToolResult, ToolID: value.ID, ToolResult: result}
143+ case core.EvUsage:
144+ j.usageMu.RLock()
145+ contextUsed, contextMax, subscription := j.contextUsed, j.contextMax, j.subscription
146+ j.usageMu.RUnlock()
147+ if current := value.Usage.PromptTokens(); current > 0 {
148+ contextUsed = current
149+ }
150+ usage := value.Cumulative
151+ record = residentRecord{Type: residentRecordUsage, Usage: &usage, ContextUsed: contextUsed, ContextMax: contextMax, Subscription: subscription}
138152 default:
139153 j.publishAgentEvent(event)
140154 return nil
@@ -147,6 +161,15 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error {
147161 return errors.New("resident journal: closed")
148162 }
149163 err := j.appendSync(record)
164+ if usageEvent, ok := event.(core.EvUsage); err == nil && ok {
165+ j.usageMu.Lock()
166+ j.usage = usageEvent.Cumulative
167+ if current := usageEvent.Usage.PromptTokens(); current > 0 {
168+ j.contextUsed = current
169+ }
170+ j.usageMu.Unlock()
171+ err = j.projectUsageMetadata()
172+ }
150173 j.mu.Unlock()
151174 if err == nil {
152175 switch message := event.(type) {
@@ -167,12 +190,16 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error {
167190 // the authority for acceptance; callers must never infer acceptance solely
168191 // from this file.
169192 type ResidentMetadata struct {
170- Version int `json:"version"`
171- ID string `json:"id"`
172- SessionID string `json:"session_id"`
173- ParentSessionID string `json:"parent_session_id,omitempty"`
174- State ResidentState `json:"state"`
175- UpdatedAt time.Time `json:"updated_at"`
193+ Version int `json:"version"`
194+ ID string `json:"id"`
195+ SessionID string `json:"session_id"`
196+ ParentSessionID string `json:"parent_session_id,omitempty"`
197+ State ResidentState `json:"state"`
198+ UpdatedAt time.Time `json:"updated_at"`
199+ Usage provider.Usage `json:"usage,omitempty"`
200+ ContextUsed int `json:"context_used,omitempty"`
201+ ContextMax int `json:"context_max,omitempty"`
202+ Subscription bool `json:"subscription,omitempty"`
176203 }
177204
178205 // ResidentResult is the bounded latest-turn projection. The transcript
@@ -198,11 +225,52 @@ type ResidentJournal struct {
198225 eventObserver func(core.AgentEvent)
199226 summaryMu sync.RWMutex
200227 latestSummary string
228+ usageMu sync.RWMutex
229+ usage provider.Usage
230+ contextUsed int
231+ contextMax int
232+ subscription bool
201233 dir string
202234 file *os.File
203235 lease *residentLease
204236 }
205237
238+// ResidentUsageSnapshot is the bounded usage projection shared by durable and
239+// live resident state.
240+type ResidentUsageSnapshot struct {
241+ Usage provider.Usage
242+ ContextUsed int
243+ ContextMax int
244+ Subscription bool
245+}
246+
247+// ConfigureUsage records resolved model metadata and restores the latest
248+// durable usage baseline when a resident child is reconstructed.
249+func (j *ResidentJournal) ConfigureUsage(contextMax int, subscription bool) ResidentUsageSnapshot {
250+ if j == nil {
251+ return ResidentUsageSnapshot{}
252+ }
253+ j.usageMu.Lock()
254+ if metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName)); err == nil {
255+ j.usage = metadata.Usage
256+ j.contextUsed = metadata.ContextUsed
257+ }
258+ j.contextMax = contextMax
259+ j.subscription = subscription
260+ snapshot := ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription}
261+ j.usageMu.Unlock()
262+ return snapshot
263+}
264+
265+func (j *ResidentJournal) usageSnapshot() ResidentUsageSnapshot {
266+ if j == nil {
267+ return ResidentUsageSnapshot{}
268+ }
269+ j.usageMu.RLock()
270+ defer j.usageMu.RUnlock()
271+ return ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription}
272+}
273+
206274 // SetEventObserver publishes in-memory agent events without creating another
207275 // durable transcript. Finalized events are observed only after journal append.
208276 func (j *ResidentJournal) SetEventObserver(observer func(core.AgentEvent)) {
@@ -272,7 +340,7 @@ func (j *ResidentJournal) Accept(spec ResidentChildSpec, prompt string) error {
272340 if err := j.appendSync(record); err != nil {
273341 return err
274342 }
275- return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: ResidentQueued, UpdatedAt: record.Time})
343+ return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, record.Time))
276344 }
277345
278346 // RecordFailure makes a post-acceptance construction failure durable.
@@ -286,7 +354,7 @@ func (j *ResidentJournal) RecordFailure(spec ResidentChildSpec) error {
286354 if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordFailed, Time: now}); err != nil {
287355 return err
288356 }
289- return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: ResidentFailed, UpdatedAt: now})
357+ return writeResidentMetadata(j.dir, j.metadata(spec, ResidentFailed, now))
290358 }
291359
292360 // AcceptFollowUp establishes a durable follow-up before a live child is told
@@ -305,7 +373,7 @@ func (j *ResidentJournal) AcceptFollowUp(spec ResidentChildSpec, turnID, prompt
305373 if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnAccepted, Time: now, TurnID: turnID, Prompt: prompt}); err != nil {
306374 return err
307375 }
308- return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: ResidentQueued, UpdatedAt: now})
376+ return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, now))
309377 }
310378
311379 func (j *ResidentJournal) RecordTurnStarted(spec ResidentChildSpec, turnID string) error {
@@ -416,7 +484,32 @@ func (j *ResidentJournal) recordTurnBoundary(spec ResidentChildSpec, recordType,
416484 if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: recordType, Time: now, TurnID: turnID, Outcome: outcome}); err != nil {
417485 return err
418486 }
419- return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: now})
487+ return writeResidentMetadata(j.dir, j.metadata(spec, state, now))
488+}
489+
490+func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState, updatedAt time.Time) ResidentMetadata {
491+ usage := j.usageSnapshot()
492+ return ResidentMetadata{
493+ Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
494+ ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt,
495+ Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
496+ }
497+}
498+
499+// projectUsageMetadata updates the rebuildable projection while j.mu holds the
500+// lifecycle ordering lock. The transcript remains authoritative if this write
501+// fails and reconciliation can reconstruct the same values.
502+func (j *ResidentJournal) projectUsageMetadata() error {
503+ metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName))
504+ if err != nil {
505+ return err
506+ }
507+ usage := j.usageSnapshot()
508+ metadata.Usage = usage.Usage
509+ metadata.ContextUsed = usage.ContextUsed
510+ metadata.ContextMax = usage.ContextMax
511+ metadata.Subscription = usage.Subscription
512+ return writeResidentMetadata(j.dir, metadata)
420513 }
421514
422515 func (j *ResidentJournal) appendSync(record residentRecord) error {
@@ -594,6 +687,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
594687 state := ResidentQueued
595688 lastStateAt := records[0].Time
596689 lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary := "", "", ""
690+ usage := ResidentUsageSnapshot{}
597691 seenTurns := make(map[string]string)
598692 // The initial prompt is accepted atomically with child.accepted rather than
599693 // through a separate turn.accepted record. Seed it so interruption and
@@ -673,12 +767,21 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
673767 return ResidentMetadata{}, errors.New("resident journal: duplicate tool result")
674768 }
675769 toolResults[record.ToolID] = struct{}{}
770+ case residentRecordUsage:
771+ if record.Usage == nil {
772+ return ResidentMetadata{}, errors.New("resident journal: invalid usage record")
773+ }
774+ usage = ResidentUsageSnapshot{Usage: *record.Usage, ContextUsed: record.ContextUsed, ContextMax: record.ContextMax, Subscription: record.Subscription}
676775 }
677776 }
678777 if lastStateAt.IsZero() {
679778 lastStateAt = time.Now().UTC()
680779 }
681- metadata := ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt}
780+ metadata := ResidentMetadata{
781+ Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
782+ ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt,
783+ Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
784+ }
682785 needsInterruption := state == ResidentQueued || state == ResidentRunning
683786 needsToolRepair := len(toolCalls) != len(toolResults)
684787 if needsInterruption || needsToolRepair {
@@ -33,6 +33,7 @@ const (
33 residentRecordAssistant = "message.assistant"33 residentRecordAssistant = "message.assistant"
34 residentRecordToolCall = "tool.call"34 residentRecordToolCall = "tool.call"
35 residentRecordToolResult = "tool.result"35 residentRecordToolResult = "tool.result"
36+ residentRecordUsage = "usage"
36 residentJournalVersion = 137 residentJournalVersion = 1
37 residentInterruptedText = "tool interrupted by resident host restart"38 residentInterruptedText = "tool interrupted by resident host restart"
38 residentMaxRecordBytes = 2 << 2039 residentMaxRecordBytes = 2 << 20
@@ -90,19 +91,23 @@ type ResidentChildSpec struct {
90 }91 }
91 92
92 type residentRecord struct {93 type residentRecord struct {
93- Version int `json:"version"`94+ Version int `json:"version"`
94- Type string `json:"type"`95+ Type string `json:"type"`
95- Time time.Time `json:"time"`96+ Time time.Time `json:"time"`
96- Spec *ResidentChildSpec `json:"spec,omitempty"`97+ Spec *ResidentChildSpec `json:"spec,omitempty"`
97- TurnID string `json:"turn_id,omitempty"`98+ TurnID string `json:"turn_id,omitempty"`
98- Prompt string `json:"prompt,omitempty"`99+ Prompt string `json:"prompt,omitempty"`
99- Outcome string `json:"outcome,omitempty"`100+ Outcome string `json:"outcome,omitempty"`
100- Message json.RawMessage `json:"message,omitempty"`101+ Message json.RawMessage `json:"message,omitempty"`
101- ToolID string `json:"tool_id,omitempty"`102+ ToolID string `json:"tool_id,omitempty"`
102- ToolName string `json:"tool_name,omitempty"`103+ ToolName string `json:"tool_name,omitempty"`
103- ToolArgs json.RawMessage `json:"tool_args,omitempty"`104+ ToolArgs json.RawMessage `json:"tool_args,omitempty"`
104- ToolResult json.RawMessage `json:"tool_result,omitempty"`105+ ToolResult json.RawMessage `json:"tool_result,omitempty"`
105- raw json.RawMessage `json:"-"`106+ Usage *provider.Usage `json:"usage,omitempty"`
107+ ContextUsed int `json:"context_used,omitempty"`
108+ ContextMax int `json:"context_max,omitempty"`
109+ Subscription bool `json:"subscription,omitempty"`
110+ raw json.RawMessage `json:"-"`
106 }111 }
107 112
108 // RecordAgentEvent persists finalized provider-neutral transcript events. It113 // RecordAgentEvent persists finalized provider-neutral transcript events. It
@@ -135,6 +140,15 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error {
135 return fmt.Errorf("resident journal encode tool result: %w", err)140 return fmt.Errorf("resident journal encode tool result: %w", err)
136 }141 }
137 record = residentRecord{Type: residentRecordToolResult, ToolID: value.ID, ToolResult: result}142 record = residentRecord{Type: residentRecordToolResult, ToolID: value.ID, ToolResult: result}
143+ case core.EvUsage:
144+ j.usageMu.RLock()
145+ contextUsed, contextMax, subscription := j.contextUsed, j.contextMax, j.subscription
146+ j.usageMu.RUnlock()
147+ if current := value.Usage.PromptTokens(); current > 0 {
148+ contextUsed = current
149+ }
150+ usage := value.Cumulative
151+ record = residentRecord{Type: residentRecordUsage, Usage: &usage, ContextUsed: contextUsed, ContextMax: contextMax, Subscription: subscription}
138 default:152 default:
139 j.publishAgentEvent(event)153 j.publishAgentEvent(event)
140 return nil154 return nil
@@ -147,6 +161,15 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error {
147 return errors.New("resident journal: closed")161 return errors.New("resident journal: closed")
148 }162 }
149 err := j.appendSync(record)163 err := j.appendSync(record)
164+ if usageEvent, ok := event.(core.EvUsage); err == nil && ok {
165+ j.usageMu.Lock()
166+ j.usage = usageEvent.Cumulative
167+ if current := usageEvent.Usage.PromptTokens(); current > 0 {
168+ j.contextUsed = current
169+ }
170+ j.usageMu.Unlock()
171+ err = j.projectUsageMetadata()
172+ }
150 j.mu.Unlock()173 j.mu.Unlock()
151 if err == nil {174 if err == nil {
152 switch message := event.(type) {175 switch message := event.(type) {
@@ -167,12 +190,16 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error {
167 // the authority for acceptance; callers must never infer acceptance solely190 // the authority for acceptance; callers must never infer acceptance solely
168 // from this file.191 // from this file.
169 type ResidentMetadata struct {192 type ResidentMetadata struct {
170- Version int `json:"version"`193+ Version int `json:"version"`
171- ID string `json:"id"`194+ ID string `json:"id"`
172- SessionID string `json:"session_id"`195+ SessionID string `json:"session_id"`
173- ParentSessionID string `json:"parent_session_id,omitempty"`196+ ParentSessionID string `json:"parent_session_id,omitempty"`
174- State ResidentState `json:"state"`197+ State ResidentState `json:"state"`
175- UpdatedAt time.Time `json:"updated_at"`198+ UpdatedAt time.Time `json:"updated_at"`
199+ Usage provider.Usage `json:"usage,omitempty"`
200+ ContextUsed int `json:"context_used,omitempty"`
201+ ContextMax int `json:"context_max,omitempty"`
202+ Subscription bool `json:"subscription,omitempty"`
176 }203 }
177 204
178 // ResidentResult is the bounded latest-turn projection. The transcript205 // ResidentResult is the bounded latest-turn projection. The transcript
@@ -198,11 +225,52 @@ type ResidentJournal struct {
198 eventObserver func(core.AgentEvent)225 eventObserver func(core.AgentEvent)
199 summaryMu sync.RWMutex226 summaryMu sync.RWMutex
200 latestSummary string227 latestSummary string
228+ usageMu sync.RWMutex
229+ usage provider.Usage
230+ contextUsed int
231+ contextMax int
232+ subscription bool
201 dir string233 dir string
202 file *os.File234 file *os.File
203 lease *residentLease235 lease *residentLease
204 }236 }
205 237
238+// ResidentUsageSnapshot is the bounded usage projection shared by durable and
239+// live resident state.
240+type ResidentUsageSnapshot struct {
241+ Usage provider.Usage
242+ ContextUsed int
243+ ContextMax int
244+ Subscription bool
245+}
246+
247+// ConfigureUsage records resolved model metadata and restores the latest
248+// durable usage baseline when a resident child is reconstructed.
249+func (j *ResidentJournal) ConfigureUsage(contextMax int, subscription bool) ResidentUsageSnapshot {
250+ if j == nil {
251+ return ResidentUsageSnapshot{}
252+ }
253+ j.usageMu.Lock()
254+ if metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName)); err == nil {
255+ j.usage = metadata.Usage
256+ j.contextUsed = metadata.ContextUsed
257+ }
258+ j.contextMax = contextMax
259+ j.subscription = subscription
260+ snapshot := ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription}
261+ j.usageMu.Unlock()
262+ return snapshot
263+}
264+
265+func (j *ResidentJournal) usageSnapshot() ResidentUsageSnapshot {
266+ if j == nil {
267+ return ResidentUsageSnapshot{}
268+ }
269+ j.usageMu.RLock()
270+ defer j.usageMu.RUnlock()
271+ return ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription}
272+}
273+
206 // SetEventObserver publishes in-memory agent events without creating another274 // SetEventObserver publishes in-memory agent events without creating another
207 // durable transcript. Finalized events are observed only after journal append.275 // durable transcript. Finalized events are observed only after journal append.
208 func (j *ResidentJournal) SetEventObserver(observer func(core.AgentEvent)) {276 func (j *ResidentJournal) SetEventObserver(observer func(core.AgentEvent)) {
@@ -272,7 +340,7 @@ func (j *ResidentJournal) Accept(spec ResidentChildSpec, prompt string) error {
272 if err := j.appendSync(record); err != nil {340 if err := j.appendSync(record); err != nil {
273 return err341 return err
274 }342 }
275- return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: ResidentQueued, UpdatedAt: record.Time})343+ return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, record.Time))
276 }344 }
277 345
278 // RecordFailure makes a post-acceptance construction failure durable.346 // RecordFailure makes a post-acceptance construction failure durable.
@@ -286,7 +354,7 @@ func (j *ResidentJournal) RecordFailure(spec ResidentChildSpec) error {
286 if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordFailed, Time: now}); err != nil {354 if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordFailed, Time: now}); err != nil {
287 return err355 return err
288 }356 }
289- return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: ResidentFailed, UpdatedAt: now})357+ return writeResidentMetadata(j.dir, j.metadata(spec, ResidentFailed, now))
290 }358 }
291 359
292 // AcceptFollowUp establishes a durable follow-up before a live child is told360 // AcceptFollowUp establishes a durable follow-up before a live child is told
@@ -305,7 +373,7 @@ func (j *ResidentJournal) AcceptFollowUp(spec ResidentChildSpec, turnID, prompt
305 if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnAccepted, Time: now, TurnID: turnID, Prompt: prompt}); err != nil {373 if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnAccepted, Time: now, TurnID: turnID, Prompt: prompt}); err != nil {
306 return err374 return err
307 }375 }
308- return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: ResidentQueued, UpdatedAt: now})376+ return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, now))
309 }377 }
310 378
311 func (j *ResidentJournal) RecordTurnStarted(spec ResidentChildSpec, turnID string) error {379 func (j *ResidentJournal) RecordTurnStarted(spec ResidentChildSpec, turnID string) error {
@@ -416,7 +484,32 @@ func (j *ResidentJournal) recordTurnBoundary(spec ResidentChildSpec, recordType,
416 if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: recordType, Time: now, TurnID: turnID, Outcome: outcome}); err != nil {484 if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: recordType, Time: now, TurnID: turnID, Outcome: outcome}); err != nil {
417 return err485 return err
418 }486 }
419- return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: now})487+ return writeResidentMetadata(j.dir, j.metadata(spec, state, now))
488+}
489+
490+func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState, updatedAt time.Time) ResidentMetadata {
491+ usage := j.usageSnapshot()
492+ return ResidentMetadata{
493+ Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
494+ ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt,
495+ Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
496+ }
497+}
498+
499+// projectUsageMetadata updates the rebuildable projection while j.mu holds the
500+// lifecycle ordering lock. The transcript remains authoritative if this write
501+// fails and reconciliation can reconstruct the same values.
502+func (j *ResidentJournal) projectUsageMetadata() error {
503+ metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName))
504+ if err != nil {
505+ return err
506+ }
507+ usage := j.usageSnapshot()
508+ metadata.Usage = usage.Usage
509+ metadata.ContextUsed = usage.ContextUsed
510+ metadata.ContextMax = usage.ContextMax
511+ metadata.Subscription = usage.Subscription
512+ return writeResidentMetadata(j.dir, metadata)
420 }513 }
421 514
422 func (j *ResidentJournal) appendSync(record residentRecord) error {515 func (j *ResidentJournal) appendSync(record residentRecord) error {
@@ -594,6 +687,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
594 state := ResidentQueued687 state := ResidentQueued
595 lastStateAt := records[0].Time688 lastStateAt := records[0].Time
596 lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary := "", "", ""689 lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary := "", "", ""
690+ usage := ResidentUsageSnapshot{}
597 seenTurns := make(map[string]string)691 seenTurns := make(map[string]string)
598 // The initial prompt is accepted atomically with child.accepted rather than692 // The initial prompt is accepted atomically with child.accepted rather than
599 // through a separate turn.accepted record. Seed it so interruption and693 // through a separate turn.accepted record. Seed it so interruption and
@@ -673,12 +767,21 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
673 return ResidentMetadata{}, errors.New("resident journal: duplicate tool result")767 return ResidentMetadata{}, errors.New("resident journal: duplicate tool result")
674 }768 }
675 toolResults[record.ToolID] = struct{}{}769 toolResults[record.ToolID] = struct{}{}
770+ case residentRecordUsage:
771+ if record.Usage == nil {
772+ return ResidentMetadata{}, errors.New("resident journal: invalid usage record")
773+ }
774+ usage = ResidentUsageSnapshot{Usage: *record.Usage, ContextUsed: record.ContextUsed, ContextMax: record.ContextMax, Subscription: record.Subscription}
676 }775 }
677 }776 }
678 if lastStateAt.IsZero() {777 if lastStateAt.IsZero() {
679 lastStateAt = time.Now().UTC()778 lastStateAt = time.Now().UTC()
680 }779 }
681- metadata := ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt}780+ metadata := ResidentMetadata{
781+ Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
782+ ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt,
783+ Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
784+ }
682 needsInterruption := state == ResidentQueued || state == ResidentRunning785 needsInterruption := state == ResidentQueued || state == ResidentRunning
683 needsToolRepair := len(toolCalls) != len(toolResults)786 needsToolRepair := len(toolCalls) != len(toolResults)
684 if needsInterruption || needsToolRepair {787 if needsInterruption || needsToolRepair {
M packages/agent/subagents/resident_journal_test.go +61 -0
@@ -62,6 +62,67 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin
6262 }
6363 }
6464
65+func TestResidentJournalReconcilesUsageMetadata(t *testing.T) {
66+ root := t.TempDir()
67+ journal, err := OpenResidentJournal(root, "usage-child")
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ spec := ResidentChildSpec{ID: "usage-child", SessionID: "usage-session", Provider: "openai-codex", Model: "gpt-test"}
72+ if err := journal.Accept(spec, "task"); err != nil {
73+ t.Fatal(err)
74+ }
75+ journal.ConfigureUsage(272_000, true)
76+ turn := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525}
77+ if err := journal.RecordAgentEvent(core.EvUsage{Usage: turn, Cumulative: turn}); err != nil {
78+ t.Fatal(err)
79+ }
80+ if err := journal.RecordAgentEvent(core.EvUsage{Usage: provider.Usage{}, Cumulative: turn}); err != nil {
81+ t.Fatal(err)
82+ }
83+ if err := journal.Close(); err != nil {
84+ t.Fatal(err)
85+ }
86+ metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ if metadata.Usage != turn || metadata.ContextUsed != 207_000 || metadata.ContextMax != 272_000 || !metadata.Subscription {
91+ t.Fatalf("metadata = %#v, want durable usage projection", metadata)
92+ }
93+}
94+
95+func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) {
96+ root := t.TempDir()
97+ journal, err := OpenResidentJournal(root, "interrupted-usage")
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ t.Cleanup(func() { _ = journal.Close() })
102+ spec := ResidentChildSpec{ID: "interrupted-usage", SessionID: "usage-session", Provider: "openai", Model: "gpt-test"}
103+ if err := journal.Accept(spec, "task"); err != nil {
104+ t.Fatal(err)
105+ }
106+ journal.ConfigureUsage(272_000, false)
107+ if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
108+ t.Fatal(err)
109+ }
110+ if err := journal.RecordTurnInterrupted(spec, "turn-1"); err != nil {
111+ t.Fatal(err)
112+ }
113+ usage := provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000}
114+ if err := journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}); err != nil {
115+ t.Fatal(err)
116+ }
117+ metadata, err := ReadResidentMetadata(filepath.Join(journal.Dir(), residentMetadataName))
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ if metadata.State != ResidentInterrupted || metadata.Usage != usage || metadata.ContextUsed != 207_000 {
122+ t.Fatalf("metadata = %#v, want interrupted state with latest usage", metadata)
123+ }
124+}
125+
65126 func TestOpenResidentJournalRejectsDotChildIDs(t *testing.T) {
66127 for _, childID := range []string{".", "..", "nested/child"} {
67128 t.Run(childID, func(t *testing.T) {
@@ -62,6 +62,67 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin
62 }62 }
63 }63 }
64 64
65+func TestResidentJournalReconcilesUsageMetadata(t *testing.T) {
66+ root := t.TempDir()
67+ journal, err := OpenResidentJournal(root, "usage-child")
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ spec := ResidentChildSpec{ID: "usage-child", SessionID: "usage-session", Provider: "openai-codex", Model: "gpt-test"}
72+ if err := journal.Accept(spec, "task"); err != nil {
73+ t.Fatal(err)
74+ }
75+ journal.ConfigureUsage(272_000, true)
76+ turn := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525}
77+ if err := journal.RecordAgentEvent(core.EvUsage{Usage: turn, Cumulative: turn}); err != nil {
78+ t.Fatal(err)
79+ }
80+ if err := journal.RecordAgentEvent(core.EvUsage{Usage: provider.Usage{}, Cumulative: turn}); err != nil {
81+ t.Fatal(err)
82+ }
83+ if err := journal.Close(); err != nil {
84+ t.Fatal(err)
85+ }
86+ metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ if metadata.Usage != turn || metadata.ContextUsed != 207_000 || metadata.ContextMax != 272_000 || !metadata.Subscription {
91+ t.Fatalf("metadata = %#v, want durable usage projection", metadata)
92+ }
93+}
94+
95+func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) {
96+ root := t.TempDir()
97+ journal, err := OpenResidentJournal(root, "interrupted-usage")
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ t.Cleanup(func() { _ = journal.Close() })
102+ spec := ResidentChildSpec{ID: "interrupted-usage", SessionID: "usage-session", Provider: "openai", Model: "gpt-test"}
103+ if err := journal.Accept(spec, "task"); err != nil {
104+ t.Fatal(err)
105+ }
106+ journal.ConfigureUsage(272_000, false)
107+ if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil {
108+ t.Fatal(err)
109+ }
110+ if err := journal.RecordTurnInterrupted(spec, "turn-1"); err != nil {
111+ t.Fatal(err)
112+ }
113+ usage := provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000}
114+ if err := journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}); err != nil {
115+ t.Fatal(err)
116+ }
117+ metadata, err := ReadResidentMetadata(filepath.Join(journal.Dir(), residentMetadataName))
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ if metadata.State != ResidentInterrupted || metadata.Usage != usage || metadata.ContextUsed != 207_000 {
122+ t.Fatalf("metadata = %#v, want interrupted state with latest usage", metadata)
123+ }
124+}
125+
65 func TestOpenResidentJournalRejectsDotChildIDs(t *testing.T) {126 func TestOpenResidentJournalRejectsDotChildIDs(t *testing.T) {
66 for _, childID := range []string{".", "..", "nested/child"} {127 for _, childID := range []string{".", "..", "nested/child"} {
67 t.Run(childID, func(t *testing.T) {128 t.Run(childID, func(t *testing.T) {
M packages/agent/subagents/resident_manager.go +16 -3
@@ -11,6 +11,7 @@ import (
1111 "sync"
1212 "time"
1313
14+ "github.com/bnema/zut/packages/provider"
1415 "github.com/google/uuid"
1516 )
1617
@@ -157,6 +158,10 @@ type ResidentSnapshot struct {
157158 ActivityUpdatedAt time.Time
158159 WaitingForModel bool
159160 OwnedElsewhere bool
161+ Usage provider.Usage
162+ ContextUsed int
163+ ContextMax int
164+ Subscription bool
160165 }
161166
162167 func residentSnapshot(child *ResidentChild) ResidentSnapshot {
@@ -168,7 +173,8 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot {
168173 Provider: child.spec.Provider, Model: child.spec.Model,
169174 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,
170175 UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(),
171- ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel}
176+ ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel,
177+ Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription}
172178 }
173179
174180 func NewResidentManager(root string, factory ResidentFactory) *ResidentManager {
@@ -785,7 +791,10 @@ func (m *ResidentManager) Reconcile() []error {
785791 state, updatedAt = ResidentQueued, time.Now().UTC()
786792 }
787793 m.mu.Lock()
788- m.recovered[childID] = ResidentSnapshot{ID: childID, State: state, UpdatedAt: updatedAt, OwnedElsewhere: true}
794+ m.recovered[childID] = ResidentSnapshot{
795+ ID: childID, State: state, UpdatedAt: updatedAt, OwnedElsewhere: true,
796+ Usage: foreign.Usage, ContextUsed: foreign.ContextUsed, ContextMax: foreign.ContextMax, Subscription: foreign.Subscription,
797+ }
789798 m.mu.Unlock()
790799 continue
791800 }
@@ -795,7 +804,11 @@ func (m *ResidentManager) Reconcile() []error {
795804 }
796805 m.mu.Lock()
797806 if _, live := m.children[childID]; !live {
798- m.recovered[childID] = ResidentSnapshot{ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model, WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt}
807+ m.recovered[childID] = ResidentSnapshot{
808+ ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model,
809+ WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt,
810+ Usage: metadata.Usage, ContextUsed: metadata.ContextUsed, ContextMax: metadata.ContextMax, Subscription: metadata.Subscription,
811+ }
799812 m.recoveredSpec[childID] = spec
800813 }
801814 m.mu.Unlock()
@@ -11,6 +11,7 @@ import (
11 "sync"11 "sync"
12 "time"12 "time"
13 13
14+ "github.com/bnema/zut/packages/provider"
14 "github.com/google/uuid"15 "github.com/google/uuid"
15 )16 )
16 17
@@ -157,6 +158,10 @@ type ResidentSnapshot struct {
157 ActivityUpdatedAt time.Time158 ActivityUpdatedAt time.Time
158 WaitingForModel bool159 WaitingForModel bool
159 OwnedElsewhere bool160 OwnedElsewhere bool
161+ Usage provider.Usage
162+ ContextUsed int
163+ ContextMax int
164+ Subscription bool
160 }165 }
161 166
162 func residentSnapshot(child *ResidentChild) ResidentSnapshot {167 func residentSnapshot(child *ResidentChild) ResidentSnapshot {
@@ -168,7 +173,8 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot {
168 Provider: child.spec.Provider, Model: child.spec.Model,173 Provider: child.spec.Provider, Model: child.spec.Model,
169 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,174 WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required,
170 UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(),175 UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(),
171- ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel}176+ ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel,
177+ Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription}
172 }178 }
173 179
174 func NewResidentManager(root string, factory ResidentFactory) *ResidentManager {180 func NewResidentManager(root string, factory ResidentFactory) *ResidentManager {
@@ -785,7 +791,10 @@ func (m *ResidentManager) Reconcile() []error {
785 state, updatedAt = ResidentQueued, time.Now().UTC()791 state, updatedAt = ResidentQueued, time.Now().UTC()
786 }792 }
787 m.mu.Lock()793 m.mu.Lock()
788- m.recovered[childID] = ResidentSnapshot{ID: childID, State: state, UpdatedAt: updatedAt, OwnedElsewhere: true}794+ m.recovered[childID] = ResidentSnapshot{
795+ ID: childID, State: state, UpdatedAt: updatedAt, OwnedElsewhere: true,
796+ Usage: foreign.Usage, ContextUsed: foreign.ContextUsed, ContextMax: foreign.ContextMax, Subscription: foreign.Subscription,
797+ }
789 m.mu.Unlock()798 m.mu.Unlock()
790 continue799 continue
791 }800 }
@@ -795,7 +804,11 @@ func (m *ResidentManager) Reconcile() []error {
795 }804 }
796 m.mu.Lock()805 m.mu.Lock()
797 if _, live := m.children[childID]; !live {806 if _, live := m.children[childID]; !live {
798- m.recovered[childID] = ResidentSnapshot{ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model, WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt}807+ m.recovered[childID] = ResidentSnapshot{
808+ ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model,
809+ WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt,
810+ Usage: metadata.Usage, ContextUsed: metadata.ContextUsed, ContextMax: metadata.ContextMax, Subscription: metadata.Subscription,
811+ }
799 m.recoveredSpec[childID] = spec812 m.recoveredSpec[childID] = spec
800 }813 }
801 m.mu.Unlock()814 m.mu.Unlock()
M packages/agent/subagents/resident_projection.go +23 -0
@@ -4,6 +4,7 @@ import (
44 "sync"
55
66 "github.com/bnema/zut/packages/core"
7+ "github.com/bnema/zut/packages/provider"
78 )
89
910 const residentLiveArgumentBytes = 64 << 10
@@ -35,6 +36,10 @@ type ResidentLiveSnapshot struct {
3536 WaitingForModel bool
3637 AssistantText string
3738 Tools []ResidentLiveTool
39+ Usage provider.Usage
40+ ContextUsed int
41+ ContextMax int
42+ Subscription bool
3843 }
3944
4045 type residentLiveProjection struct {
@@ -44,6 +49,19 @@ type residentLiveProjection struct {
4449
4550 func newResidentLiveProjection() *residentLiveProjection { return &residentLiveProjection{} }
4651
52+func (p *residentLiveProjection) SeedUsage(usage ResidentUsageSnapshot) {
53+ if p == nil {
54+ return
55+ }
56+ p.mu.Lock()
57+ p.snapshot.Usage = usage.Usage
58+ p.snapshot.ContextUsed = usage.ContextUsed
59+ p.snapshot.ContextMax = usage.ContextMax
60+ p.snapshot.Subscription = usage.Subscription
61+ p.snapshot.Revision++
62+ p.mu.Unlock()
63+}
64+
4765 func (p *residentLiveProjection) Start(turnID string) {
4866 if p == nil {
4967 return
@@ -125,6 +143,11 @@ func (p *residentLiveProjection) Apply(event core.AgentEvent) {
125143 case core.EvAssistantMessage:
126144 p.snapshot.WaitingForModel = false
127145 p.snapshot.AssistantText = ""
146+ case core.EvUsage:
147+ p.snapshot.Usage = value.Cumulative
148+ if current := value.Usage.PromptTokens(); current > 0 {
149+ p.snapshot.ContextUsed = current
150+ }
128151 default:
129152 changed = false
130153 }
@@ -4,6 +4,7 @@ import (
4 "sync"4 "sync"
5 5
6 "github.com/bnema/zut/packages/core"6 "github.com/bnema/zut/packages/core"
7+ "github.com/bnema/zut/packages/provider"
7 )8 )
8 9
9 const residentLiveArgumentBytes = 64 << 1010 const residentLiveArgumentBytes = 64 << 10
@@ -35,6 +36,10 @@ type ResidentLiveSnapshot struct {
35 WaitingForModel bool36 WaitingForModel bool
36 AssistantText string37 AssistantText string
37 Tools []ResidentLiveTool38 Tools []ResidentLiveTool
39+ Usage provider.Usage
40+ ContextUsed int
41+ ContextMax int
42+ Subscription bool
38 }43 }
39 44
40 type residentLiveProjection struct {45 type residentLiveProjection struct {
@@ -44,6 +49,19 @@ type residentLiveProjection struct {
44 49
45 func newResidentLiveProjection() *residentLiveProjection { return &residentLiveProjection{} }50 func newResidentLiveProjection() *residentLiveProjection { return &residentLiveProjection{} }
46 51
52+func (p *residentLiveProjection) SeedUsage(usage ResidentUsageSnapshot) {
53+ if p == nil {
54+ return
55+ }
56+ p.mu.Lock()
57+ p.snapshot.Usage = usage.Usage
58+ p.snapshot.ContextUsed = usage.ContextUsed
59+ p.snapshot.ContextMax = usage.ContextMax
60+ p.snapshot.Subscription = usage.Subscription
61+ p.snapshot.Revision++
62+ p.mu.Unlock()
63+}
64+
47 func (p *residentLiveProjection) Start(turnID string) {65 func (p *residentLiveProjection) Start(turnID string) {
48 if p == nil {66 if p == nil {
49 return67 return
@@ -125,6 +143,11 @@ func (p *residentLiveProjection) Apply(event core.AgentEvent) {
125 case core.EvAssistantMessage:143 case core.EvAssistantMessage:
126 p.snapshot.WaitingForModel = false144 p.snapshot.WaitingForModel = false
127 p.snapshot.AssistantText = ""145 p.snapshot.AssistantText = ""
146+ case core.EvUsage:
147+ p.snapshot.Usage = value.Cumulative
148+ if current := value.Usage.PromptTokens(); current > 0 {
149+ p.snapshot.ContextUsed = current
150+ }
128 default:151 default:
129 changed = false152 changed = false
130 }153 }
M packages/agent/subagents/resident_projection_test.go +11 -0
@@ -39,6 +39,17 @@ func TestResidentLiveProjectionPublishesCopiesAndFinalizesLiveItems(t *testing.T
3939 }
4040 }
4141
42+func TestResidentLiveProjectionPreservesContextAcrossZeroUsage(t *testing.T) {
43+ projection := newResidentLiveProjection()
44+ usage := provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000}
45+ projection.Apply(core.EvUsage{Usage: usage, Cumulative: usage})
46+ projection.Apply(core.EvUsage{Usage: provider.Usage{}, Cumulative: usage})
47+ got := projection.Snapshot()
48+ if got.Usage != usage || got.ContextUsed != 207_000 {
49+ t.Fatalf("live usage = %#v, context = %d; want cumulative usage and preserved context", got.Usage, got.ContextUsed)
50+ }
51+}
52+
4253 func TestResidentLiveProjectionBoundsToolArguments(t *testing.T) {
4354 projection := newResidentLiveProjection()
4455 projection.Start("turn-1")
@@ -39,6 +39,17 @@ func TestResidentLiveProjectionPublishesCopiesAndFinalizesLiveItems(t *testing.T
39 }39 }
40 }40 }
41 41
42+func TestResidentLiveProjectionPreservesContextAcrossZeroUsage(t *testing.T) {
43+ projection := newResidentLiveProjection()
44+ usage := provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000}
45+ projection.Apply(core.EvUsage{Usage: usage, Cumulative: usage})
46+ projection.Apply(core.EvUsage{Usage: provider.Usage{}, Cumulative: usage})
47+ got := projection.Snapshot()
48+ if got.Usage != usage || got.ContextUsed != 207_000 {
49+ t.Fatalf("live usage = %#v, context = %d; want cumulative usage and preserved context", got.Usage, got.ContextUsed)
50+ }
51+}
52+
42 func TestResidentLiveProjectionBoundsToolArguments(t *testing.T) {53 func TestResidentLiveProjectionBoundsToolArguments(t *testing.T) {
43 projection := newResidentLiveProjection()54 projection := newResidentLiveProjection()
44 projection.Start("turn-1")55 projection.Start("turn-1")
M packages/provider/provider.go +16 -0
@@ -180,6 +180,22 @@ type Usage struct {
180180 CostUSD float64 `json:"cost_usd"`
181181 }
182182
183+// PromptTokens returns all input tokens, regardless of cache disposition.
184+func (u Usage) PromptTokens() int {
185+ return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens
186+}
187+
188+// CacheHitRatio returns the share of prompt tokens served from cache. The
189+// boolean is false when the provider reported no cache reads, which avoids
190+// presenting an unsupported cache metric as a zero-percent hit rate.
191+func (u Usage) CacheHitRatio() (float64, bool) {
192+ total := u.PromptTokens()
193+ if total <= 0 || u.CacheReadTokens <= 0 {
194+ return 0, false
195+ }
196+ return float64(u.CacheReadTokens) / float64(total), true
197+}
198+
183199 // Add returns u plus v. A reasoning total is known only when both
184200 // component usage reports include a separate reasoning-token count.
185201 func (u Usage) Add(v Usage) Usage {
@@ -180,6 +180,22 @@ type Usage struct {
180 CostUSD float64 `json:"cost_usd"`180 CostUSD float64 `json:"cost_usd"`
181 }181 }
182 182
183+// PromptTokens returns all input tokens, regardless of cache disposition.
184+func (u Usage) PromptTokens() int {
185+ return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens
186+}
187+
188+// CacheHitRatio returns the share of prompt tokens served from cache. The
189+// boolean is false when the provider reported no cache reads, which avoids
190+// presenting an unsupported cache metric as a zero-percent hit rate.
191+func (u Usage) CacheHitRatio() (float64, bool) {
192+ total := u.PromptTokens()
193+ if total <= 0 || u.CacheReadTokens <= 0 {
194+ return 0, false
195+ }
196+ return float64(u.CacheReadTokens) / float64(total), true
197+}
198+
183 // Add returns u plus v. A reasoning total is known only when both199 // Add returns u plus v. A reasoning total is known only when both
184 // component usage reports include a separate reasoning-token count.200 // component usage reports include a separate reasoning-token count.
185 func (u Usage) Add(v Usage) Usage {201 func (u Usage) Add(v Usage) Usage {
M packages/provider/provider_test.go +17 -0
@@ -11,6 +11,23 @@ import (
1111 "testing"
1212 )
1313
14+func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) {
15+ u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000}
16+ if got := u.PromptTokens(); got != 210_000 {
17+ t.Fatalf("prompt tokens = %d, want 210000", got)
18+ }
19+ ratio, ok := u.CacheHitRatio()
20+ if !ok || math.Abs(ratio-123.0/210.0) > 1e-9 {
21+ t.Fatalf("cache hit ratio = %v, %v, want %v, true", ratio, ok, 123.0/210.0)
22+ }
23+}
24+
25+func TestUsageCacheHitRatioUnavailableWithoutCacheReads(t *testing.T) {
26+ if ratio, ok := (Usage{InputTokens: 84_000}).CacheHitRatio(); ok || ratio != 0 {
27+ t.Fatalf("cache hit ratio = %v, %v, want 0, false", ratio, ok)
28+ }
29+}
30+
1431 func TestSSEParse(t *testing.T) {
1532 r := strings.NewReader("event: foo\ndata: {\"a\":1}\n\ndata: hello\ndata: world\n\n")
1633 ch := make(chan sseEvent, 4)
@@ -11,6 +11,23 @@ import (
11 "testing"11 "testing"
12 )12 )
13 13
14+func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) {
15+ u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000}
16+ if got := u.PromptTokens(); got != 210_000 {
17+ t.Fatalf("prompt tokens = %d, want 210000", got)
18+ }
19+ ratio, ok := u.CacheHitRatio()
20+ if !ok || math.Abs(ratio-123.0/210.0) > 1e-9 {
21+ t.Fatalf("cache hit ratio = %v, %v, want %v, true", ratio, ok, 123.0/210.0)
22+ }
23+}
24+
25+func TestUsageCacheHitRatioUnavailableWithoutCacheReads(t *testing.T) {
26+ if ratio, ok := (Usage{InputTokens: 84_000}).CacheHitRatio(); ok || ratio != 0 {
27+ t.Fatalf("cache hit ratio = %v, %v, want 0, false", ratio, ok)
28+ }
29+}
30+
14 func TestSSEParse(t *testing.T) {31 func TestSSEParse(t *testing.T) {
15 r := strings.NewReader("event: foo\ndata: {\"a\":1}\n\ndata: hello\ndata: world\n\n")32 r := strings.NewReader("event: foo\ndata: {\"a\":1}\n\ndata: hello\ndata: world\n\n")
16 ch := make(chan sseEvent, 4)33 ch := make(chan sseEvent, 4)
M packages/tui/statusbar_test.go +30 -0
@@ -7,6 +7,36 @@ import (
77 "github.com/bnema/zut/packages/provider"
88 )
99
10+func TestStatusBarShowsCacheHitRatio(t *testing.T) {
11+ lines := StatusBar(StatusBarParams{
12+ Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200,
13+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000},
14+ ContextUsed: 45_152, ContextMax: 272_000,
15+ })
16+ plain := stripANSI(strings.Join(lines, "\n"))
17+ if !strings.Contains(plain, "↑84k ↓1.5k R123k/ C59.4%") {
18+ t.Fatalf("status bar = %q, want compact cache hit ratio", plain)
19+ }
20+}
21+
22+func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) {
23+ const cols = 30
24+ lines := StatusBar(StatusBarParams{
25+ Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols,
26+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CostUSD: 0.525},
27+ ContextUsed: 209_000, ContextMax: 272_000,
28+ })
29+ plain := stripANSI(strings.Join(lines, "\n"))
30+ if !strings.Contains(plain, "C58.9%") {
31+ t.Fatalf("narrow status bar = %q, want cache hit ratio", plain)
32+ }
33+ for _, line := range lines {
34+ if width := visibleWidth(line); width > cols {
35+ t.Fatalf("narrow status line width = %d, want <= %d: %q", width, cols, stripANSI(line))
36+ }
37+ }
38+}
39+
1040 func TestStatusBarShowsActiveGoal(t *testing.T) {
1141 params := StatusBarParams{
1242 Theme: Dark,
@@ -7,6 +7,36 @@ import (
7 "github.com/bnema/zut/packages/provider"7 "github.com/bnema/zut/packages/provider"
8 )8 )
9 9
10+func TestStatusBarShowsCacheHitRatio(t *testing.T) {
11+ lines := StatusBar(StatusBarParams{
12+ Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200,
13+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000},
14+ ContextUsed: 45_152, ContextMax: 272_000,
15+ })
16+ plain := stripANSI(strings.Join(lines, "\n"))
17+ if !strings.Contains(plain, "↑84k ↓1.5k R123k/ C59.4%") {
18+ t.Fatalf("status bar = %q, want compact cache hit ratio", plain)
19+ }
20+}
21+
22+func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) {
23+ const cols = 30
24+ lines := StatusBar(StatusBarParams{
25+ Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols,
26+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CostUSD: 0.525},
27+ ContextUsed: 209_000, ContextMax: 272_000,
28+ })
29+ plain := stripANSI(strings.Join(lines, "\n"))
30+ if !strings.Contains(plain, "C58.9%") {
31+ t.Fatalf("narrow status bar = %q, want cache hit ratio", plain)
32+ }
33+ for _, line := range lines {
34+ if width := visibleWidth(line); width > cols {
35+ t.Fatalf("narrow status line width = %d, want <= %d: %q", width, cols, stripANSI(line))
36+ }
37+ }
38+}
39+
10 func TestStatusBarShowsActiveGoal(t *testing.T) {40 func TestStatusBarShowsActiveGoal(t *testing.T) {
11 params := StatusBarParams{41 params := StatusBarParams{
12 Theme: Dark,42 Theme: Dark,
A packages/tui/usage.go +48 -0
@@ -0,0 +1,48 @@
1+package tui
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/zut/packages/provider"
7+)
8+
9+// UsageStatsParams describes cumulative usage shown by compact status surfaces.
10+type UsageStatsParams struct {
11+ Usage provider.Usage
12+ Subscription bool
13+}
14+
15+// UsageStatsParts returns compact, provider-neutral token and cost labels.
16+func UsageStatsParts(p UsageStatsParams) []string {
17+ var parts []string
18+ if p.Usage.InputTokens > 0 {
19+ parts = append(parts, fmt.Sprintf("↑%s", formatTokens(p.Usage.InputTokens)))
20+ }
21+ if p.Usage.OutputTokens > 0 {
22+ parts = append(parts, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens)))
23+ }
24+ if p.Usage.CacheReadTokens > 0 {
25+ parts = append(parts, fmt.Sprintf("R%s/", formatTokens(p.Usage.CacheReadTokens)))
26+ }
27+ if ratio, ok := p.Usage.CacheHitRatio(); ok {
28+ parts = append(parts, fmt.Sprintf("C%.1f%%", ratio*100))
29+ }
30+ if p.Usage.CacheWriteTokens > 0 {
31+ parts = append(parts, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens)))
32+ }
33+ if p.Usage.CostUSD > 0 || p.Subscription {
34+ cost := fmt.Sprintf("$%.3f", p.Usage.CostUSD)
35+ if p.Subscription {
36+ cost += " (sub)"
37+ }
38+ parts = append(parts, cost)
39+ }
40+ return parts
41+}
42+
43+// ContextUsageText returns the latest request's context utilization without
44+// styling so callers can safely measure, truncate, and color the whole line.
45+func ContextUsageText(used, max int) string {
46+ label, _ := contextUsage(Theme{}, used, max)
47+ return label
48+}
@@ -0,0 +1,48 @@
1+package tui
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/zut/packages/provider"
7+)
8+
9+// UsageStatsParams describes cumulative usage shown by compact status surfaces.
10+type UsageStatsParams struct {
11+ Usage provider.Usage
12+ Subscription bool
13+}
14+
15+// UsageStatsParts returns compact, provider-neutral token and cost labels.
16+func UsageStatsParts(p UsageStatsParams) []string {
17+ var parts []string
18+ if p.Usage.InputTokens > 0 {
19+ parts = append(parts, fmt.Sprintf("↑%s", formatTokens(p.Usage.InputTokens)))
20+ }
21+ if p.Usage.OutputTokens > 0 {
22+ parts = append(parts, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens)))
23+ }
24+ if p.Usage.CacheReadTokens > 0 {
25+ parts = append(parts, fmt.Sprintf("R%s/", formatTokens(p.Usage.CacheReadTokens)))
26+ }
27+ if ratio, ok := p.Usage.CacheHitRatio(); ok {
28+ parts = append(parts, fmt.Sprintf("C%.1f%%", ratio*100))
29+ }
30+ if p.Usage.CacheWriteTokens > 0 {
31+ parts = append(parts, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens)))
32+ }
33+ if p.Usage.CostUSD > 0 || p.Subscription {
34+ cost := fmt.Sprintf("$%.3f", p.Usage.CostUSD)
35+ if p.Subscription {
36+ cost += " (sub)"
37+ }
38+ parts = append(parts, cost)
39+ }
40+ return parts
41+}
42+
43+// ContextUsageText returns the latest request's context utilization without
44+// styling so callers can safely measure, truncate, and color the whole line.
45+func ContextUsageText(used, max int) string {
46+ label, _ := contextUsage(Theme{}, used, max)
47+ return label
48+}
M packages/tui/view.go +32 -39
@@ -2897,36 +2897,7 @@ type StatusBarParams struct {
28972897 func StatusBar(p StatusBarParams) []string {
28982898 th := p.Theme
28992899
2900- // Token stats: only include each segment when non-zero. Keeps
2901- // the bar compact on brand-new sessions.
2902- var stats []string
2903- if p.Usage.InputTokens > 0 {
2904- stats = append(stats, fmt.Sprintf("↑%s", formatTokens(p.Usage.InputTokens)))
2905- }
2906- if p.Usage.OutputTokens > 0 {
2907- stats = append(stats, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens)))
2908- }
2909- if p.Usage.CacheReadTokens > 0 {
2910- stats = append(stats, fmt.Sprintf("R%s", formatTokens(p.Usage.CacheReadTokens)))
2911- }
2912- if p.Usage.CacheWriteTokens > 0 {
2913- stats = append(stats, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens)))
2914- }
2915-
2916- // Cost: always show the dollar value computed from token counts,
2917- // even on subscription. Lets you see what the equivalent api cost
2918- // would be (handy for gauging subscription value). Append "(sub)"
2919- // only as a hint that no real money moved.
2920- var costStr string
2921- if p.Usage.CostUSD > 0 || p.Subscription {
2922- costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD)
2923- if p.Subscription {
2924- costStr += " (sub)"
2925- }
2926- }
2927- if costStr != "" {
2928- stats = append(stats, costStr)
2929- }
2900+ stats := UsageStatsParts(UsageStatsParams{Usage: p.Usage, Subscription: p.Subscription})
29302901 if p.GoalStatus != "" {
29312902 stats = append(stats, "goal:"+p.GoalStatus)
29322903 }
@@ -3020,7 +2991,7 @@ func StatusBar(p StatusBarParams) []string {
30202991 modelLine := pad + th.FGColor(th.Muted, left)
30212992 lines := []string{busyLine}
30222993 if middle != "" && visibleWidth(modelLine+pad+th.FGColor(th.Muted, middle)) > p.Cols {
3023- lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols)
2994+ lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, stats, p.Cols)
30242995 } else {
30252996 var infoBuilder strings.Builder
30262997 infoBuilder.WriteString(modelLine)
@@ -3042,7 +3013,7 @@ func StatusBar(p StatusBarParams) []string {
30423013 // into an awkward position on small widths.
30433014 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
30443015 var lines []string
3045- lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols)
3016+ lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, stats, p.Cols)
30463017 if cwd != "" {
30473018 lines = append(lines, pad+th.FGColor(th.Muted, cwd))
30483019 }
@@ -3059,7 +3030,7 @@ func StatusBar(p StatusBarParams) []string {
30593030 return []string{primary, cwdRendered}
30603031 }
30613032
3062-func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText, statsText string, cols int) []string {
3033+func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText string, stats []string, cols int) []string {
30633034 modelLine := pad + th.FGColor(th.Muted, modelText)
30643035 infoParts := make([]string, 0, 2)
30653036 if fastText != "" {
@@ -3071,10 +3042,7 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText
30713042 infoText := strings.Join(infoParts, pad)
30723043 if infoText == "" {
30733044 lines = append(lines, modelLine)
3074- if statsText != "" {
3075- lines = append(lines, pad+th.FGColor(th.Muted, statsText))
3076- }
3077- return lines
3045+ return appendWrappedUsageStats(lines, th, pad, stats, cols)
30783046 }
30793047
30803048 modelInfoPlain := pad + modelText + pad + infoText
@@ -3093,9 +3061,34 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText
30933061 lines = append(lines, pad+th.FGColor(reasoningStatusColor(th, reasoningText), reasoningText))
30943062 }
30953063 }
3096- if statsText != "" {
3097- lines = append(lines, pad+th.FGColor(th.Muted, statsText))
3064+ return appendWrappedUsageStats(lines, th, pad, stats, cols)
3065+}
3066+
3067+func appendWrappedUsageStats(lines []string, th Theme, pad string, stats []string, cols int) []string {
3068+ if len(stats) == 0 {
3069+ return lines
3070+ }
3071+ available := cols - visibleWidth(pad)
3072+ if cols <= 0 || available <= 0 {
3073+ return append(lines, pad+th.FGColor(th.Muted, strings.Join(stats, " ")))
3074+ }
3075+ current := make([]string, 0, len(stats))
3076+ flush := func() {
3077+ if len(current) == 0 {
3078+ return
3079+ }
3080+ line := pad + th.FGColor(th.Muted, strings.Join(current, " "))
3081+ lines = append(lines, truncateToWidth(line, cols))
3082+ current = current[:0]
3083+ }
3084+ for _, stat := range stats {
3085+ candidate := append(append([]string(nil), current...), stat)
3086+ if len(current) > 0 && visibleWidth(strings.Join(candidate, " ")) > available {
3087+ flush()
3088+ }
3089+ current = append(current, stat)
30983090 }
3091+ flush()
30993092 return lines
31003093 }
31013094
@@ -2897,36 +2897,7 @@ type StatusBarParams struct {
2897 func StatusBar(p StatusBarParams) []string {2897 func StatusBar(p StatusBarParams) []string {
2898 th := p.Theme2898 th := p.Theme
2899 2899
2900- // Token stats: only include each segment when non-zero. Keeps2900+ stats := UsageStatsParts(UsageStatsParams{Usage: p.Usage, Subscription: p.Subscription})
2901- // the bar compact on brand-new sessions.
2902- var stats []string
2903- if p.Usage.InputTokens > 0 {
2904- stats = append(stats, fmt.Sprintf("↑%s", formatTokens(p.Usage.InputTokens)))
2905- }
2906- if p.Usage.OutputTokens > 0 {
2907- stats = append(stats, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens)))
2908- }
2909- if p.Usage.CacheReadTokens > 0 {
2910- stats = append(stats, fmt.Sprintf("R%s", formatTokens(p.Usage.CacheReadTokens)))
2911- }
2912- if p.Usage.CacheWriteTokens > 0 {
2913- stats = append(stats, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens)))
2914- }
2915-
2916- // Cost: always show the dollar value computed from token counts,
2917- // even on subscription. Lets you see what the equivalent api cost
2918- // would be (handy for gauging subscription value). Append "(sub)"
2919- // only as a hint that no real money moved.
2920- var costStr string
2921- if p.Usage.CostUSD > 0 || p.Subscription {
2922- costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD)
2923- if p.Subscription {
2924- costStr += " (sub)"
2925- }
2926- }
2927- if costStr != "" {
2928- stats = append(stats, costStr)
2929- }
2930 if p.GoalStatus != "" {2901 if p.GoalStatus != "" {
2931 stats = append(stats, "goal:"+p.GoalStatus)2902 stats = append(stats, "goal:"+p.GoalStatus)
2932 }2903 }
@@ -3020,7 +2991,7 @@ func StatusBar(p StatusBarParams) []string {
3020 modelLine := pad + th.FGColor(th.Muted, left)2991 modelLine := pad + th.FGColor(th.Muted, left)
3021 lines := []string{busyLine}2992 lines := []string{busyLine}
3022 if middle != "" && visibleWidth(modelLine+pad+th.FGColor(th.Muted, middle)) > p.Cols {2993 if middle != "" && visibleWidth(modelLine+pad+th.FGColor(th.Muted, middle)) > p.Cols {
3023- lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols)2994+ lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, stats, p.Cols)
3024 } else {2995 } else {
3025 var infoBuilder strings.Builder2996 var infoBuilder strings.Builder
3026 infoBuilder.WriteString(modelLine)2997 infoBuilder.WriteString(modelLine)
@@ -3042,7 +3013,7 @@ func StatusBar(p StatusBarParams) []string {
3042 // into an awkward position on small widths.3013 // into an awkward position on small widths.
3043 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {3014 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
3044 var lines []string3015 var lines []string
3045- lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols)3016+ lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, stats, p.Cols)
3046 if cwd != "" {3017 if cwd != "" {
3047 lines = append(lines, pad+th.FGColor(th.Muted, cwd))3018 lines = append(lines, pad+th.FGColor(th.Muted, cwd))
3048 }3019 }
@@ -3059,7 +3030,7 @@ func StatusBar(p StatusBarParams) []string {
3059 return []string{primary, cwdRendered}3030 return []string{primary, cwdRendered}
3060 }3031 }
3061 3032
3062-func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText, statsText string, cols int) []string {3033+func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText string, stats []string, cols int) []string {
3063 modelLine := pad + th.FGColor(th.Muted, modelText)3034 modelLine := pad + th.FGColor(th.Muted, modelText)
3064 infoParts := make([]string, 0, 2)3035 infoParts := make([]string, 0, 2)
3065 if fastText != "" {3036 if fastText != "" {
@@ -3071,10 +3042,7 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText
3071 infoText := strings.Join(infoParts, pad)3042 infoText := strings.Join(infoParts, pad)
3072 if infoText == "" {3043 if infoText == "" {
3073 lines = append(lines, modelLine)3044 lines = append(lines, modelLine)
3074- if statsText != "" {3045+ return appendWrappedUsageStats(lines, th, pad, stats, cols)
3075- lines = append(lines, pad+th.FGColor(th.Muted, statsText))
3076- }
3077- return lines
3078 }3046 }
3079 3047
3080 modelInfoPlain := pad + modelText + pad + infoText3048 modelInfoPlain := pad + modelText + pad + infoText
@@ -3093,9 +3061,34 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText
3093 lines = append(lines, pad+th.FGColor(reasoningStatusColor(th, reasoningText), reasoningText))3061 lines = append(lines, pad+th.FGColor(reasoningStatusColor(th, reasoningText), reasoningText))
3094 }3062 }
3095 }3063 }
3096- if statsText != "" {3064+ return appendWrappedUsageStats(lines, th, pad, stats, cols)
3097- lines = append(lines, pad+th.FGColor(th.Muted, statsText))3065+}
3066+
3067+func appendWrappedUsageStats(lines []string, th Theme, pad string, stats []string, cols int) []string {
3068+ if len(stats) == 0 {
3069+ return lines
3070+ }
3071+ available := cols - visibleWidth(pad)
3072+ if cols <= 0 || available <= 0 {
3073+ return append(lines, pad+th.FGColor(th.Muted, strings.Join(stats, " ")))
3074+ }
3075+ current := make([]string, 0, len(stats))
3076+ flush := func() {
3077+ if len(current) == 0 {
3078+ return
3079+ }
3080+ line := pad + th.FGColor(th.Muted, strings.Join(current, " "))
3081+ lines = append(lines, truncateToWidth(line, cols))
3082+ current = current[:0]
3083+ }
3084+ for _, stat := range stats {
3085+ candidate := append(append([]string(nil), current...), stat)
3086+ if len(current) > 0 && visibleWidth(strings.Join(candidate, " ")) > available {
3087+ flush()
3088+ }
3089+ current = append(current, stat)
3098 }3090 }
3091+ flush()
3099 return lines3092 return lines
3100 }3093 }
3101 3094

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close