v0.50.1 a637010

timeline_view_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"os"
7	"path/filepath"
8	"runtime"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/mattn/go-runewidth"
14
15	"github.com/bnema/zut/packages/core"
16	"github.com/bnema/zut/packages/provider"
17	"github.com/bnema/zut/packages/tui"
18)
19
20func timelineTestData() timelineData {
21	started := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
22	finished := started.Add(1250 * time.Millisecond)
23	return timelineData{
24		System: "You are zot.",
25		Tools: []provider.Tool{{
26			Name: "bash", Schema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}}}`),
27		}},
28		Messages: []provider.Message{
29			{Role: provider.RoleUser, Time: started.Add(-time.Second), Content: []provider.Content{provider.TextBlock{Text: "list files"}}},
30			{Role: provider.RoleAssistant, Time: started, Content: []provider.Content{
31				provider.TextBlock{Text: "I will inspect them."},
32				provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls -la"}`)},
33			}},
34			{Role: provider.RoleTool, Time: finished, Content: []provider.Content{
35				provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "README.md"}}},
36			}},
37		},
38		ContextUsed: 1200,
39		ContextMax:  200000,
40	}
41}
42
43func TestSessionTimelineOpensAlternateView(t *testing.T) {
44	interactive := &Interactive{timeline: newTimelineView()}
45	interactive.runSlash(context.Background(), "/session timeline")
46	if !interactive.timeline.Active() {
47		t.Fatal("/session timeline did not open the timeline view")
48	}
49}
50
51func TestSessionPickerIncludesTimeline(t *testing.T) {
52	interactive := &Interactive{sessionOpsDialog: newSessionOpsDialog()}
53	interactive.openSessionOpsDialog()
54	if len(interactive.sessionOpsDialog.items) == 0 || interactive.sessionOpsDialog.items[0].action != "timeline" {
55		t.Fatalf("session actions = %+v, want timeline first", interactive.sessionOpsDialog.items)
56	}
57}
58
59func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) {
60	entries := buildTimelineEntries(timelineTestData())
61	if len(entries) != 4 {
62		t.Fatalf("entries = %d, want system, user, assistant, and tool", len(entries))
63	}
64	tool := entries[3]
65	if tool.Kind != "tool" || tool.ToolCall != "call-1" {
66		t.Fatalf("tool entry = %+v", tool)
67	}
68	if !strings.Contains(tool.Payload, "ls -la") || !strings.Contains(tool.Result, "README.md") {
69		t.Fatalf("tool details missing: %+v", tool)
70	}
71	if !strings.Contains(tool.Schema, "command") {
72		t.Fatalf("tool schema missing: %q", tool.Schema)
73	}
74	if tool.Duration != "1.25s" {
75		t.Fatalf("duration = %q, want 1.25s", tool.Duration)
76	}
77}
78
79func TestTimelineReasoningIsReadableAndScrollable(t *testing.T) {
80	data := timelineTestData()
81	data.Messages[1].Content = append([]provider.Content{provider.ReasoningBlock{
82		Summary: "**First** step\n" + strings.Repeat("A long line of thought. ", 25) + "\nFinal insight",
83	}}, data.Messages[1].Content...)
84	entries := buildTimelineEntries(data)
85	if len(entries) != 5 || entries[2].Kind != "reasoning" || entries[3].Kind != "assistant" {
86		t.Fatalf("reasoning entries = %+v", entries)
87	}
88	if strings.Contains(entries[3].Summary, "First") {
89		t.Fatalf("reasoning duplicated in assistant: %+v", entries[3])
90	}
91	view := newTimelineView()
92	view.Open(data)
93	view.cursor = 2
94	rows := timelineDetailRows(tui.Dark, 40, entries[2], 0)
95	if len(rows) < 4 || !strings.Contains(stripANSIBytes(strings.Join(rows, "\n")), "Final insight") {
96		t.Fatalf("reasoning detail not complete: %q", rows)
97	}
98	for _, row := range rows {
99		if runewidth.StringWidth(stripANSIBytes(row)) > 40 {
100			t.Fatalf("row exceeds width: %q", row)
101		}
102	}
103	first := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n")
104	view.HandleKey(tui.Key{Kind: tui.KeyEnd}, data)
105	last := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n")
106	if view.detailTop == 0 || first == last || !strings.Contains(stripANSIBytes(last), "Final insight") {
107		t.Fatalf("detail did not scroll to end: top=%d, output=%q", view.detailTop, last)
108	}
109	view.HandleKey(tui.Key{Kind: tui.KeyLeft}, data)
110	if view.detailTop == 0 {
111		t.Fatal("left did not scroll detail up")
112	}
113	view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
114	if view.detailTop != 0 {
115		t.Fatal("changing tab did not reset detail scroll")
116	}
117}
118
119func TestTimelineNavigationKeysMoveSelection(t *testing.T) {
120	data := timelineTestData()
121	view := newTimelineView()
122	view.Open(data)
123	last := len(buildTimelineEntries(data)) - 1
124	if view.cursor != last {
125		t.Fatalf("initial cursor = %d, want %d", view.cursor, last)
126	}
127	view.HandleKey(tui.Key{Kind: tui.KeyUp}, data)
128	if view.cursor != last-1 {
129		t.Fatalf("cursor after up = %d, want %d", view.cursor, last-1)
130	}
131	view.HandleKey(tui.Key{Kind: tui.KeyDown}, data)
132	if view.cursor != last {
133		t.Fatalf("cursor after down = %d, want %d", view.cursor, last)
134	}
135	view.HandleKey(tui.Key{Kind: tui.KeyPageUp}, data)
136	if view.cursor != 0 {
137		t.Fatalf("cursor after page up = %d, want 0", view.cursor)
138	}
139	view.HandleKey(tui.Key{Kind: tui.KeyPageDown}, data)
140	if view.cursor != last {
141		t.Fatalf("cursor after page down = %d, want %d", view.cursor, last)
142	}
143}
144
145func TestTimelineSearchAndTabs(t *testing.T) {
146	data := timelineTestData()
147	view := newTimelineView()
148	view.Open(data)
149	view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}, data)
150	for _, r := range "readme" {
151		view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}, data)
152	}
153	matches := filterTimelineEntries(buildTimelineEntries(data), view.filter)
154	if len(matches) != 1 || matches[0].Kind != "tool" {
155		t.Fatalf("matches = %+v, want tool result", matches)
156	}
157	view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
158	if view.tab != 1 {
159		t.Fatalf("tab = %d, want payload tab", view.tab)
160	}
161	view.HandleKey(tui.Key{Kind: tui.KeyEsc}, data)
162	if view.filter != "" || !view.Active() {
163		t.Fatal("first esc should clear search without closing timeline")
164	}
165}
166
167func TestTimelineTabsKeepSelectedTabVisible(t *testing.T) {
168	const width = 30
169	for selected, name := range timelineTabNames {
170		rendered := renderTimelineTabs(tui.Theme{}, width, selected)
171		plain := stripANSIBytes(rendered)
172		if !strings.Contains(plain, "["+name+"]") {
173			t.Errorf("selected tab %q not visible in %q", name, plain)
174		}
175		if got := runewidth.StringWidth(plain); got > width {
176			t.Errorf("tabs width = %d, want <= %d: %q", got, width, plain)
177		}
178	}
179}
180
181func TestTimelineRenderShowsContextAndControls(t *testing.T) {
182	data := timelineTestData()
183	if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 {
184		t.Fatalf("context rows = %d, want usage and composition only", len(contextLines))
185	}
186	view := newTimelineView()
187	view.Open(data)
188	rendered := strings.Join(view.Render(tui.Theme{}, 100, 40, data), "\n")
189	for _, want := range []string{"timeline", "context 1.2k / 200.0k", "estimated composition", "ctrl+e export", "TOOL bash"} {
190		if !strings.Contains(rendered, want) {
191			t.Fatalf("render missing %q:\n%s", want, rendered)
192		}
193	}
194}
195
196func TestTimelineExportOmitsImageDataAndUsesPrivatePermissions(t *testing.T) {
197	data := timelineTestData()
198	data.Messages = append(data.Messages, provider.Message{
199		Role:    provider.RoleUser,
200		Content: []provider.Content{provider.ImageBlock{MimeType: "image/png", Data: []byte("secret-image-bytes")}},
201	})
202	view := newTimelineView()
203	path, err := view.Export(t.TempDir(), data)
204	if err != nil {
205		t.Fatal(err)
206	}
207	contents, err := os.ReadFile(path)
208	if err != nil {
209		t.Fatal(err)
210	}
211	if strings.Contains(string(contents), "secret-image-bytes") {
212		t.Fatal("export embedded image data")
213	}
214	if !strings.Contains(string(contents), "image/png") {
215		t.Fatal("export omitted image metadata")
216	}
217	info, err := os.Stat(path)
218	if err != nil {
219		t.Fatal(err)
220	}
221	if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
222		t.Fatalf("permissions = %o, want 600", info.Mode().Perm())
223	}
224}
225
226func TestTimelinePairsReusedToolCallIDs(t *testing.T) {
227	t0 := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
228	call := func(cmd string) provider.Content {
229		return provider.ToolCallBlock{ID: "reused", Name: "bash", Arguments: json.RawMessage(`{"command":"` + cmd + `"}`)}
230	}
231	result := func(text string, isErr bool) provider.Message {
232		return provider.Message{Role: provider.RoleTool, Time: t0.Add(time.Second), Content: []provider.Content{
233			provider.ToolResultBlock{CallID: "reused", IsError: isErr, Content: []provider.Content{provider.TextBlock{Text: text}}},
234		}}
235	}
236	data := timelineData{Messages: []provider.Message{
237		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "go"}}},
238		{Role: provider.RoleAssistant, Time: t0, Content: []provider.Content{call("first")}},
239		result("first-output", false),
240		{Role: provider.RoleAssistant, Time: t0.Add(2 * time.Second), Content: []provider.Content{call("second")}},
241		result("second-output", true),
242		{Role: provider.RoleAssistant, Time: t0.Add(4 * time.Second), Content: []provider.Content{call("third")}},
243	}}
244	var tools []timelineEntry
245	for _, e := range buildTimelineEntries(data) {
246		if e.Kind == "tool" {
247			tools = append(tools, e)
248		}
249	}
250	if len(tools) != 3 {
251		t.Fatalf("tool entries = %d, want 3: %+v", len(tools), tools)
252	}
253	if !strings.Contains(tools[0].Result, "first-output") || tools[0].IsError {
254		t.Fatalf("first call paired wrongly: %+v", tools[0])
255	}
256	if !strings.Contains(tools[1].Result, "second-output") || !tools[1].IsError {
257		t.Fatalf("second call paired wrongly: %+v", tools[1])
258	}
259	if tools[2].Result != "" || timelineStatus(tools[2]) != "result unavailable" {
260		t.Fatalf("third call must stay unpaired: %+v", tools[2])
261	}
262}
263
264func TestTimelineKeepsOrphanResultVisible(t *testing.T) {
265	data := timelineData{Messages: []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
266		provider.ToolResultBlock{CallID: "gone", Content: []provider.Content{provider.TextBlock{Text: "late"}}},
267	}}}}
268	entries := buildTimelineEntries(data)
269	if len(entries) != 1 || !strings.Contains(entries[0].Result, "late") || !strings.Contains(entries[0].Summary, "unpaired") {
270		t.Fatalf("orphan result = %+v", entries)
271	}
272}
273
274func TestTimelineNeverShowsOrExportsEncryptedReasoningOrSignatures(t *testing.T) {
275	data := timelineTestData()
276	data.Messages[1].Content = append([]provider.Content{
277		provider.ReasoningBlock{ID: "r1", Summary: "visible summary", Encrypted: "ENCRYPTED-SECRET-BLOB"},
278		provider.TextBlock{Text: "answer", ThoughtSignature: "SIGNATURE-SECRET"},
279	}, data.Messages[1].Content...)
280	view := newTimelineView()
281	view.Open(data)
282	var shown strings.Builder
283	for tab := 0; tab < timelineTabCount; tab++ {
284		view.tab = tab
285		for cursor := range buildTimelineEntries(data) {
286			view.cursor = cursor
287			shown.WriteString(strings.Join(view.Render(tui.Dark, 100, 40, data), "\n"))
288		}
289	}
290	path, err := view.Export(t.TempDir(), data)
291	if err != nil {
292		t.Fatal(err)
293	}
294	exported, err := os.ReadFile(path)
295	if err != nil {
296		t.Fatal(err)
297	}
298	for _, secret := range []string{"ENCRYPTED-SECRET-BLOB", "SIGNATURE-SECRET"} {
299		if strings.Contains(shown.String(), secret) || strings.Contains(string(exported), secret) {
300			t.Fatalf("%s leaked into the timeline", secret)
301		}
302	}
303	if !strings.Contains(string(exported), "visible summary") {
304		t.Fatal("reasoning summary missing from export")
305	}
306}
307
308func TestTimelineExportIsExclusiveAndPrivate(t *testing.T) {
309	if runtime.GOOS == "windows" {
310		t.Skip("POSIX permissions")
311	}
312	dir := filepath.Join(t.TempDir(), "exports")
313	view := newTimelineView()
314	path, err := view.Export(dir, timelineTestData())
315	if err != nil {
316		t.Fatal(err)
317	}
318	if info, err := os.Stat(dir); err != nil || info.Mode().Perm() != 0o700 {
319		t.Fatalf("export dir mode = %v (%v), want 700", info.Mode().Perm(), err)
320	}
321	// A pre-planted symlink at the target name must not be followed.
322	victim := filepath.Join(t.TempDir(), "victim")
323	if err := os.WriteFile(victim, []byte("keep"), 0o600); err != nil {
324		t.Fatal(err)
325	}
326	if err := os.Remove(path); err != nil {
327		t.Fatal(err)
328	}
329	if err := os.Symlink(victim, path); err != nil {
330		t.Skip("symlinks unavailable")
331	}
332	if _, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600); err == nil {
333		t.Fatal("exclusive open unexpectedly succeeded on an existing symlink")
334	}
335	if got, _ := os.ReadFile(victim); string(got) != "keep" {
336		t.Fatalf("victim overwritten: %q", got)
337	}
338}
339
340func TestTimelineSnapshotHidesHostInternalMessages(t *testing.T) {
341	ag := core.NewAgent(nil, "m", "system prompt", nil)
342	ag.SetMessages([]provider.Message{
343		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "real prompt"}}},
344		{Role: provider.RoleUser, Meta: map[string]string{goalContinueMetaKey: "true"}, Content: []provider.Content{provider.TextBlock{Text: "hidden goal continuation"}}},
345		{Role: provider.RoleUser, Meta: map[string]string{autoCompactContinueMetaKey: "true"}, Content: []provider.Content{provider.TextBlock{Text: "hidden compact continuation"}}},
346		{Role: provider.RoleUser, Meta: map[string]string{core.RepetitionGuardMetaKey: "true"}, Content: []provider.Content{provider.TextBlock{Text: "hidden repetition guard"}}},
347	})
348	i := &Interactive{agent: ag, timeline: newTimelineView()}
349	i.mu.Lock()
350	data := i.timelineDataLocked()
351	i.mu.Unlock()
352	if data.System != "system prompt" || len(data.Messages) != 1 {
353		t.Fatalf("snapshot = system %q, %d messages", data.System, len(data.Messages))
354	}
355	for _, e := range buildTimelineEntries(data) {
356		if strings.Contains(e.Summary, "hidden") {
357			t.Fatalf("internal message leaked: %+v", e)
358		}
359	}
360}
361
362func TestTimelineCacheRebuildsOnlyWhenTranscriptChanges(t *testing.T) {
363	ag := core.NewAgent(nil, "m", "sys", nil)
364	ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}})
365	i := &Interactive{agent: ag, timeline: newTimelineView()}
366	i.mu.Lock()
367	data := i.timelineDataLocked()
368	i.timeline.Open(data)
369	first := i.timeline.entries(data)
370	again := i.timeline.entries(i.timelineDataLocked())
371	i.mu.Unlock()
372	if &first[0] != &again[0] {
373		t.Fatal("unchanged transcript rebuilt the timeline")
374	}
375	ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "two"}}}})
376	i.mu.Lock()
377	updated := i.timeline.entries(i.timelineDataLocked())
378	i.mu.Unlock()
379	if !strings.Contains(updated[len(updated)-1].Summary, "two") {
380		t.Fatalf("stale timeline after transcript change: %+v", updated)
381	}
382}
383
384func TestTimelineRendersInsideFloatingPane(t *testing.T) {
385	data := timelineTestData()
386	i := &Interactive{timeline: newTimelineView()}
387	i.timeline.Open(data)
388	pane := tui.FloatingPaneMaxRect(100, 30)
389	lines := padDialogFrame(i.timeline.Render(tui.Dark, pane.ContentWidth(), pane.ContentHeight(), data))
390	title, body, _ := floatingDialogBody(lines)
391	if title != "timeline" {
392		t.Fatalf("pane title = %q, want timeline", title)
393	}
394	if len(body) > pane.ContentHeight() {
395		t.Fatalf("body has %d rows, pane content height is %d", len(body), pane.ContentHeight())
396	}
397	for _, row := range body {
398		if w := runewidth.StringWidth(stripANSIBytes(row)); w > pane.ContentWidth() {
399			t.Fatalf("row width %d exceeds pane %d: %q", w, pane.ContentWidth(), row)
400		}
401	}
402	// A narrow drawer must still fit.
403	narrow := tui.FloatingPaneMaxRect(40, 12)
404	for _, row := range i.timeline.Render(tui.Dark, narrow.ContentWidth(), narrow.ContentHeight(), data) {
405		if w := runewidth.StringWidth(stripANSIBytes(row)); w > narrow.ContentWidth() {
406			t.Fatalf("narrow row width %d exceeds %d: %q", w, narrow.ContentWidth(), row)
407		}
408	}
409}
410
411func TestTimelineKeysAreOwnedByTimelineAndCtrlCCloses(t *testing.T) {
412	i := NewInteractive(InteractiveConfig{Agent: core.NewAgent(nil, "m", "", nil)})
413	i.openTimeline()
414	if !i.timeline.Active() || !i.confirmChildActive() {
415		t.Fatal("timeline is not registered as an active overlay")
416	}
417	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'x'})
418	if i.ed.Value() != "" {
419		t.Fatalf("key leaked to the main editor: %q", i.ed.Value())
420	}
421	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlC})
422	if i.timeline.Active() {
423		t.Fatal("ctrl+c did not close the timeline")
424	}
425}
426
427func hostileTimelineData() timelineData {
428	const hostile = "a\x1b]0;evil-title\x07b\x1b[31mred\x1b[0m\tc\rd\x1b"
429	data := timelineData{
430		System: "sys " + hostile + "\nsecond\r\nthird\rfourth",
431		Tools:  []provider.Tool{{Name: "bash", Schema: json.RawMessage(`{"type":"object"}`)}},
432		Messages: []provider.Message{
433			{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: hostile + "\nmore"}}},
434			{Role: provider.RoleAssistant, Content: []provider.Content{
435				provider.ReasoningBlock{Summary: "think " + hostile},
436				provider.ToolCallBlock{ID: "c1", Name: "bash", Arguments: json.RawMessage(`{"command":"echo \u001b]0;evil-title\u0007 \t x\r y"}`)},
437			}},
438			{Role: provider.RoleTool, Content: []provider.Content{
439				provider.ToolResultBlock{CallID: "c1", Content: []provider.Content{provider.TextBlock{Text: hostile}}},
440			}},
441		},
442	}
443	return data
444}
445
446func TestTimelineSanitizesUntrustedText(t *testing.T) {
447	data := hostileTimelineData()
448	view := newTimelineView()
449	view.Open(data)
450	view.searching = true
451	view.filter = "q\x1b]0;evil-title\x07\tz"
452	entries := buildTimelineEntries(data)
453	view.filter = ""
454	for _, width := range []int{20, 60, 120} {
455		for tab := 0; tab < timelineTabCount; tab++ {
456			view.tab = tab
457			for cursor := range entries {
458				view.cursor = cursor
459				out := strings.Join(view.Render(tui.Dark, width, 30, data), "\n")
460				plain := stripANSIBytes(out)
461				// JSON payloads show control bytes as inert \uXXXX text, so
462				// only raw controls are checked there; the summary tab and the
463				// list rows must have the OSC payload removed entirely.
464				bad := []string{"\r", "\t", "\a", "\x1b"}
465				if tab == 0 {
466					bad = append(bad, "evil-title")
467				}
468				for _, b := range bad {
469					if strings.Contains(out, b) && b != "\x1b" || b == "\x1b" && strings.Contains(plain, b) {
470						t.Fatalf("width %d tab %d entry %d leaked %q:\n%q", width, tab, cursor, b, plain)
471					}
472				}
473				for _, row := range strings.Split(plain, "\n") {
474					if w := runewidth.StringWidth(row); w > width {
475						t.Fatalf("width %d tab %d entry %d: row width %d: %q", width, tab, cursor, w, row)
476					}
477				}
478			}
479		}
480	}
481	// A typed or pasted filter cannot inject control sequences either.
482	view.searching = true
483	view.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "x\x1b]0;evil-title\x07\ty"}, data)
484	view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\t'}, data)
485	view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\r'}, data)
486	shown := stripANSIBytes(strings.Join(view.Render(tui.Dark, 80, 30, data), "\n"))
487	if strings.ContainsAny(view.filter, "\x1b\a\t\r") || strings.Contains(shown, "evil-title\x07") || strings.ContainsAny(shown, "\a\t\r") {
488		t.Fatalf("filter leaked control text: filter=%q", view.filter)
489	}
490}
491
492func TestTimelineBodyFitsPaneAtEveryHeight(t *testing.T) {
493	data := timelineTestData()
494	data.Messages = append(data.Messages, provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{
495		provider.ReasoningBlock{Summary: strings.Repeat("long reasoning line\n", 40)},
496	}})
497	view := newTimelineView()
498	view.Open(data)
499	view.notice = "exported to ~/x.json"
500	for _, searching := range []bool{false, true} {
501		view.searching = searching
502		for height := 1; height <= 40; height++ {
503			for _, width := range []int{12, 30, 78, 120} {
504				for tab := 0; tab < timelineTabCount; tab++ {
505					view.tab = tab
506					lines := padDialogFrame(view.Render(tui.Dark, width, height, data))
507					_, body, _ := floatingDialogBody(lines)
508					if len(body) > height {
509						t.Fatalf("height %d width %d tab %d: body has %d rows", height, width, tab, len(body))
510					}
511					for _, row := range body {
512						if w := runewidth.StringWidth(stripANSIBytes(row)); w > width {
513							t.Fatalf("height %d width %d: row width %d: %q", height, width, w, row)
514						}
515					}
516				}
517			}
518		}
519	}
520	// Tiny panes still show the selected event before optional chrome.
521	view.searching = false
522	view.tab = 0
523	lines := padDialogFrame(view.Render(tui.Dark, 80, 3, data))
524	_, body, _ := floatingDialogBody(lines)
525	if !strings.Contains(stripANSIBytes(strings.Join(body, "\n")), "REASONING") {
526		t.Fatalf("selected event missing from a 3-row pane: %q", body)
527	}
528}
529
530func TestTimelineSnapshotCacheTracksPromptContentAndSkipsCopies(t *testing.T) {
531	ag := core.NewAgent(nil, "m", "prompt-AAAA", core.Registry{})
532	ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}})
533	i := &Interactive{agent: ag, timeline: newTimelineView()}
534	snapshot := func() timelineData {
535		i.mu.Lock()
536		defer i.mu.Unlock()
537		return i.timelineDataLocked()
538	}
539
540	first := snapshot()
541	again := snapshot()
542	if &first.Messages[0] != &again.Messages[0] || !again.Est.ok {
543		t.Fatal("unchanged transcript and prompt were re-copied")
544	}
545	// A same-length system prompt edit changes neither the transcript
546	// revision nor the prompt length, yet must refresh the snapshot.
547	ag.SetSystemPrompt("prompt-BBBB")
548	if edited := snapshot(); edited.System != "prompt-BBBB" {
549		t.Fatalf("stale system prompt after same-length edit: %q", edited.System)
550	}
551	// Same for a tool whose schema changes at equal length.
552	ag.SetTools(core.Registry{"t": schemaTool{name: "t", schema: `{"a":1}`}})
553	if s := snapshot(); len(s.Tools) != 1 || string(s.Tools[0].Schema) != `{"a":1}` {
554		t.Fatalf("tools = %+v", s.Tools)
555	}
556	ag.SetTools(core.Registry{"t": schemaTool{name: "t", schema: `{"a":2}`}})
557	if s := snapshot(); string(s.Tools[0].Schema) != `{"a":2}` {
558		t.Fatalf("stale tool schema after same-length edit: %s", s.Tools[0].Schema)
559	}
560	// The context gauge is live and never cached.
561	i.mu.Lock()
562	i.lastCtxInput = 42
563	i.mu.Unlock()
564	if snapshot().ContextUsed != 42 {
565		t.Fatal("context usage was served from the cache")
566	}
567}
568
569type schemaTool struct{ name, schema string }
570
571func (t schemaTool) Name() string            { return t.name }
572func (t schemaTool) Description() string     { return "d" }
573func (t schemaTool) Schema() json.RawMessage { return json.RawMessage(t.schema) }
574func (t schemaTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
575	return core.ToolResult{}, nil
576}

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 476

↑↓ moveenter openesc close