v0.3.73 c4c30ff

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	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}

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