v0.3.47 56b82c0

timeline_view_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"os"
7	"runtime"
8	"strings"
9	"testing"
10	"time"
11
12	"github.com/patriceckhart/zot/packages/provider"
13	"github.com/patriceckhart/zot/packages/tui"
14)
15
16func timelineTestData() timelineData {
17	started := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
18	finished := started.Add(1250 * time.Millisecond)
19	return timelineData{
20		System: "You are zot.",
21		Tools: []provider.Tool{{
22			Name: "bash", Schema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}}}`),
23		}},
24		Messages: []provider.Message{
25			{Role: provider.RoleUser, Time: started.Add(-time.Second), Content: []provider.Content{provider.TextBlock{Text: "list files"}}},
26			{Role: provider.RoleAssistant, Time: started, Content: []provider.Content{
27				provider.TextBlock{Text: "I will inspect them."},
28				provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls -la"}`)},
29			}},
30			{Role: provider.RoleTool, Time: finished, Content: []provider.Content{
31				provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "README.md"}}},
32			}},
33		},
34		ContextUsed: 1200,
35		ContextMax:  200000,
36	}
37}
38
39func TestSessionTimelineOpensAlternateView(t *testing.T) {
40	interactive := &Interactive{timeline: newTimelineView()}
41	interactive.runSlash(context.Background(), "/session timeline")
42	if !interactive.timeline.Active() {
43		t.Fatal("/session timeline did not open the timeline view")
44	}
45}
46
47func TestSessionPickerIncludesTimeline(t *testing.T) {
48	interactive := &Interactive{sessionOpsDialog: newSessionOpsDialog()}
49	interactive.openSessionOpsDialog()
50	if len(interactive.sessionOpsDialog.items) == 0 || interactive.sessionOpsDialog.items[0].action != "timeline" {
51		t.Fatalf("session actions = %+v, want timeline first", interactive.sessionOpsDialog.items)
52	}
53}
54
55func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) {
56	entries := buildTimelineEntries(timelineTestData())
57	if len(entries) != 4 {
58		t.Fatalf("entries = %d, want system, user, assistant, and tool", len(entries))
59	}
60	tool := entries[3]
61	if tool.Kind != "tool" || tool.ToolCall != "call-1" {
62		t.Fatalf("tool entry = %+v", tool)
63	}
64	if !strings.Contains(tool.Payload, "ls -la") || !strings.Contains(tool.Result, "README.md") {
65		t.Fatalf("tool details missing: %+v", tool)
66	}
67	if !strings.Contains(tool.Schema, "command") {
68		t.Fatalf("tool schema missing: %q", tool.Schema)
69	}
70	if tool.Duration != "1.25s" {
71		t.Fatalf("duration = %q, want 1.25s", tool.Duration)
72	}
73}
74
75func TestTimelineSearchAndTabs(t *testing.T) {
76	data := timelineTestData()
77	view := newTimelineView()
78	view.Open(data)
79	view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}, data)
80	for _, r := range "readme" {
81		view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}, data)
82	}
83	matches := filterTimelineEntries(buildTimelineEntries(data), view.filter)
84	if len(matches) != 1 || matches[0].Kind != "tool" {
85		t.Fatalf("matches = %+v, want tool result", matches)
86	}
87	view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
88	if view.tab != 1 {
89		t.Fatalf("tab = %d, want payload tab", view.tab)
90	}
91	view.HandleKey(tui.Key{Kind: tui.KeyEsc}, data)
92	if view.filter != "" || !view.Active() {
93		t.Fatal("first esc should clear search without closing timeline")
94	}
95}
96
97func TestTimelineRenderShowsContextAndControls(t *testing.T) {
98	data := timelineTestData()
99	if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 {
100		t.Fatalf("context rows = %d, want usage and composition only", len(contextLines))
101	}
102	view := newTimelineView()
103	view.Open(data)
104	rendered := strings.Join(view.Render(tui.Theme{}, 100, 40, data), "\n")
105	for _, want := range []string{"timeline", "context 1.2k / 200.0k", "estimated composition", "ctrl+e export", "TOOL bash"} {
106		if !strings.Contains(rendered, want) {
107			t.Fatalf("render missing %q:\n%s", want, rendered)
108		}
109	}
110}
111
112func TestTimelineExportOmitsImageDataAndUsesPrivatePermissions(t *testing.T) {
113	data := timelineTestData()
114	data.Messages = append(data.Messages, provider.Message{
115		Role:    provider.RoleUser,
116		Content: []provider.Content{provider.ImageBlock{MimeType: "image/png", Data: []byte("secret-image-bytes")}},
117	})
118	view := newTimelineView()
119	path, err := view.Export(t.TempDir(), data)
120	if err != nil {
121		t.Fatal(err)
122	}
123	contents, err := os.ReadFile(path)
124	if err != nil {
125		t.Fatal(err)
126	}
127	if strings.Contains(string(contents), "secret-image-bytes") {
128		t.Fatal("export embedded image data")
129	}
130	if !strings.Contains(string(contents), "image/png") {
131		t.Fatal("export omitted image metadata")
132	}
133	info, err := os.Stat(path)
134	if err != nil {
135		t.Fatal(err)
136	}
137	if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
138		t.Fatalf("permissions = %o, want 600", info.Mode().Perm())
139	}
140}

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 477

↑↓ moveenter openesc close