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