diff --git a/packages/tui/live/live_keys_test.go b/packages/tui/live/live_keys_test.go index b7bd4fe..248ae01 100644 --- a/packages/tui/live/live_keys_test.go +++ b/packages/tui/live/live_keys_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -519,4 +520,126 @@ func TestExportMkdirFailureSetsErrorFlash(t *testing.T) { if !strings.HasPrefix(p.action.text, "✗ export failed:") { t.Errorf("action.text = %q, want it to start with the export-failed prefix", p.action.text) } -} \ No newline at end of file +} + +func liveHelpKeyMsg(token string) (tea.KeyMsg, bool) { + switch token { + case "space": + return tea.KeyMsg{Type: tea.KeySpace}, true + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter}, true + case "←": + return tea.KeyMsg{Type: tea.KeyLeft}, true + default: + if len([]rune(token)) != 1 { + return tea.KeyMsg{}, false + } + return runeKey(token), true + } +} + +func liveSnap(p *Live) string { + action := "" + if p.action != nil { + action = p.action.level + "|" + p.action.text + } + inspect := "" + if p.inspect != nil { + inspect = p.inspect.ID + } + return strings.Join([]string{ + "mode=" + p.mode, + "signal=" + p.signal, + "sel=" + strconv.Itoa(p.sel), + "help=" + strconv.FormatBool(p.help), + "frozen=" + strconv.FormatBool(p.frozen), + "searchEdit=" + strconv.FormatBool(p.editingSearch), + "inspect=" + inspect, + "layersScroll=" + strconv.Itoa(p.layersScroll), + "action=" + action, + }, ";") +} + +func preparedLiveForHelpKey(t *testing.T, token string) *Live { + t.Helper() + p := newTestLiveForKeys() + switch token { + case "w": + p.merged = []streamItem{{ID: "d1", Decision: "DENY", Tool: "Bash"}} + case "b": + p.merged = []streamItem{{ID: "a1", Decision: "ALLOW", Tool: "Read"}} + case "enter", "e": + p.merged = []streamItem{{ID: "row", Decision: "ALLOW", Tool: "Read"}} + if token == "e" { + t.Setenv("HOME", t.TempDir()) + } + case "←": + p.mode = "inspect" + p.inspect = &streamItem{ID: "row", Decision: "ALLOW", Tool: "Read"} + p.inspectScroll = 3 + default: + p.merged = []streamItem{{ID: "row", Decision: "ALLOW", Tool: "Read"}} + } + return p +} + +func TestLiveHelpAdvertisedKeysHaveHandlers(t *testing.T) { + // Every discrete help token must change Live state through onKey. + // Composite navigation labels and the meta "any key" line are covered + // elsewhere. esc/q are handled by App.onKey, not Live.onKey. + skipLabel := map[string]struct{}{ + "↑↓ / PgUp PgDn": {}, + "any key": {}, + "esc": {}, + "q": {}, + } + ctx := tui.PanelContext{Now: time.UnixMilli(1000), Rows: 20, Cols: 80} + unknown := runeKey("=") + + for _, g := range liveHelp { + for _, pair := range g.Keys { + label, desc := pair[0], pair[1] + if _, ok := skipLabel[label]; ok { + continue + } + tokens := []string{label} + if strings.Contains(label, " / ") { + tokens = nil + for _, part := range strings.Split(label, "/") { + part = strings.TrimSpace(part) + if part != "" { + tokens = append(tokens, part) + } + } + } + for _, tok := range tokens { + tok, desc := tok, desc + t.Run(tok, func(t *testing.T) { + msg, ok := liveHelpKeyMsg(tok) + if !ok { + t.Fatalf("help token %q (%q) has no KeyMsg mapping", tok, desc) + } + + pKey := preparedLiveForHelpKey(t, tok) + before := liveSnap(pKey) + pKey.onKey(msg, ctx) + afterKey := liveSnap(pKey) + + pUnk := preparedLiveForHelpKey(t, tok) + pUnk.onKey(unknown, ctx) + afterUnk := liveSnap(pUnk) + + if afterUnk != before { + t.Fatalf("baseline unknown key %q changed state: before=%s after=%s", "=", before, afterUnk) + } + if afterKey == before { + t.Errorf("liveHelp advertises %q (%q) but onKey left Live state unchanged (same as unknown key)", tok, desc) + } + if afterKey == afterUnk { + t.Errorf("liveHelp advertises %q (%q) but onKey matched the unknown-key baseline", tok, desc) + } + }) + } + } + } +} diff --git a/packages/tui/live/live_view.go b/packages/tui/live/live_view.go index 4248b0a..9c198cc 100644 --- a/packages/tui/live/live_view.go +++ b/packages/tui/live/live_view.go @@ -119,7 +119,6 @@ var liveHelp = []tui.HelpGroup{ {"b", "block the selected ALLOW (adds DENY rule)"}, {"d / x / r", "filter: denies / dlp hits / rate-limit bursts"}, {"/", "live search (tool, agent, command…) · enter done"}, - {"s", "session picker"}, {"l", "layers detail (rate limit · dlp · guard)"}, {"e", "export visible rows → ~/.solongate/live-export.jsonl"}, {"space", "copy mode: freeze screen for mouse selection"}, @@ -831,4 +830,4 @@ func maxFloat(a, b float64) float64 { return a } return b -} \ No newline at end of file +}