Skip to content

Commit eb3d8c3

Browse files
committed
feat(tui): add model signals metric tabs
1 parent 0ae0cbf commit eb3d8c3

4 files changed

Lines changed: 545 additions & 57 deletions

File tree

internal/tui/model.go

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,49 @@ const (
3737
pagePrivacy
3838
)
3939

40+
type modelSignalsTab int
41+
42+
const (
43+
modelSignalsTabCharts modelSignalsTab = iota
44+
modelSignalsTabOverview
45+
modelSignalsTabDaily
46+
modelSignalsTabCohorts
47+
modelSignalsTabMatrix
48+
modelSignalsTabProjects
49+
modelSignalsTabAnomalies
50+
)
51+
52+
var modelSignalsTabs = []modelSignalsTab{
53+
modelSignalsTabCharts,
54+
modelSignalsTabOverview,
55+
modelSignalsTabDaily,
56+
modelSignalsTabCohorts,
57+
modelSignalsTabMatrix,
58+
modelSignalsTabProjects,
59+
modelSignalsTabAnomalies,
60+
}
61+
62+
func (t modelSignalsTab) title() string {
63+
switch t {
64+
case modelSignalsTabCharts:
65+
return "Charts"
66+
case modelSignalsTabOverview:
67+
return "Overview"
68+
case modelSignalsTabDaily:
69+
return "Daily"
70+
case modelSignalsTabCohorts:
71+
return "Cohorts"
72+
case modelSignalsTabMatrix:
73+
return "Matrix"
74+
case modelSignalsTabProjects:
75+
return "Projects"
76+
case modelSignalsTabAnomalies:
77+
return "Anomalies"
78+
default:
79+
return "Charts"
80+
}
81+
}
82+
4083
func (p page) title() string {
4184
switch p {
4285
case pageOverview:
@@ -167,6 +210,8 @@ type state struct {
167210
privacyTarget int
168211
privacyPending *privacyProfileAction
169212
privacyApplying bool
213+
214+
modelSignalsTab modelSignalsTab
170215
}
171216

172217
func newState(service appService, width, height int) *state {
@@ -327,6 +372,14 @@ func (s *state) handleKey(k keyMsg) (command, bool) {
327372
if s.page == pageToolCalls {
328373
return s.switchPage(pageTools), false
329374
}
375+
case '[', 'h', 'H':
376+
if s.page == pageModelSignals {
377+
s.cycleModelSignalsTab(-1)
378+
}
379+
case ']', 'l', 'L':
380+
if s.page == pageModelSignals {
381+
s.cycleModelSignalsTab(1)
382+
}
330383
}
331384
}
332385

@@ -381,6 +434,26 @@ func (s *state) handleKey(k keyMsg) (command, bool) {
381434
return nil, false
382435
}
383436

437+
func (s *state) cycleModelSignalsTab(delta int) {
438+
if delta == 0 {
439+
return
440+
}
441+
index := 0
442+
for i, tab := range modelSignalsTabs {
443+
if tab == s.modelSignalsTab {
444+
index = i
445+
break
446+
}
447+
}
448+
index = (index + delta) % len(modelSignalsTabs)
449+
if index < 0 {
450+
index += len(modelSignalsTabs)
451+
}
452+
s.modelSignalsTab = modelSignalsTabs[index]
453+
s.scroll = 0
454+
s.status = "model signals tab: " + s.modelSignalsTab.title()
455+
}
456+
384457
func (s *state) switchPage(target page) command {
385458
if target == pageSessionDetail {
386459
target = pageSessions
@@ -602,7 +675,7 @@ func (s *state) itemCount() int {
602675
}
603676
return len(toolCallDetailLines(*s.toolCall, s.width))
604677
case pageModelSignals:
605-
return len(modelSignalLines(s.signals, s.width))
678+
return len(modelSignalLines(s.signals, s.width, s.modelSignalsTab))
606679
case pageSettings:
607680
return len(settingsLines(s.settings, s.width))
608681
case pagePrivacy:

internal/tui/tui_test.go

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ func TestToolsOpenToolCallsAndDetail(t *testing.T) {
277277

278278
func TestModelSignalsLoadsAndRenders(t *testing.T) {
279279
svc := sampleService()
280-
st := newState(svc, 120, 60)
280+
st := newState(svc, 180, 60)
281281

282282
cmd, quit := st.update(keyMsg{typ: keyRune, ch: 'm'})
283283
if quit {
@@ -289,13 +289,109 @@ func TestModelSignalsLoadsAndRenders(t *testing.T) {
289289
assertContains(t, view, "Model Signals")
290290
assertContains(t, view, "Health:")
291291
assertContains(t, view, "warning")
292+
assertContains(t, view, "Metric Explorer")
293+
assertContains(t, view, "P90 latency")
294+
assertContains(t, view, "Cache savings")
295+
assertContains(t, view, "Failure pressure")
296+
297+
cmd, quit = st.update(keyMsg{typ: keyRune, ch: ']'})
298+
if quit {
299+
t.Fatal("unexpected quit")
300+
}
301+
if cmd != nil {
302+
t.Fatal("model signal tab switch returned a command")
303+
}
304+
view = st.view()
292305
assertContains(t, view, "Model Breakdown")
293306
assertContains(t, view, "gpt-5-codex")
294307
assertContains(t, view, "Top Drift Cohorts")
295308
assertContains(t, view, "Tool failures above baseline")
309+
310+
st.update(keyMsg{typ: keyRune, ch: ']'})
311+
view = st.view()
296312
assertContains(t, view, "Daily Efficiency")
313+
assertContains(t, view, "Cost/H")
314+
assertContains(t, view, "P90/P50")
315+
316+
st.update(keyMsg{typ: keyRune, ch: ']'})
317+
st.update(keyMsg{typ: keyRune, ch: ']'})
318+
view = st.view()
319+
assertContains(t, view, "Source Model Matrix")
320+
assertContains(t, view, "RiskLvl")
321+
assertContains(t, view, "gpt-5-codex")
322+
323+
st.update(keyMsg{typ: keyRune, ch: ']'})
324+
view = st.view()
297325
assertContains(t, view, "Project Hotspots")
326+
327+
st.update(keyMsg{typ: keyRune, ch: ']'})
328+
view = st.view()
298329
assertContains(t, view, "Anomaly Sessions")
330+
assertContains(t, view, "Output")
331+
assertContains(t, view, "Started")
332+
}
333+
334+
func TestModelSignalTabNavigationWrapsAndResetsScroll(t *testing.T) {
335+
svc := sampleService()
336+
st := newState(svc, 100, 24)
337+
338+
cmd, quit := st.update(keyMsg{typ: keyRune, ch: 'm'})
339+
if quit {
340+
t.Fatal("unexpected quit")
341+
}
342+
st.update(runCommand(t, cmd))
343+
344+
st.scroll = 5
345+
cmd, quit = st.update(keyMsg{typ: keyRune, ch: '['})
346+
if quit {
347+
t.Fatal("unexpected quit")
348+
}
349+
if cmd != nil {
350+
t.Fatal("tab switch returned a command")
351+
}
352+
if st.modelSignalsTab != modelSignalsTabAnomalies {
353+
t.Fatalf("tab = %s, want Anomalies", st.modelSignalsTab.title())
354+
}
355+
if st.scroll != 0 {
356+
t.Fatalf("scroll = %d, want reset to 0", st.scroll)
357+
}
358+
359+
st.update(keyMsg{typ: keyRune, ch: 'h'})
360+
if st.modelSignalsTab != modelSignalsTabProjects {
361+
t.Fatalf("tab = %s, want Projects", st.modelSignalsTab.title())
362+
}
363+
st.update(keyMsg{typ: keyRune, ch: 'l'})
364+
if st.modelSignalsTab != modelSignalsTabAnomalies {
365+
t.Fatalf("tab = %s, want Anomalies", st.modelSignalsTab.title())
366+
}
367+
st.update(keyMsg{typ: keyRune, ch: ']'})
368+
if st.modelSignalsTab != modelSignalsTabCharts {
369+
t.Fatalf("tab = %s, want Charts", st.modelSignalsTab.title())
370+
}
371+
}
372+
373+
func TestModelSignalsNarrowWidthViewsRender(t *testing.T) {
374+
svc := sampleService()
375+
st := newState(svc, 80, 24)
376+
377+
cmd, quit := st.update(keyMsg{typ: keyRune, ch: 'm'})
378+
if quit {
379+
t.Fatal("unexpected quit")
380+
}
381+
st.update(runCommand(t, cmd))
382+
383+
for _, want := range []string{
384+
"Metric Explorer",
385+
"Health Overview",
386+
"Daily Efficiency",
387+
"Top Drift Cohorts",
388+
"Source Model Matrix",
389+
"Project Hotspots",
390+
"Anomaly Sessions",
391+
} {
392+
assertContains(t, st.view(), want)
393+
st.update(keyMsg{typ: keyRune, ch: ']'})
394+
}
299395
}
300396

301397
func TestToolsAndToolCallsKeepSelectedRowVisible(t *testing.T) {

internal/tui/view.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func (s *state) headerLine() string {
6969
case pageModelSignals:
7070
summary := s.signals.HealthSummary
7171
parts = append(parts,
72+
"tab "+s.modelSignalsTab.title(),
7273
"health "+modelSignalSeverityLabel(summary.Severity),
7374
fmt.Sprintf("%s sessions", formatInt(int64(s.signals.TotalSessions))),
7475
fmt.Sprintf("%s calls", formatInt(int64(s.signals.TotalModelCalls))),
@@ -146,7 +147,7 @@ func (s *state) footerLine() string {
146147
case pageToolCallDetail:
147148
text = "Keys: b/esc calls up/down scroll r refresh i update index I rebuild index q quit"
148149
case pageModelSignals:
149-
text = "Keys: up/down scroll tab cycle r refresh i update index I rebuild index q quit"
150+
text = "Keys: [/]/h/l signal tabs up/down scroll tab cycle pages r refresh i update index I rebuild index q quit"
150151
case pagePrivacy:
151152
if s.privacyPending != nil {
152153
text = "Keys: enter write profile esc cancel q quit"

0 commit comments

Comments
 (0)