v0.4.7 2ff82fa

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/mattn/go-runewidth"
13
14	"github.com/patriceckhart/zot/packages/provider"
15	"github.com/patriceckhart/zot/packages/tui"
16)
17
18func timelineTestData() timelineData {
19	started := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
20	finished := started.Add(1250 * time.Millisecond)
21	return timelineData{
22		System: "You are zot.",
23		Tools: []provider.Tool{{
24			Name: "bash", Schema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}}}`),
25		}},
26		Messages: []provider.Message{
27			{Role: provider.RoleUser, Time: started.Add(-time.Second), Content: []provider.Content{provider.TextBlock{Text: "list files"}}},
28			{Role: provider.RoleAssistant, Time: started, Content: []provider.Content{
29				provider.TextBlock{Text: "I will inspect them."},
30				provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls -la"}`)},
31			}},
32			{Role: provider.RoleTool, Time: finished, Content: []provider.Content{
33				provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "README.md"}}},
34			}},
35		},
36		ContextUsed: 1200,
37		ContextMax:  200000,
38	}
39}
40
41func TestSessionTimelineOpensAlternateView(t *testing.T) {
42	interactive := &Interactive{timeline: newTimelineView()}
43	interactive.runSlash(context.Background(), "/session timeline")
44	if !interactive.timeline.Active() {
45		t.Fatal("/session timeline did not open the timeline view")
46	}
47}
48
49func TestSessionPickerIncludesTimeline(t *testing.T) {
50	interactive := &Interactive{sessionOpsDialog: newSessionOpsDialog()}
51	interactive.openSessionOpsDialog()
52	if len(interactive.sessionOpsDialog.items) == 0 || interactive.sessionOpsDialog.items[0].action != "timeline" {
53		t.Fatalf("session actions = %+v, want timeline first", interactive.sessionOpsDialog.items)
54	}
55}
56
57func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) {
58	entries := buildTimelineEntries(timelineTestData())
59	if len(entries) != 4 {
60		t.Fatalf("entries = %d, want system, user, assistant, and tool", len(entries))
61	}
62	tool := entries[3]
63	if tool.Kind != "tool" || tool.ToolCall != "call-1" {
64		t.Fatalf("tool entry = %+v", tool)
65	}
66	if !strings.Contains(tool.Payload, "ls -la") || !strings.Contains(tool.Result, "README.md") {
67		t.Fatalf("tool details missing: %+v", tool)
68	}
69	if !strings.Contains(tool.Schema, "command") {
70		t.Fatalf("tool schema missing: %q", tool.Schema)
71	}
72	if tool.Duration != "1.25s" {
73		t.Fatalf("duration = %q, want 1.25s", tool.Duration)
74	}
75}
76
77func TestTimelineReasoningIsReadableAndScrollable(t *testing.T) {
78	data := timelineTestData()
79	data.Messages[1].Content = append([]provider.Content{provider.ReasoningBlock{
80		Summary: "**First** step\n" + strings.Repeat("A long line of thought. ", 25) + "\nFinal insight",
81	}}, data.Messages[1].Content...)
82	entries := buildTimelineEntries(data)
83	if len(entries) != 5 || entries[2].Kind != "reasoning" || entries[3].Kind != "assistant" {
84		t.Fatalf("reasoning entries = %+v", entries)
85	}
86	if strings.Contains(entries[3].Summary, "First") {
87		t.Fatalf("reasoning duplicated in assistant: %+v", entries[3])
88	}
89	view := newTimelineView()
90	view.Open(data)
91	view.cursor = 2
92	rows := timelineDetailRows(tui.Dark, 40, entries[2], 0)
93	if len(rows) < 4 || !strings.Contains(stripANSIBytes(strings.Join(rows, "\n")), "Final insight") {
94		t.Fatalf("reasoning detail not complete: %q", rows)
95	}
96	for _, row := range rows {
97		if runewidth.StringWidth(stripANSIBytes(row)) > 40 {
98			t.Fatalf("row exceeds width: %q", row)
99		}
100	}
101	first := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n")
102	view.HandleKey(tui.Key{Kind: tui.KeyEnd}, data)
103	last := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n")
104	if view.detailTop == 0 || first == last || !strings.Contains(stripANSIBytes(last), "Final insight") {
105		t.Fatalf("detail did not scroll to end: top=%d, output=%q", view.detailTop, last)
106	}
107	view.HandleKey(tui.Key{Kind: tui.KeyLeft}, data)
108	if view.detailTop == 0 {
109		t.Fatal("left did not scroll detail up")
110	}
111	view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
112	if view.detailTop != 0 {
113		t.Fatal("changing tab did not reset detail scroll")
114	}
115}
116
117func TestTimelineNavigationKeysMoveSelection(t *testing.T) {
118	data := timelineTestData()
119	view := newTimelineView()
120	view.Open(data)
121	last := len(buildTimelineEntries(data)) - 1
122	if view.cursor != last {
123		t.Fatalf("initial cursor = %d, want %d", view.cursor, last)
124	}
125	view.HandleKey(tui.Key{Kind: tui.KeyUp}, data)
126	if view.cursor != last-1 {
127		t.Fatalf("cursor after up = %d, want %d", view.cursor, last-1)
128	}
129	view.HandleKey(tui.Key{Kind: tui.KeyDown}, data)
130	if view.cursor != last {
131		t.Fatalf("cursor after down = %d, want %d", view.cursor, last)
132	}
133	view.HandleKey(tui.Key{Kind: tui.KeyPageUp}, data)
134	if view.cursor != 0 {
135		t.Fatalf("cursor after page up = %d, want 0", view.cursor)
136	}
137	view.HandleKey(tui.Key{Kind: tui.KeyPageDown}, data)
138	if view.cursor != last {
139		t.Fatalf("cursor after page down = %d, want %d", view.cursor, last)
140	}
141}
142
143func TestTimelineSearchAndTabs(t *testing.T) {
144	data := timelineTestData()
145	view := newTimelineView()
146	view.Open(data)
147	view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}, data)
148	for _, r := range "readme" {
149		view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}, data)
150	}
151	matches := filterTimelineEntries(buildTimelineEntries(data), view.filter)
152	if len(matches) != 1 || matches[0].Kind != "tool" {
153		t.Fatalf("matches = %+v, want tool result", matches)
154	}
155	view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
156	if view.tab != 1 {
157		t.Fatalf("tab = %d, want payload tab", view.tab)
158	}
159	view.HandleKey(tui.Key{Kind: tui.KeyEsc}, data)
160	if view.filter != "" || !view.Active() {
161		t.Fatal("first esc should clear search without closing timeline")
162	}
163}
164
165func TestTimelineTabsKeepSelectedTabVisible(t *testing.T) {
166	const width = 30
167	for selected, name := range timelineTabNames {
168		rendered := renderTimelineTabs(tui.Theme{}, width, selected)
169		plain := stripANSIBytes(rendered)
170		if !strings.Contains(plain, "["+name+"]") {
171			t.Errorf("selected tab %q not visible in %q", name, plain)
172		}
173		if got := runewidth.StringWidth(plain); got > width {
174			t.Errorf("tabs width = %d, want <= %d: %q", got, width, plain)
175		}
176	}
177}
178
179func TestTimelineRenderShowsContextAndControls(t *testing.T) {
180	data := timelineTestData()
181	if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 {
182		t.Fatalf("context rows = %d, want usage and composition only", len(contextLines))
183	}
184	view := newTimelineView()
185	view.Open(data)
186	rendered := strings.Join(view.Render(tui.Theme{}, 100, 40, data), "\n")
187	for _, want := range []string{"timeline", "context 1.2k / 200.0k", "estimated composition", "ctrl+e export", "TOOL bash"} {
188		if !strings.Contains(rendered, want) {
189			t.Fatalf("render missing %q:\n%s", want, rendered)
190		}
191	}
192}
193
194func TestTimelineExportOmitsImageDataAndUsesPrivatePermissions(t *testing.T) {
195	data := timelineTestData()
196	data.Messages = append(data.Messages, provider.Message{
197		Role:    provider.RoleUser,
198		Content: []provider.Content{provider.ImageBlock{MimeType: "image/png", Data: []byte("secret-image-bytes")}},
199	})
200	view := newTimelineView()
201	path, err := view.Export(t.TempDir(), data)
202	if err != nil {
203		t.Fatal(err)
204	}
205	contents, err := os.ReadFile(path)
206	if err != nil {
207		t.Fatal(err)
208	}
209	if strings.Contains(string(contents), "secret-image-bytes") {
210		t.Fatal("export embedded image data")
211	}
212	if !strings.Contains(string(contents), "image/png") {
213		t.Fatal("export omitted image metadata")
214	}
215	info, err := os.Stat(path)
216	if err != nil {
217		t.Fatal(err)
218	}
219	if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
220		t.Fatalf("permissions = %o, want 600", info.Mode().Perm())
221	}
222}

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