1package modes
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "time"
10
11 "github.com/mattn/go-runewidth"
12
13 "github.com/patriceckhart/zot/packages/provider"
14 "github.com/patriceckhart/zot/packages/tui"
15)
16
17const timelineTabCount = 5
18
19var timelineTabNames = [timelineTabCount]string{"summary", "payload", "result", "schema", "timing"}
20
21type timelineEntry struct {
22 Kind string `json:"kind"`
23 Turn int `json:"turn,omitempty"`
24 Label string `json:"label"`
25 Summary string `json:"summary"`
26 Time time.Time `json:"time,omitempty"`
27 Duration string `json:"duration,omitempty"`
28 Payload string `json:"payload,omitempty"`
29 Result string `json:"result,omitempty"`
30 Schema string `json:"schema,omitempty"`
31 IsError bool `json:"is_error,omitempty"`
32 ToolCall string `json:"tool_call_id,omitempty"`
33}
34
35type timelineData struct {
36 System string
37 Tools []provider.Tool
38 Messages []provider.Message
39 ContextUsed int
40 ContextMax int
41}
42
43type timelineView struct {
44 active bool
45 cursor int
46 tab int
47 searching bool
48 filter string
49 notice string
50}
51
52type timelineAction struct {
53 Export bool
54 Close bool
55}
56
57func newTimelineView() *timelineView { return &timelineView{} }
58
59func (v *timelineView) Active() bool { return v != nil && v.active }
60
61func (v *timelineView) Open(data timelineData) {
62 v.active = true
63 v.searching = false
64 v.filter = ""
65 v.notice = ""
66 v.tab = 0
67 v.cursor = len(buildTimelineEntries(data)) - 1
68 if v.cursor < 0 {
69 v.cursor = 0
70 }
71}
72
73func (v *timelineView) Close() {
74 v.active = false
75 v.searching = false
76 v.filter = ""
77 v.notice = ""
78}
79
80func (v *timelineView) SetNotice(s string) { v.notice = s }
81
82func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
83 entries := filterTimelineEntries(buildTimelineEntries(data), v.filter)
84 clampTimelineCursor(v, len(entries))
85
86 switch k.Kind {
87 case tui.KeyUp:
88 if v.cursor > 0 {
89 v.cursor--
90 }
91 case tui.KeyDown:
92 if v.cursor < len(entries)-1 {
93 v.cursor++
94 }
95 case tui.KeyPageUp:
96 v.cursor -= 8
97 if v.cursor < 0 {
98 v.cursor = 0
99 }
100 case tui.KeyPageDown:
101 v.cursor += 8
102 clampTimelineCursor(v, len(entries))
103 case tui.KeyTab:
104 v.tab = (v.tab + 1) % timelineTabCount
105 case tui.KeyShiftTab:
106 v.tab = (v.tab + timelineTabCount - 1) % timelineTabCount
107 case tui.KeyCtrlE:
108 return timelineAction{Export: true}
109 case tui.KeyBackspace:
110 if v.searching && v.filter != "" {
111 r := []rune(v.filter)
112 v.filter = string(r[:len(r)-1])
113 v.cursor = 0
114 }
115 case tui.KeyPaste:
116 if v.searching {
117 v.filter += singleLinePaste(k.Paste)
118 v.cursor = 0
119 }
120 case tui.KeyRune:
121 if !v.searching && k.Rune == '/' {
122 v.searching = true
123 v.notice = ""
124 } else if v.searching && !k.Ctrl && !k.Alt && !k.Super {
125 v.filter += string(k.Rune)
126 v.cursor = 0
127 }
128 case tui.KeyEsc, tui.KeyCtrlC:
129 if v.searching {
130 if v.filter != "" {
131 v.filter = ""
132 v.cursor = 0
133 } else {
134 v.searching = false
135 }
136 break
137 }
138 v.Close()
139 return timelineAction{Close: true}
140 }
141 return timelineAction{}
142}
143
144func clampTimelineCursor(v *timelineView, n int) {
145 if n == 0 {
146 v.cursor = 0
147 return
148 }
149 if v.cursor >= n {
150 v.cursor = n - 1
151 }
152 if v.cursor < 0 {
153 v.cursor = 0
154 }
155}
156
157func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData) []string {
158 if !v.Active() {
159 return nil
160 }
161 if width < 30 {
162 width = 30
163 }
164 entries := filterTimelineEntries(buildTimelineEntries(data), v.filter)
165 clampTimelineCursor(v, len(entries))
166
167 lines := []string{frameHeader(th, "timeline", width)}
168 lines = append(lines, renderTimelineContext(th, width, data)...)
169
170 hint := "↑/↓ select pgup/pgdn move tab details / search ctrl+e export esc chat"
171 if v.searching {
172 hint = "search: " + v.filter + "_ (esc clears)"
173 }
174 lines = append(lines, th.FG256(th.Muted, fitTimelineLine(hint, width)))
175
176 listRows := (height - 14) / 2
177 if listRows < 3 {
178 listRows = 3
179 }
180 if listRows > 12 {
181 listRows = 12
182 }
183 if len(entries) == 0 {
184 lines = append(lines, th.FG256(th.Muted, " no matching events"))
185 } else {
186 start := v.cursor - listRows/2
187 if start < 0 {
188 start = 0
189 }
190 end := start + listRows
191 if end > len(entries) {
192 end = len(entries)
193 start = end - listRows
194 if start < 0 {
195 start = 0
196 }
197 }
198 for idx := start; idx < end; idx++ {
199 line := formatTimelineRow(entries[idx], width)
200 if idx == v.cursor {
201 lines = append(lines, th.PadHighlight(line, width))
202 } else {
203 lines = append(lines, timelineColor(th, entries[idx], line))
204 }
205 }
206 }
207
208 lines = append(lines, renderTimelineTabs(th, width, v.tab))
209 if len(entries) > 0 {
210 detailRows := height - len(lines) - 7
211 if detailRows < 3 {
212 detailRows = 3
213 }
214 lines = append(lines, renderTimelineDetail(th, width, entries[v.cursor], v.tab, detailRows)...)
215 }
216 if v.notice != "" {
217 lines = append(lines, th.FG256(th.Tool, fitTimelineLine(v.notice, width)))
218 }
219 lines = append(lines, frameRule(th, width))
220 return lines
221}
222
223func buildTimelineEntries(data timelineData) []timelineEntry {
224 toolSchemas := make(map[string]string, len(data.Tools))
225 for _, tool := range data.Tools {
226 toolSchemas[tool.Name] = prettyTimelineJSON(tool.Schema)
227 }
228
229 results := map[string]provider.ToolResultBlock{}
230 resultTimes := map[string]time.Time{}
231 for _, message := range data.Messages {
232 for _, content := range message.Content {
233 if result, ok := content.(provider.ToolResultBlock); ok {
234 results[result.CallID] = result
235 resultTimes[result.CallID] = message.Time
236 }
237 }
238 }
239
240 entries := make([]timelineEntry, 0, len(data.Messages)+1)
241 if strings.TrimSpace(data.System) != "" {
242 entries = append(entries, timelineEntry{
243 Kind: "system", Label: "SYSTEM", Summary: firstTimelineLine(data.System), Payload: data.System,
244 })
245 }
246 turn := 0
247 var previousTime time.Time
248 for _, message := range data.Messages {
249 if message.Role == provider.RoleUser {
250 turn++
251 }
252 textParts := timelineMessageText(message)
253 if len(textParts) > 0 {
254 entry := timelineEntry{
255 Kind: string(message.Role), Turn: turn, Label: strings.ToUpper(string(message.Role)),
256 Summary: firstTimelineLine(strings.Join(textParts, "\n")), Time: message.Time,
257 Payload: prettyTimelineValue(timelineMessagePayload(message)),
258 }
259 if !previousTime.IsZero() && !message.Time.IsZero() && message.Time.After(previousTime) {
260 entry.Duration = message.Time.Sub(previousTime).Round(time.Millisecond).String()
261 }
262 entries = append(entries, entry)
263 if !message.Time.IsZero() {
264 previousTime = message.Time
265 }
266 }
267 for _, content := range message.Content {
268 call, ok := content.(provider.ToolCallBlock)
269 if !ok {
270 continue
271 }
272 entry := timelineEntry{
273 Kind: "tool", Turn: turn, Label: "TOOL " + call.Name, Summary: summarizeToolArguments(call.Arguments),
274 Time: message.Time, Payload: prettyTimelineJSON(call.Arguments), Schema: toolSchemas[call.Name], ToolCall: call.ID,
275 }
276 if result, found := results[call.ID]; found {
277 entry.Result = prettyTimelineValue(timelineResultPayload(result))
278 entry.IsError = result.IsError
279 if ended := resultTimes[call.ID]; !message.Time.IsZero() && ended.After(message.Time) {
280 entry.Duration = ended.Sub(message.Time).Round(time.Millisecond).String()
281 }
282 }
283 entries = append(entries, entry)
284 }
285 }
286 return entries
287}
288
289func timelineMessageText(message provider.Message) []string {
290 var out []string
291 for _, content := range message.Content {
292 switch block := content.(type) {
293 case provider.TextBlock:
294 if strings.TrimSpace(block.Text) != "" {
295 out = append(out, block.Text)
296 }
297 case provider.ReasoningBlock:
298 if strings.TrimSpace(block.Summary) != "" {
299 out = append(out, "reasoning: "+block.Summary)
300 }
301 case provider.ImageBlock:
302 out = append(out, fmt.Sprintf("[image %s, %d bytes]", block.MimeType, len(block.Data)))
303 }
304 }
305 return out
306}
307
308func timelineMessagePayload(message provider.Message) any {
309 blocks := make([]any, 0, len(message.Content))
310 for _, content := range message.Content {
311 switch block := content.(type) {
312 case provider.TextBlock:
313 blocks = append(blocks, map[string]any{"type": "text", "text": block.Text})
314 case provider.ReasoningBlock:
315 blocks = append(blocks, map[string]any{"type": "reasoning", "summary": block.Summary})
316 case provider.ImageBlock:
317 blocks = append(blocks, map[string]any{"type": "image", "mime_type": block.MimeType, "bytes": len(block.Data)})
318 }
319 }
320 return map[string]any{"role": message.Role, "content": blocks, "meta": message.Meta}
321}
322
323func timelineResultPayload(result provider.ToolResultBlock) any {
324 parts := make([]any, 0, len(result.Content))
325 for _, content := range result.Content {
326 switch block := content.(type) {
327 case provider.TextBlock:
328 parts = append(parts, map[string]any{"type": "text", "text": block.Text})
329 case provider.ImageBlock:
330 parts = append(parts, map[string]any{"type": "image", "mime_type": block.MimeType, "bytes": len(block.Data)})
331 default:
332 parts = append(parts, fmt.Sprintf("%T", content))
333 }
334 }
335 return map[string]any{"content": parts, "is_error": result.IsError}
336}
337
338func filterTimelineEntries(entries []timelineEntry, filter string) []timelineEntry {
339 filter = strings.ToLower(strings.TrimSpace(filter))
340 if filter == "" {
341 return entries
342 }
343 out := make([]timelineEntry, 0, len(entries))
344 for _, entry := range entries {
345 haystack := strings.ToLower(strings.Join([]string{entry.Label, entry.Summary, entry.Payload, entry.Result}, "\n"))
346 if strings.Contains(haystack, filter) {
347 out = append(out, entry)
348 }
349 }
350 return out
351}
352
353func renderTimelineContext(th tui.Theme, width int, data timelineData) []string {
354 system := estimateTimelineTokens(data.System)
355 toolBytes, _ := json.Marshal(data.Tools)
356 tools := estimateTimelineTokens(string(toolBytes))
357 messages := estimateTimelineMessageTokens(data.Messages)
358
359 usage := fmt.Sprintf("context %s", compactTimelineNumber(data.ContextUsed))
360 if data.ContextMax > 0 {
361 pct := float64(data.ContextUsed) * 100 / float64(data.ContextMax)
362 usage = fmt.Sprintf("context %s / %s (%.1f%%)", compactTimelineNumber(data.ContextUsed), compactTimelineNumber(data.ContextMax), pct)
363 }
364 composition := fmt.Sprintf("estimated composition: system %s tools %s messages %s", compactTimelineNumber(system), compactTimelineNumber(tools), compactTimelineNumber(messages))
365 return []string{tui.Bold(usage), th.FG256(th.Muted, fitTimelineLine(composition, width))}
366}
367
368func formatTimelineRow(entry timelineEntry, width int) string {
369 stamp := " "
370 if !entry.Time.IsZero() {
371 stamp = entry.Time.Format("15:04:05")
372 }
373 turn := " "
374 if entry.Turn > 0 {
375 turn = fmt.Sprintf("%3d", entry.Turn)
376 }
377 line := fmt.Sprintf("%s %s %-16s %s", stamp, turn, entry.Label, entry.Summary)
378 return fitTimelineLine(line, width)
379}
380
381func timelineColor(th tui.Theme, entry timelineEntry, line string) string {
382 color := th.Muted
383 switch entry.Kind {
384 case "user":
385 color = th.User
386 case "assistant":
387 color = th.Assistant
388 case "tool":
389 color = th.Tool
390 case "system":
391 color = th.Warning
392 }
393 if entry.IsError {
394 color = th.Error
395 }
396 return th.FG256(color, line)
397}
398
399func renderTimelineTabs(th tui.Theme, width, selected int) string {
400 if selected < 0 || selected >= len(timelineTabNames) || width < 1 {
401 return ""
402 }
403 start, end := selected, selected+1
404 for start > 0 || end < len(timelineTabNames) {
405 grew := false
406 if start > 0 && timelineTabsWidth(start-1, end) <= width {
407 start--
408 grew = true
409 }
410 if end < len(timelineTabNames) && timelineTabsWidth(start, end+1) <= width {
411 end++
412 grew = true
413 }
414 if !grew {
415 break
416 }
417 }
418
419 tabs := make([]string, 0, end-start)
420 for idx := start; idx < end; idx++ {
421 name := timelineTabNames[idx]
422 if idx == selected {
423 tabs = append(tabs, tui.Bold(th.FG256(th.Accent, "["+name+"]")))
424 } else {
425 tabs = append(tabs, th.FG256(th.Muted, " "+name+" "))
426 }
427 }
428 return strings.Join(tabs, " ")
429}
430
431func timelineTabsWidth(start, end int) int {
432 width := 2 * (end - start - 1)
433 for idx := start; idx < end; idx++ {
434 width += runewidth.StringWidth(timelineTabNames[idx]) + 2
435 }
436 return width
437}
438
439func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, maxRows int) []string {
440 var detail string
441 switch tab {
442 case 0:
443 detail = fmt.Sprintf("type: %s\nturn: %d\nstatus: %s\nsummary: %s", entry.Kind, entry.Turn, timelineStatus(entry), entry.Summary)
444 case 1:
445 detail = entry.Payload
446 case 2:
447 detail = entry.Result
448 case 3:
449 detail = entry.Schema
450 case 4:
451 started := "not recorded"
452 if !entry.Time.IsZero() {
453 started = entry.Time.Format(time.RFC3339Nano)
454 }
455 duration := entry.Duration
456 if duration == "" {
457 duration = "not recorded"
458 }
459 detail = "started: " + started + "\nduration: " + duration + "\nsource: transcript timestamps"
460 }
461 if strings.TrimSpace(detail) == "" {
462 detail = "not available for this event"
463 }
464 var rows []string
465 for _, line := range strings.Split(detail, "\n") {
466 for _, wrapped := range tui.WrapANSILine(line, width-2) {
467 rows = append(rows, " "+th.FG256(th.ToolOut, wrapped))
468 if len(rows) >= maxRows {
469 return rows
470 }
471 }
472 }
473 return rows
474}
475
476func timelineStatus(entry timelineEntry) string {
477 if entry.IsError {
478 return "error"
479 }
480 if entry.Kind == "tool" && entry.Result == "" {
481 return "result unavailable"
482 }
483 return "completed"
484}
485
486func (v *timelineView) Export(dir string, data timelineData) (string, error) {
487 if dir == "" {
488 dir = defaultExportDir()
489 }
490 if err := os.MkdirAll(dir, 0o755); err != nil {
491 return "", fmt.Errorf("create export directory: %w", err)
492 }
493 payload := struct {
494 ExportedAt time.Time `json:"exported_at"`
495 ContextUsed int `json:"context_used"`
496 ContextMax int `json:"context_max"`
497 Events []timelineEntry `json:"events"`
498 }{time.Now(), data.ContextUsed, data.ContextMax, buildTimelineEntries(data)}
499 encoded, err := json.MarshalIndent(payload, "", " ")
500 if err != nil {
501 return "", fmt.Errorf("encode timeline: %w", err)
502 }
503 name := "zot-timeline-" + time.Now().Format("20060102-150405.000000000") + ".json"
504 path := filepath.Join(dir, name)
505 if err := os.WriteFile(path, append(encoded, '\n'), 0o600); err != nil {
506 return "", fmt.Errorf("write timeline: %w", err)
507 }
508 return path, nil
509}
510
511func firstTimelineLine(s string) string {
512 for _, line := range strings.Split(s, "\n") {
513 if line = strings.TrimSpace(line); line != "" {
514 return fitTimelineLine(line, 120)
515 }
516 }
517 return "(empty)"
518}
519
520func summarizeToolArguments(raw json.RawMessage) string {
521 var object map[string]any
522 if json.Unmarshal(raw, &object) == nil {
523 for _, key := range []string{"command", "path", "query", "pattern"} {
524 if value, ok := object[key]; ok {
525 return firstTimelineLine(fmt.Sprint(value))
526 }
527 }
528 }
529 return firstTimelineLine(string(raw))
530}
531
532func prettyTimelineJSON(raw json.RawMessage) string {
533 if len(raw) == 0 {
534 return ""
535 }
536 var value any
537 if json.Unmarshal(raw, &value) != nil {
538 return string(raw)
539 }
540 return prettyTimelineValue(value)
541}
542
543func prettyTimelineValue(value any) string {
544 encoded, err := json.MarshalIndent(value, "", " ")
545 if err != nil {
546 return fmt.Sprint(value)
547 }
548 return string(encoded)
549}
550
551func estimateTimelineTokens(s string) int {
552 if s == "" {
553 return 0
554 }
555 return (len([]byte(s)) + 3) / 4
556}
557
558func estimateTimelineMessageTokens(messages []provider.Message) int {
559 bytes := 0
560 for _, message := range messages {
561 bytes += len(message.Role) + 12
562 for _, content := range message.Content {
563 switch block := content.(type) {
564 case provider.TextBlock:
565 bytes += len(block.Text) + len(block.ThoughtSignature)
566 case provider.ReasoningBlock:
567 bytes += len(block.Summary) + len(block.Encrypted)
568 case provider.ImageBlock:
569 // JSON transports image bytes as base64, which expands by roughly 4/3.
570 bytes += (len(block.Data)*4 + 2) / 3
571 case provider.ToolCallBlock:
572 bytes += len(block.ID) + len(block.Name) + len(block.Arguments)
573 case provider.ToolResultBlock:
574 bytes += len(block.CallID) + estimateTimelineResultBytes(block.Content)
575 }
576 }
577 }
578 return (bytes + 3) / 4
579}
580
581func estimateTimelineResultBytes(contents []provider.Content) int {
582 bytes := 0
583 for _, content := range contents {
584 switch block := content.(type) {
585 case provider.TextBlock:
586 bytes += len(block.Text)
587 case provider.ImageBlock:
588 bytes += (len(block.Data)*4 + 2) / 3
589 }
590 }
591 return bytes
592}
593
594func compactTimelineNumber(n int) string {
595 if n >= 1_000_000 {
596 return fmt.Sprintf("%.1fm", float64(n)/1_000_000)
597 }
598 if n >= 1_000 {
599 return fmt.Sprintf("%.1fk", float64(n)/1_000)
600 }
601 return fmt.Sprintf("%d", n)
602}
603
604func fitTimelineLine(s string, width int) string {
605 if width < 1 {
606 return ""
607 }
608 if runewidth.StringWidth(s) <= width {
609 return s
610 }
611 return runewidth.Truncate(s, width, "...")
612}