v0.4.4 e5506c1

timeline_view.go RawHistoryFolder

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}

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 477

↑↓ moveenter openesc close