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}