v0.3.51 8e8b1da

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 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
103func TestTimelineSearchAndTabs(t *testing.T) {
104	data := timelineTestData()
105	view := newTimelineView()
106	view.Open(data)
107	view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}, data)
108	for _, r := range "readme" {
109		view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}, data)
110	}
111	matches := filterTimelineEntries(buildTimelineEntries(data), view.filter)
112	if len(matches) != 1 || matches[0].Kind != "tool" {
113		t.Fatalf("matches = %+v, want tool result", matches)
114	}
115	view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
116	if view.tab != 1 {
117		t.Fatalf("tab = %d, want payload tab", view.tab)
118	}
119	view.HandleKey(tui.Key{Kind: tui.KeyEsc}, data)
120	if view.filter != "" || !view.Active() {
121		t.Fatal("first esc should clear search without closing timeline")
122	}
123}
124
125func 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
139func TestTimelineRenderShowsContextAndControls(t *testing.T) {
140	data := timelineTestData()
141	if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 {
142		t.Fatalf("context rows = %d, want usage and composition only", len(contextLines))
143	}
144	view := newTimelineView()
145	view.Open(data)
146	rendered := strings.Join(view.Render(tui.Theme{}, 100, 40, data), "\n")
147	for _, want := range []string{"timeline", "context 1.2k / 200.0k", "estimated composition", "ctrl+e export", "TOOL bash"} {
148		if !strings.Contains(rendered, want) {
149			t.Fatalf("render missing %q:\n%s", want, rendered)
150		}
151	}
152}
153
154func TestTimelineExportOmitsImageDataAndUsesPrivatePermissions(t *testing.T) {
155	data := timelineTestData()
156	data.Messages = append(data.Messages, provider.Message{
157		Role:    provider.RoleUser,
158		Content: []provider.Content{provider.ImageBlock{MimeType: "image/png", Data: []byte("secret-image-bytes")}},
159	})
160	view := newTimelineView()
161	path, err := view.Export(t.TempDir(), data)
162	if err != nil {
163		t.Fatal(err)
164	}
165	contents, err := os.ReadFile(path)
166	if err != nil {
167		t.Fatal(err)
168	}
169	if strings.Contains(string(contents), "secret-image-bytes") {
170		t.Fatal("export embedded image data")
171	}
172	if !strings.Contains(string(contents), "image/png") {
173		t.Fatal("export omitted image metadata")
174	}
175	info, err := os.Stat(path)
176	if err != nil {
177		t.Fatal(err)
178	}
179	if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
180		t.Fatalf("permissions = %o, want 600", info.Mode().Perm())
181	}
182}

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