Fix session timeline controls and completion
- commit
- 9b7bb6a4f36bc8c8deb2cc5796a8f557f4fb7479
- author
- patriceckhart · · 2026-08-22 15:37 UTC
- parent
- eadc321
- signature
- unsigned
- refs
- tag: v0.3.48
6 files · +161 -12
M packages/agent/modes/slash_suggest.go +31 -7
| @@ -42,7 +42,7 @@ var slashCatalog = []slashCommand{ | |||
| 42 | 42 | {Name: "/reasoning", Desc: "set the reasoning level"}, | |
| 43 | 43 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | |
| 44 | 44 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | |
| 45 | - | {Name: "/session", Desc: "inspect, export, import, fork, or navigate the current session"}, | |
| 45 | + | {Name: "/session", Desc: "manage the current session"}, | |
| 46 | 46 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | |
| 47 | 47 | {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, | |
| 48 | 48 | {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"}, | |
| @@ -58,6 +58,14 @@ var slashCatalog = []slashCommand{ | |||
| 58 | 58 | {Name: "/exit", Desc: "exit zot"}, | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | + | var sessionSlashCommands = []slashCommand{ | |
| 62 | + | {Name: "/session timeline", Desc: "inspect context, messages, and tool calls"}, | |
| 63 | + | {Name: "/session export", Desc: "write the current session to a .zotsession file"}, | |
| 64 | + | {Name: "/session import", Desc: "load a .zotsession file into this directory"}, | |
| 65 | + | {Name: "/session fork", Desc: "branch from a past user message"}, | |
| 66 | + | {Name: "/session tree", Desc: "switch between session branches"}, | |
| 67 | + | } | |
| 68 | + | ||
| 61 | 69 | // slashSuggester renders the popup that appears when the editor starts | |
| 62 | 70 | // with "/". It does not own any input state — the editor drives. | |
| 63 | 71 | const slashSuggestPageSize = 8 | |
| @@ -266,17 +274,25 @@ var hiddenSlashCommands = []string{ | |||
| 266 | 274 | func newSlashSuggester() *slashSuggester { return &slashSuggester{} } | |
| 267 | 275 | ||
| 268 | 276 | // matches returns the commands whose name has input as a prefix. | |
| 269 | - | // If input is just "/", everything is shown. | |
| 277 | + | // If input is just "/", everything is shown. The /session command exposes | |
| 278 | + | // its actions after the first space. | |
| 270 | 279 | func (s *slashSuggester) matches(input string) []slashCommand { | |
| 271 | - | input = strings.TrimRight(input, " ") | |
| 272 | 280 | if input == "" || !strings.HasPrefix(input, "/") { | |
| 273 | 281 | return nil | |
| 274 | 282 | } | |
| 275 | - | // If there is a space, the user has moved past the command name. | |
| 276 | - | if idx := strings.IndexByte(input, ' '); idx >= 0 { | |
| 283 | + | matchPrefix := strings.ToLower(input) | |
| 284 | + | if strings.HasPrefix(matchPrefix, "/session ") { | |
| 285 | + | var out []slashCommand | |
| 286 | + | for _, c := range sessionSlashCommands { | |
| 287 | + | if strings.HasPrefix(strings.ToLower(c.Name), matchPrefix) { | |
| 288 | + | out = append(out, c) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | return out | |
| 292 | + | } | |
| 293 | + | if strings.ContainsRune(input, ' ') { | |
| 277 | 294 | return nil | |
| 278 | 295 | } | |
| 279 | - | matchPrefix := strings.ToLower(input) | |
| 280 | 296 | catalog := s.allCatalog() | |
| 281 | 297 | if strings.HasPrefix(matchPrefix, skillCommandPrefix) { | |
| 282 | 298 | catalog = s.skills | |
| @@ -294,7 +310,15 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||
| 294 | 310 | out = append(out, c) | |
| 295 | 311 | } | |
| 296 | 312 | } | |
| 297 | - | return pruneOrphanHeaders(out) | |
| 313 | + | out = pruneOrphanHeaders(out) | |
| 314 | + | // Prefer an exact command over longer commands sharing its prefix. This | |
| 315 | + | // keeps /session selected instead of the earlier /sessions entry. | |
| 316 | + | for _, c := range out { | |
| 317 | + | if !c.Header && strings.EqualFold(c.Name, input) { | |
| 318 | + | return []slashCommand{c} | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | return out | |
| 298 | 322 | } | |
| 299 | 323 | ||
| 300 | 324 | // pruneOrphanHeaders removes header rows that have no commands | |
| @@ -42,7 +42,7 @@ var slashCatalog = []slashCommand{ | |||||
| 42 | {Name: "/reasoning", Desc: "set the reasoning level"}, | 42 | {Name: "/reasoning", Desc: "set the reasoning level"}, | ||
| 43 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | 43 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | ||
| 44 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | 44 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | ||
| 45 | - | {Name: "/session", Desc: "inspect, export, import, fork, or navigate the current session"}, | 45 | + | {Name: "/session", Desc: "manage the current session"}, |
| 46 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | 46 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | ||
| 47 | {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, | 47 | {Name: "/compact", Desc: "summarize and replace the transcript to free up context"}, | ||
| 48 | {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"}, | 48 | {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"}, | ||
| @@ -58,6 +58,14 @@ var slashCatalog = []slashCommand{ | |||||
| 58 | {Name: "/exit", Desc: "exit zot"}, | 58 | {Name: "/exit", Desc: "exit zot"}, | ||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| 61 | + | var sessionSlashCommands = []slashCommand{ | |||
| 62 | + | {Name: "/session timeline", Desc: "inspect context, messages, and tool calls"}, | |||
| 63 | + | {Name: "/session export", Desc: "write the current session to a .zotsession file"}, | |||
| 64 | + | {Name: "/session import", Desc: "load a .zotsession file into this directory"}, | |||
| 65 | + | {Name: "/session fork", Desc: "branch from a past user message"}, | |||
| 66 | + | {Name: "/session tree", Desc: "switch between session branches"}, | |||
| 67 | + | } | |||
| 68 | + | ||||
| 61 | // slashSuggester renders the popup that appears when the editor starts | 69 | // slashSuggester renders the popup that appears when the editor starts | ||
| 62 | // with "/". It does not own any input state — the editor drives. | 70 | // with "/". It does not own any input state — the editor drives. | ||
| 63 | const slashSuggestPageSize = 8 | 71 | const slashSuggestPageSize = 8 | ||
| @@ -266,17 +274,25 @@ var hiddenSlashCommands = []string{ | |||||
| 266 | func newSlashSuggester() *slashSuggester { return &slashSuggester{} } | 274 | func newSlashSuggester() *slashSuggester { return &slashSuggester{} } | ||
| 267 | 275 | ||||
| 268 | // matches returns the commands whose name has input as a prefix. | 276 | // matches returns the commands whose name has input as a prefix. | ||
| 269 | - | // If input is just "/", everything is shown. | 277 | + | // If input is just "/", everything is shown. The /session command exposes |
| 278 | + | // its actions after the first space. | |||
| 270 | func (s *slashSuggester) matches(input string) []slashCommand { | 279 | func (s *slashSuggester) matches(input string) []slashCommand { | ||
| 271 | - | input = strings.TrimRight(input, " ") | |||
| 272 | if input == "" || !strings.HasPrefix(input, "/") { | 280 | if input == "" || !strings.HasPrefix(input, "/") { | ||
| 273 | return nil | 281 | return nil | ||
| 274 | } | 282 | } | ||
| 275 | - | // If there is a space, the user has moved past the command name. | 283 | + | matchPrefix := strings.ToLower(input) |
| 276 | - | if idx := strings.IndexByte(input, ' '); idx >= 0 { | 284 | + | if strings.HasPrefix(matchPrefix, "/session ") { |
| 285 | + | var out []slashCommand | |||
| 286 | + | for _, c := range sessionSlashCommands { | |||
| 287 | + | if strings.HasPrefix(strings.ToLower(c.Name), matchPrefix) { | |||
| 288 | + | out = append(out, c) | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | return out | |||
| 292 | + | } | |||
| 293 | + | if strings.ContainsRune(input, ' ') { | |||
| 277 | return nil | 294 | return nil | ||
| 278 | } | 295 | } | ||
| 279 | - | matchPrefix := strings.ToLower(input) | |||
| 280 | catalog := s.allCatalog() | 296 | catalog := s.allCatalog() | ||
| 281 | if strings.HasPrefix(matchPrefix, skillCommandPrefix) { | 297 | if strings.HasPrefix(matchPrefix, skillCommandPrefix) { | ||
| 282 | catalog = s.skills | 298 | catalog = s.skills | ||
| @@ -294,7 +310,15 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||||
| 294 | out = append(out, c) | 310 | out = append(out, c) | ||
| 295 | } | 311 | } | ||
| 296 | } | 312 | } | ||
| 297 | - | return pruneOrphanHeaders(out) | 313 | + | out = pruneOrphanHeaders(out) |
| 314 | + | // Prefer an exact command over longer commands sharing its prefix. This | |||
| 315 | + | // keeps /session selected instead of the earlier /sessions entry. | |||
| 316 | + | for _, c := range out { | |||
| 317 | + | if !c.Header && strings.EqualFold(c.Name, input) { | |||
| 318 | + | return []slashCommand{c} | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | return out | |||
| 298 | } | 322 | } | ||
| 299 | 323 | ||||
| 300 | // pruneOrphanHeaders removes header rows that have no commands | 324 | // pruneOrphanHeaders removes header rows that have no commands | ||
M packages/agent/modes/slash_suggest_test.go +29 -0
| @@ -44,6 +44,35 @@ func TestSlashSuggesterHasSwarm(t *testing.T) { | |||
| 44 | 44 | } | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | + | func TestSlashSuggesterPrefersExactSessionCommand(t *testing.T) { | |
| 48 | + | s := newSlashSuggester() | |
| 49 | + | if got := commandNames(s.matches("/session")); !slices.Equal(got, []string{"/session"}) { | |
| 50 | + | t.Fatalf("matches = %v, want exact /session", got) | |
| 51 | + | } | |
| 52 | + | if got := s.Selection("/session"); got != "/session" { | |
| 53 | + | t.Fatalf("selection = %q, want /session", got) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestSlashSuggesterCompletesSessionActions(t *testing.T) { | |
| 58 | + | s := newSlashSuggester() | |
| 59 | + | if got, want := commandNames(s.matches("/session ")), []string{ | |
| 60 | + | "/session timeline", | |
| 61 | + | "/session export", | |
| 62 | + | "/session import", | |
| 63 | + | "/session fork", | |
| 64 | + | "/session tree", | |
| 65 | + | }; !slices.Equal(got, want) { | |
| 66 | + | t.Fatalf("matches = %v, want %v", got, want) | |
| 67 | + | } | |
| 68 | + | if got := commandNames(s.matches("/session t")); !slices.Equal(got, []string{"/session timeline", "/session tree"}) { | |
| 69 | + | t.Fatalf("filtered matches = %v", got) | |
| 70 | + | } | |
| 71 | + | if got := s.Selection("/session t"); got != "/session timeline" { | |
| 72 | + | t.Fatalf("selection = %q, want /session timeline", got) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 47 | 76 | func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | |
| 48 | 77 | s := newSlashSuggester() | |
| 49 | 78 | if got := commandNames(s.matches("/EX")); !contains(got, "/exit") { | |
| @@ -44,6 +44,35 @@ func TestSlashSuggesterHasSwarm(t *testing.T) { | |||||
| 44 | } | 44 | } | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | + | func TestSlashSuggesterPrefersExactSessionCommand(t *testing.T) { | |||
| 48 | + | s := newSlashSuggester() | |||
| 49 | + | if got := commandNames(s.matches("/session")); !slices.Equal(got, []string{"/session"}) { | |||
| 50 | + | t.Fatalf("matches = %v, want exact /session", got) | |||
| 51 | + | } | |||
| 52 | + | if got := s.Selection("/session"); got != "/session" { | |||
| 53 | + | t.Fatalf("selection = %q, want /session", got) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestSlashSuggesterCompletesSessionActions(t *testing.T) { | |||
| 58 | + | s := newSlashSuggester() | |||
| 59 | + | if got, want := commandNames(s.matches("/session ")), []string{ | |||
| 60 | + | "/session timeline", | |||
| 61 | + | "/session export", | |||
| 62 | + | "/session import", | |||
| 63 | + | "/session fork", | |||
| 64 | + | "/session tree", | |||
| 65 | + | }; !slices.Equal(got, want) { | |||
| 66 | + | t.Fatalf("matches = %v, want %v", got, want) | |||
| 67 | + | } | |||
| 68 | + | if got := commandNames(s.matches("/session t")); !slices.Equal(got, []string{"/session timeline", "/session tree"}) { | |||
| 69 | + | t.Fatalf("filtered matches = %v", got) | |||
| 70 | + | } | |||
| 71 | + | if got := s.Selection("/session t"); got != "/session timeline" { | |||
| 72 | + | t.Fatalf("selection = %q, want /session timeline", got) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 47 | func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | 76 | func TestSlashCommandsAreCaseInsensitive(t *testing.T) { | ||
| 48 | s := newSlashSuggester() | 77 | s := newSlashSuggester() | ||
| 49 | if got := commandNames(s.matches("/EX")); !contains(got, "/exit") { | 78 | if got := commandNames(s.matches("/EX")); !contains(got, "/exit") { | ||
M packages/agent/modes/timeline_view.go +32 -4
| @@ -167,7 +167,7 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData | |||
| 167 | 167 | lines := []string{frameHeader(th, "timeline", width)} | |
| 168 | 168 | lines = append(lines, renderTimelineContext(th, width, data)...) | |
| 169 | 169 | ||
| 170 | - | hint := "up/down select pgup/pgdn move tab details / search ctrl+e export esc chat" | |
| 170 | + | hint := "↑/↓ select pgup/pgdn move tab details / search ctrl+e export esc chat" | |
| 171 | 171 | if v.searching { | |
| 172 | 172 | hint = "search: " + v.filter + "_ (esc clears)" | |
| 173 | 173 | } | |
| @@ -397,15 +397,43 @@ func timelineColor(th tui.Theme, entry timelineEntry, line string) string { | |||
| 397 | 397 | } | |
| 398 | 398 | ||
| 399 | 399 | func renderTimelineTabs(th tui.Theme, width, selected int) string { | |
| 400 | - | var tabs []string | |
| 401 | - | for idx, name := range timelineTabNames { | |
| 400 | + | if selected < 0 || selected >= len(timelineTabNames) || width < 1 { | |
| 401 | + | return "" | |
| 402 | + | } | |
| 403 | + | start, end := selected, selected+1 | |
| 404 | + | for start > 0 || end < len(timelineTabNames) { | |
| 405 | + | grew := false | |
| 406 | + | if start > 0 && timelineTabsWidth(start-1, end) <= width { | |
| 407 | + | start-- | |
| 408 | + | grew = true | |
| 409 | + | } | |
| 410 | + | if end < len(timelineTabNames) && timelineTabsWidth(start, end+1) <= width { | |
| 411 | + | end++ | |
| 412 | + | grew = true | |
| 413 | + | } | |
| 414 | + | if !grew { | |
| 415 | + | break | |
| 416 | + | } | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | tabs := make([]string, 0, end-start) | |
| 420 | + | for idx := start; idx < end; idx++ { | |
| 421 | + | name := timelineTabNames[idx] | |
| 402 | 422 | if idx == selected { | |
| 403 | 423 | tabs = append(tabs, tui.Bold(th.FG256(th.Accent, "["+name+"]"))) | |
| 404 | 424 | } else { | |
| 405 | 425 | tabs = append(tabs, th.FG256(th.Muted, " "+name+" ")) | |
| 406 | 426 | } | |
| 407 | 427 | } | |
| 408 | - | return fitTimelineLine(strings.Join(tabs, " "), width) | |
| 428 | + | return strings.Join(tabs, " ") | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | func timelineTabsWidth(start, end int) int { | |
| 432 | + | width := 2 * (end - start - 1) | |
| 433 | + | for idx := start; idx < end; idx++ { | |
| 434 | + | width += runewidth.StringWidth(timelineTabNames[idx]) + 2 | |
| 435 | + | } | |
| 436 | + | return width | |
| 409 | 437 | } | |
| 410 | 438 | ||
| 411 | 439 | func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, maxRows int) []string { | |
| @@ -167,7 +167,7 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData | |||||
| 167 | lines := []string{frameHeader(th, "timeline", width)} | 167 | lines := []string{frameHeader(th, "timeline", width)} | ||
| 168 | lines = append(lines, renderTimelineContext(th, width, data)...) | 168 | lines = append(lines, renderTimelineContext(th, width, data)...) | ||
| 169 | 169 | ||||
| 170 | - | hint := "up/down select pgup/pgdn move tab details / search ctrl+e export esc chat" | 170 | + | hint := "↑/↓ select pgup/pgdn move tab details / search ctrl+e export esc chat" |
| 171 | if v.searching { | 171 | if v.searching { | ||
| 172 | hint = "search: " + v.filter + "_ (esc clears)" | 172 | hint = "search: " + v.filter + "_ (esc clears)" | ||
| 173 | } | 173 | } | ||
| @@ -397,15 +397,43 @@ func timelineColor(th tui.Theme, entry timelineEntry, line string) string { | |||||
| 397 | } | 397 | } | ||
| 398 | 398 | ||||
| 399 | func renderTimelineTabs(th tui.Theme, width, selected int) string { | 399 | func renderTimelineTabs(th tui.Theme, width, selected int) string { | ||
| 400 | - | var tabs []string | 400 | + | if selected < 0 || selected >= len(timelineTabNames) || width < 1 { |
| 401 | - | for idx, name := range timelineTabNames { | 401 | + | return "" |
| 402 | + | } | |||
| 403 | + | start, end := selected, selected+1 | |||
| 404 | + | for start > 0 || end < len(timelineTabNames) { | |||
| 405 | + | grew := false | |||
| 406 | + | if start > 0 && timelineTabsWidth(start-1, end) <= width { | |||
| 407 | + | start-- | |||
| 408 | + | grew = true | |||
| 409 | + | } | |||
| 410 | + | if end < len(timelineTabNames) && timelineTabsWidth(start, end+1) <= width { | |||
| 411 | + | end++ | |||
| 412 | + | grew = true | |||
| 413 | + | } | |||
| 414 | + | if !grew { | |||
| 415 | + | break | |||
| 416 | + | } | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | tabs := make([]string, 0, end-start) | |||
| 420 | + | for idx := start; idx < end; idx++ { | |||
| 421 | + | name := timelineTabNames[idx] | |||
| 402 | if idx == selected { | 422 | if idx == selected { | ||
| 403 | tabs = append(tabs, tui.Bold(th.FG256(th.Accent, "["+name+"]"))) | 423 | tabs = append(tabs, tui.Bold(th.FG256(th.Accent, "["+name+"]"))) | ||
| 404 | } else { | 424 | } else { | ||
| 405 | tabs = append(tabs, th.FG256(th.Muted, " "+name+" ")) | 425 | tabs = append(tabs, th.FG256(th.Muted, " "+name+" ")) | ||
| 406 | } | 426 | } | ||
| 407 | } | 427 | } | ||
| 408 | - | return fitTimelineLine(strings.Join(tabs, " "), width) | 428 | + | return strings.Join(tabs, " ") |
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | func timelineTabsWidth(start, end int) int { | |||
| 432 | + | width := 2 * (end - start - 1) | |||
| 433 | + | for idx := start; idx < end; idx++ { | |||
| 434 | + | width += runewidth.StringWidth(timelineTabNames[idx]) + 2 | |||
| 435 | + | } | |||
| 436 | + | return width | |||
| 409 | } | 437 | } | ||
| 410 | 438 | ||||
| 411 | func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, maxRows int) []string { | 439 | func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, maxRows int) []string { | ||
M packages/agent/modes/timeline_view_test.go +42 -0
| @@ -9,6 +9,8 @@ import ( | |||
| 9 | 9 | "testing" | |
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | + | "github.com/mattn/go-runewidth" | |
| 13 | + | ||
| 12 | 14 | "github.com/patriceckhart/zot/packages/provider" | |
| 13 | 15 | "github.com/patriceckhart/zot/packages/tui" | |
| 14 | 16 | ) | |
| @@ -72,6 +74,32 @@ func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) { | |||
| 72 | 74 | } | |
| 73 | 75 | } | |
| 74 | 76 | ||
| 77 | + | func TestTimelineNavigationKeysMoveSelection(t *testing.T) { | |
| 78 | + | data := timelineTestData() | |
| 79 | + | view := newTimelineView() | |
| 80 | + | view.Open(data) | |
| 81 | + | last := len(buildTimelineEntries(data)) - 1 | |
| 82 | + | if view.cursor != last { | |
| 83 | + | t.Fatalf("initial cursor = %d, want %d", view.cursor, last) | |
| 84 | + | } | |
| 85 | + | view.HandleKey(tui.Key{Kind: tui.KeyUp}, data) | |
| 86 | + | if view.cursor != last-1 { | |
| 87 | + | t.Fatalf("cursor after up = %d, want %d", view.cursor, last-1) | |
| 88 | + | } | |
| 89 | + | view.HandleKey(tui.Key{Kind: tui.KeyDown}, data) | |
| 90 | + | if view.cursor != last { | |
| 91 | + | t.Fatalf("cursor after down = %d, want %d", view.cursor, last) | |
| 92 | + | } | |
| 93 | + | view.HandleKey(tui.Key{Kind: tui.KeyPageUp}, data) | |
| 94 | + | if view.cursor != 0 { | |
| 95 | + | t.Fatalf("cursor after page up = %d, want 0", view.cursor) | |
| 96 | + | } | |
| 97 | + | view.HandleKey(tui.Key{Kind: tui.KeyPageDown}, data) | |
| 98 | + | if view.cursor != last { | |
| 99 | + | t.Fatalf("cursor after page down = %d, want %d", view.cursor, last) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 75 | 103 | func TestTimelineSearchAndTabs(t *testing.T) { | |
| 76 | 104 | data := timelineTestData() | |
| 77 | 105 | view := newTimelineView() | |
| @@ -94,6 +122,20 @@ func TestTimelineSearchAndTabs(t *testing.T) { | |||
| 94 | 122 | } | |
| 95 | 123 | } | |
| 96 | 124 | ||
| 125 | + | func TestTimelineTabsKeepSelectedTabVisible(t *testing.T) { | |
| 126 | + | const width = 30 | |
| 127 | + | for selected, name := range timelineTabNames { | |
| 128 | + | rendered := renderTimelineTabs(tui.Theme{}, width, selected) | |
| 129 | + | plain := stripANSIBytes(rendered) | |
| 130 | + | if !strings.Contains(plain, "["+name+"]") { | |
| 131 | + | t.Errorf("selected tab %q not visible in %q", name, plain) | |
| 132 | + | } | |
| 133 | + | if got := runewidth.StringWidth(plain); got > width { | |
| 134 | + | t.Errorf("tabs width = %d, want <= %d: %q", got, width, plain) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 97 | 139 | func TestTimelineRenderShowsContextAndControls(t *testing.T) { | |
| 98 | 140 | data := timelineTestData() | |
| 99 | 141 | if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 { | |
| @@ -9,6 +9,8 @@ import ( | |||||
| 9 | "testing" | 9 | "testing" | ||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | + | "github.com/mattn/go-runewidth" | |||
| 13 | + | ||||
| 12 | "github.com/patriceckhart/zot/packages/provider" | 14 | "github.com/patriceckhart/zot/packages/provider" | ||
| 13 | "github.com/patriceckhart/zot/packages/tui" | 15 | "github.com/patriceckhart/zot/packages/tui" | ||
| 14 | ) | 16 | ) | ||
| @@ -72,6 +74,32 @@ func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) { | |||||
| 72 | } | 74 | } | ||
| 73 | } | 75 | } | ||
| 74 | 76 | ||||
| 77 | + | func TestTimelineNavigationKeysMoveSelection(t *testing.T) { | |||
| 78 | + | data := timelineTestData() | |||
| 79 | + | view := newTimelineView() | |||
| 80 | + | view.Open(data) | |||
| 81 | + | last := len(buildTimelineEntries(data)) - 1 | |||
| 82 | + | if view.cursor != last { | |||
| 83 | + | t.Fatalf("initial cursor = %d, want %d", view.cursor, last) | |||
| 84 | + | } | |||
| 85 | + | view.HandleKey(tui.Key{Kind: tui.KeyUp}, data) | |||
| 86 | + | if view.cursor != last-1 { | |||
| 87 | + | t.Fatalf("cursor after up = %d, want %d", view.cursor, last-1) | |||
| 88 | + | } | |||
| 89 | + | view.HandleKey(tui.Key{Kind: tui.KeyDown}, data) | |||
| 90 | + | if view.cursor != last { | |||
| 91 | + | t.Fatalf("cursor after down = %d, want %d", view.cursor, last) | |||
| 92 | + | } | |||
| 93 | + | view.HandleKey(tui.Key{Kind: tui.KeyPageUp}, data) | |||
| 94 | + | if view.cursor != 0 { | |||
| 95 | + | t.Fatalf("cursor after page up = %d, want 0", view.cursor) | |||
| 96 | + | } | |||
| 97 | + | view.HandleKey(tui.Key{Kind: tui.KeyPageDown}, data) | |||
| 98 | + | if view.cursor != last { | |||
| 99 | + | t.Fatalf("cursor after page down = %d, want %d", view.cursor, last) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 75 | func TestTimelineSearchAndTabs(t *testing.T) { | 103 | func TestTimelineSearchAndTabs(t *testing.T) { | ||
| 76 | data := timelineTestData() | 104 | data := timelineTestData() | ||
| 77 | view := newTimelineView() | 105 | view := newTimelineView() | ||
| @@ -94,6 +122,20 @@ func TestTimelineSearchAndTabs(t *testing.T) { | |||||
| 94 | } | 122 | } | ||
| 95 | } | 123 | } | ||
| 96 | 124 | ||||
| 125 | + | func TestTimelineTabsKeepSelectedTabVisible(t *testing.T) { | |||
| 126 | + | const width = 30 | |||
| 127 | + | for selected, name := range timelineTabNames { | |||
| 128 | + | rendered := renderTimelineTabs(tui.Theme{}, width, selected) | |||
| 129 | + | plain := stripANSIBytes(rendered) | |||
| 130 | + | if !strings.Contains(plain, "["+name+"]") { | |||
| 131 | + | t.Errorf("selected tab %q not visible in %q", name, plain) | |||
| 132 | + | } | |||
| 133 | + | if got := runewidth.StringWidth(plain); got > width { | |||
| 134 | + | t.Errorf("tabs width = %d, want <= %d: %q", got, width, plain) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 97 | func TestTimelineRenderShowsContextAndControls(t *testing.T) { | 139 | func TestTimelineRenderShowsContextAndControls(t *testing.T) { | ||
| 98 | data := timelineTestData() | 140 | data := timelineTestData() | ||
| 99 | if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 { | 141 | if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 { | ||
M packages/tui/input.go +10 -1
| @@ -165,12 +165,21 @@ func (r *Reader) readEscape() (Key, error) { | |||
| 165 | 165 | case '[': | |
| 166 | 166 | return r.readCSI() | |
| 167 | 167 | case 'O': | |
| 168 | - | // SS3 sequences (function keys in some terminals). | |
| 168 | + | // SS3 sequences. Terminals in application cursor mode use these | |
| 169 | + | // for arrows, while some terminals also use them for Home and End. | |
| 169 | 170 | c, err := r.src() | |
| 170 | 171 | if err != nil { | |
| 171 | 172 | return Key{}, err | |
| 172 | 173 | } | |
| 173 | 174 | switch c { | |
| 175 | + | case 'A': | |
| 176 | + | return Key{Kind: KeyUp}, nil | |
| 177 | + | case 'B': | |
| 178 | + | return Key{Kind: KeyDown}, nil | |
| 179 | + | case 'C': | |
| 180 | + | return Key{Kind: KeyRight}, nil | |
| 181 | + | case 'D': | |
| 182 | + | return Key{Kind: KeyLeft}, nil | |
| 174 | 183 | case 'H': | |
| 175 | 184 | return Key{Kind: KeyHome}, nil | |
| 176 | 185 | case 'F': | |
| @@ -165,12 +165,21 @@ func (r *Reader) readEscape() (Key, error) { | |||||
| 165 | case '[': | 165 | case '[': | ||
| 166 | return r.readCSI() | 166 | return r.readCSI() | ||
| 167 | case 'O': | 167 | case 'O': | ||
| 168 | - | // SS3 sequences (function keys in some terminals). | 168 | + | // SS3 sequences. Terminals in application cursor mode use these |
| 169 | + | // for arrows, while some terminals also use them for Home and End. | |||
| 169 | c, err := r.src() | 170 | c, err := r.src() | ||
| 170 | if err != nil { | 171 | if err != nil { | ||
| 171 | return Key{}, err | 172 | return Key{}, err | ||
| 172 | } | 173 | } | ||
| 173 | switch c { | 174 | switch c { | ||
| 175 | + | case 'A': | |||
| 176 | + | return Key{Kind: KeyUp}, nil | |||
| 177 | + | case 'B': | |||
| 178 | + | return Key{Kind: KeyDown}, nil | |||
| 179 | + | case 'C': | |||
| 180 | + | return Key{Kind: KeyRight}, nil | |||
| 181 | + | case 'D': | |||
| 182 | + | return Key{Kind: KeyLeft}, nil | |||
| 174 | case 'H': | 183 | case 'H': | ||
| 175 | return Key{Kind: KeyHome}, nil | 184 | return Key{Kind: KeyHome}, nil | ||
| 176 | case 'F': | 185 | case 'F': | ||
M packages/tui/input_test.go +17 -0
| @@ -91,6 +91,23 @@ func TestReaderParsesCSIUTabAndBackspace(t *testing.T) { | |||
| 91 | 91 | } | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | + | func TestReaderParsesSS3CursorKeys(t *testing.T) { | |
| 95 | + | cases := []struct { | |
| 96 | + | seq string | |
| 97 | + | want KeyKind | |
| 98 | + | }{ | |
| 99 | + | {"\x1bOA", KeyUp}, | |
| 100 | + | {"\x1bOB", KeyDown}, | |
| 101 | + | {"\x1bOC", KeyRight}, | |
| 102 | + | {"\x1bOD", KeyLeft}, | |
| 103 | + | } | |
| 104 | + | for _, tc := range cases { | |
| 105 | + | if k := readKey(t, tc.seq); k.Kind != tc.want { | |
| 106 | + | t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, k.Kind, tc.want) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 94 | 111 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | |
| 95 | 112 | cases := []struct { | |
| 96 | 113 | seq string | |
| @@ -91,6 +91,23 @@ func TestReaderParsesCSIUTabAndBackspace(t *testing.T) { | |||||
| 91 | } | 91 | } | ||
| 92 | } | 92 | } | ||
| 93 | 93 | ||||
| 94 | + | func TestReaderParsesSS3CursorKeys(t *testing.T) { | |||
| 95 | + | cases := []struct { | |||
| 96 | + | seq string | |||
| 97 | + | want KeyKind | |||
| 98 | + | }{ | |||
| 99 | + | {"\x1bOA", KeyUp}, | |||
| 100 | + | {"\x1bOB", KeyDown}, | |||
| 101 | + | {"\x1bOC", KeyRight}, | |||
| 102 | + | {"\x1bOD", KeyLeft}, | |||
| 103 | + | } | |||
| 104 | + | for _, tc := range cases { | |||
| 105 | + | if k := readKey(t, tc.seq); k.Kind != tc.want { | |||
| 106 | + | t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, k.Kind, tc.want) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 94 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | 111 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | ||
| 95 | cases := []struct { | 112 | cases := []struct { | ||
| 96 | seq string | 113 | seq string | ||