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}