v0.48.1 deaab59

timeline_view.go RawHistoryFolder

1package modes
2
3import (
4	"encoding/json"
5	"fmt"
6	"hash/fnv"
7	"os"
8	"path/filepath"
9	"strings"
10	"time"
11	"unicode"
12
13	"github.com/mattn/go-runewidth"
14
15	"github.com/bnema/zut/packages/core"
16	"github.com/bnema/zut/packages/provider"
17	"github.com/bnema/zut/packages/tui"
18)
19
20// The session timeline is a read-only inspector for the model context: the
21// system prompt, the visible transcript, and every tool call with its result.
22// It renders inside the shared floating pane, so the live transcript keeps
23// updating behind it.
24//
25// Privacy: entries are built only from the transcript snapshot. Encrypted
26// reasoning, provider thought signatures and image bytes are never rendered
27// or exported, and nothing here logs.
28
29const timelineTabCount = 5
30
31var timelineTabNames = [timelineTabCount]string{"summary", "payload", "result", "schema", "timing"}
32
33type timelineEntry struct {
34	Kind     string    `json:"kind"`
35	Turn     int       `json:"turn,omitempty"`
36	Label    string    `json:"label"`
37	Summary  string    `json:"summary"`
38	Time     time.Time `json:"time,omitempty"`
39	Duration string    `json:"duration,omitempty"`
40	Payload  string    `json:"payload,omitempty"`
41	Result   string    `json:"result,omitempty"`
42	Schema   string    `json:"schema,omitempty"`
43	IsError  bool      `json:"is_error,omitempty"`
44	ToolCall string    `json:"tool_call_id,omitempty"`
45}
46
47// timelineCacheKey identifies an unchanged snapshot: the agent, its transcript
48// revision, and a content hash of the system prompt and tool specs (which
49// change without bumping the transcript revision). The zero value disables
50// caching (used by tests and callers without a live agent).
51type timelineCacheKey struct {
52	agent  *core.Agent
53	rev    uint64
54	prompt uint64
55}
56
57// timelineEstimates are the token estimates shown in the context header. They
58// depend only on the snapshot, so they are computed once per snapshot.
59type timelineEstimates struct {
60	system, tools, messages int
61	ok                      bool
62}
63
64type timelineData struct {
65	System      string
66	Tools       []provider.Tool
67	Messages    []provider.Message
68	ContextUsed int
69	ContextMax  int
70	Key         timelineCacheKey
71	Est         timelineEstimates
72}
73
74type timelineView struct {
75	active    bool
76	cursor    int
77	tab       int
78	detailTop int
79	searching bool
80	filter    string
81	notice    string
82
83	// snap is the last complete snapshot; it is reused while snapKey matches
84	// so an idle redraw copies neither the transcript nor the tool specs.
85	snapValid bool
86	snapKey   timelineCacheKey
87	snap      timelineData
88
89	cacheValid  bool
90	cacheKey    timelineCacheKey
91	cacheAll    []timelineEntry
92	cacheFilter string
93	cacheList   []timelineEntry
94	cacheListOK bool
95}
96
97type timelineAction struct {
98	Export bool
99	Close  bool
100}
101
102func newTimelineView() *timelineView { return &timelineView{} }
103
104func (v *timelineView) Active() bool { return v != nil && v.active }
105
106// entries returns the (optionally filtered) timeline rows for data, reusing
107// the previous build while the transcript revision is unchanged.
108func (v *timelineView) entries(data timelineData) []timelineEntry {
109	if data.Key == (timelineCacheKey{}) || !v.cacheValid || v.cacheKey != data.Key {
110		v.cacheAll = buildTimelineEntries(data)
111		v.cacheKey = data.Key
112		v.cacheValid = data.Key != (timelineCacheKey{})
113		v.cacheListOK = false
114	}
115	if !v.cacheListOK || v.cacheFilter != v.filter {
116		v.cacheList = filterTimelineEntries(v.cacheAll, v.filter)
117		v.cacheFilter = v.filter
118		v.cacheListOK = true
119	}
120	return v.cacheList
121}
122
123func (v *timelineView) Open(data timelineData) {
124	v.active = true
125	v.searching = false
126	v.filter = ""
127	v.notice = ""
128	v.tab = 0
129	v.detailTop = 0
130	v.cacheValid = false
131	v.cursor = len(v.entries(data)) - 1
132	if v.cursor < 0 {
133		v.cursor = 0
134	}
135}
136
137func (v *timelineView) Close() {
138	v.active = false
139	v.searching = false
140	v.filter = ""
141	v.notice = ""
142	v.detailTop = 0
143	v.cacheValid = false
144	v.cacheAll, v.cacheList = nil, nil
145	v.cacheListOK = false
146	v.snapValid = false
147	v.snap = timelineData{}
148}
149
150func (v *timelineView) SetNotice(s string) { v.notice = s }
151
152func (v *timelineView) HandleKey(k tui.Key, data timelineData) timelineAction {
153	entries := v.entries(data)
154	clampTimelineCursor(v, len(entries))
155
156	previousCursor, previousTab := v.cursor, v.tab
157	switch k.Kind {
158	case tui.KeyLeft:
159		if v.detailTop > 0 {
160			v.detailTop--
161		}
162	case tui.KeyRight:
163		v.detailTop++
164	case tui.KeyHome:
165		v.detailTop = 0
166	case tui.KeyEnd:
167		v.detailTop = int(^uint(0) >> 1)
168	case tui.KeyUp:
169		if v.cursor > 0 {
170			v.cursor--
171		}
172	case tui.KeyDown:
173		if v.cursor < len(entries)-1 {
174			v.cursor++
175		}
176	case tui.KeyPageUp:
177		v.cursor -= 8
178		if v.cursor < 0 {
179			v.cursor = 0
180		}
181	case tui.KeyPageDown:
182		v.cursor += 8
183		clampTimelineCursor(v, len(entries))
184	case tui.KeyTab:
185		v.tab = (v.tab + 1) % timelineTabCount
186	case tui.KeyShiftTab:
187		v.tab = (v.tab + timelineTabCount - 1) % timelineTabCount
188	case tui.KeyCtrlE:
189		return timelineAction{Export: true}
190	case tui.KeyBackspace:
191		if v.searching && v.filter != "" {
192			r := []rune(v.filter)
193			v.filter = string(r[:len(r)-1])
194			v.cursor = 0
195			v.detailTop = 0
196		}
197	case tui.KeyPaste:
198		if v.searching {
199			v.filter += sanitizeTimelineLine(singleLinePaste(k.Paste))
200			v.cursor = 0
201			v.detailTop = 0
202		}
203	case tui.KeyRune:
204		if !v.searching && k.Rune == '/' {
205			v.searching = true
206			v.notice = ""
207		} else if v.searching && !k.Ctrl && !k.Alt && !k.Super && !unicode.IsControl(k.Rune) {
208			v.filter += string(k.Rune)
209			v.cursor = 0
210			v.detailTop = 0
211		}
212	case tui.KeyCtrlC:
213		// Ctrl+C always leaves the view, like every other floating dialog.
214		v.Close()
215		return timelineAction{Close: true}
216	case tui.KeyEsc:
217		if v.searching {
218			if v.filter != "" {
219				v.filter = ""
220				v.cursor = 0
221				v.detailTop = 0
222			} else {
223				v.searching = false
224			}
225			break
226		}
227		v.Close()
228		return timelineAction{Close: true}
229	}
230	if v.cursor != previousCursor || v.tab != previousTab {
231		v.detailTop = 0
232	}
233	return timelineAction{}
234}
235
236func clampTimelineCursor(v *timelineView, n int) {
237	if n == 0 {
238		v.cursor = 0
239		return
240	}
241	if v.cursor >= n {
242		v.cursor = n - 1
243	}
244	if v.cursor < 0 {
245		v.cursor = 0
246	}
247}
248
249// Render draws the inspector for a pane content area of width x height. The
250// first line is a frame header and the last a frame rule so the floating pane
251// can lift the header into its border like other dialogs. After padDialogFrame
252// and floatingDialogBody, the body never exceeds height rows: optional rows
253// (hint, context, tabs, notice) are dropped before the list or detail shrink,
254// down to a single list row.
255func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData) []string {
256	if !v.Active() {
257		return nil
258	}
259	if width < 1 {
260		width = 1
261	}
262	entries := v.entries(data)
263	clampTimelineCursor(v, len(entries))
264
265	// padDialogFrame may add one blank row after the header and one before
266	// the rule; both stay in the pane body.
267	avail := max(0, height-2)
268	noticeRows := 0
269	if v.notice != "" && avail >= 4 {
270		noticeRows = 1
271	}
272	avail -= noticeRows
273	tabRows, hintRows, contextRows := 0, 0, 0
274	if avail >= 3 {
275		tabRows = 1
276		extra := avail - 3
277		hintRows = min(1, extra)
278		extra -= hintRows
279		contextRows = min(2, extra)
280	}
281	rest := avail - tabRows - hintRows - contextRows
282	listRows := min(12, max(min(rest, 1), rest/3))
283	detailRows := max(0, rest-listRows)
284
285	lines := []string{frameHeader(th, "timeline", width)}
286	if contextRows > 0 {
287		lines = append(lines, renderTimelineContext(th, width, data)[:contextRows]...)
288	}
289	if hintRows > 0 {
290		hint := "↑/↓ select  tab details  ←/→ scroll detail  home/end  / search  ctrl+e export  esc close"
291		if v.searching {
292			hint = "search: " + sanitizeTimelineLine(v.filter) + "_  (esc clears)"
293		}
294		lines = append(lines, th.FGColor(th.Muted, fitTimelineLine(hint, width)))
295	}
296	if listRows > 0 {
297		if len(entries) == 0 {
298			lines = append(lines, th.FGColor(th.Muted, fitTimelineLine("  no matching events", width)))
299		} else {
300			start := v.cursor - listRows/2
301			if start < 0 {
302				start = 0
303			}
304			end := start + listRows
305			if end > len(entries) {
306				end = len(entries)
307				start = max(0, end-listRows)
308			}
309			for idx := start; idx < end; idx++ {
310				line := formatTimelineRow(entries[idx], width)
311				if idx == v.cursor {
312					lines = append(lines, th.PadHighlight(line, width))
313				} else {
314					lines = append(lines, timelineColor(th, entries[idx], line))
315				}
316			}
317		}
318	}
319	if tabRows > 0 {
320		lines = append(lines, renderTimelineTabs(th, width, v.tab))
321	}
322	if detailRows > 0 && len(entries) > 0 {
323		detail := timelineDetailRows(th, width, entries[v.cursor], v.tab)
324		maxTop := max(0, len(detail)-detailRows)
325		if v.detailTop > maxTop {
326			v.detailTop = maxTop
327		}
328		lines = append(lines, detail[v.detailTop:min(v.detailTop+detailRows, len(detail))]...)
329	}
330	if noticeRows > 0 {
331		lines = append(lines, th.FGColor(th.Tool, fitTimelineLine(sanitizeTimelineLine(v.notice), width)))
332	}
333	lines = append(lines, frameRule(th, width))
334	return lines
335}
336
337// sanitizeTimelineLine makes untrusted transcript text one safe terminal row:
338// escape sequences (CSI, OSC), control characters, tabs and CR/LF are removed
339// or replaced before the text is measured, truncated or wrapped.
340func sanitizeTimelineLine(s string) string {
341	return sanitizeSessionTreeText(s)
342}
343
344// sanitizeTimelineBlock sanitizes multi-line text row by row, keeping line
345// breaks (LF, CRLF and lone CR) and each row's leading indentation.
346func sanitizeTimelineBlock(s string) string {
347	s = strings.ReplaceAll(s, "\r\n", "\n")
348	s = strings.ReplaceAll(s, "\r", "\n")
349	rows := strings.Split(s, "\n")
350	for n, row := range rows {
351		indent := 0
352	indentation:
353		for _, r := range row {
354			switch r {
355			case ' ':
356				indent++
357			case '\t':
358				indent += 4
359			default:
360				break indentation
361			}
362		}
363		rows[n] = strings.Repeat(" ", indent) + sanitizeSessionTreeText(row)
364	}
365	return strings.Join(rows, "\n")
366}
367
368// buildTimelineEntries turns a transcript snapshot into ordered rows. Each
369// tool call is paired with the next unclaimed result carrying its ID, so an ID
370// a provider reuses across turns keeps every call attached to its own result.
371func buildTimelineEntries(data timelineData) []timelineEntry {
372	toolSchemas := make(map[string]string, len(data.Tools))
373	for _, tool := range data.Tools {
374		toolSchemas[tool.Name] = prettyTimelineJSON(tool.Schema)
375	}
376
377	entries := make([]timelineEntry, 0, len(data.Messages)+1)
378	if strings.TrimSpace(data.System) != "" {
379		entries = append(entries, timelineEntry{
380			Kind: "system", Label: "SYSTEM", Summary: firstTimelineLine(data.System), Payload: data.System,
381		})
382	}
383	// pending maps a call ID to the FIFO of entry indexes still awaiting a result.
384	pending := map[string][]int{}
385	callTimes := map[int]time.Time{}
386	turn := 0
387	var previousTime time.Time
388	for _, message := range data.Messages {
389		if message.Role == provider.RoleUser {
390			turn++
391		}
392		for _, content := range message.Content {
393			if block, ok := content.(provider.ReasoningBlock); ok && strings.TrimSpace(block.Summary) != "" {
394				entries = append(entries, timelineEntry{
395					Kind: "reasoning", Turn: turn, Label: "REASONING", Summary: firstTimelineLine(block.Summary),
396					Time: message.Time, Payload: block.Summary,
397				})
398			}
399		}
400		textParts := timelineMessageText(message)
401		if len(textParts) > 0 {
402			entry := timelineEntry{
403				Kind: string(message.Role), Turn: turn, Label: strings.ToUpper(string(message.Role)),
404				Summary: firstTimelineLine(strings.Join(textParts, "\n")), Time: message.Time,
405				Payload: prettyTimelineValue(timelineMessagePayload(message)),
406			}
407			if !previousTime.IsZero() && !message.Time.IsZero() && message.Time.After(previousTime) {
408				entry.Duration = message.Time.Sub(previousTime).Round(time.Millisecond).String()
409			}
410			entries = append(entries, entry)
411			if !message.Time.IsZero() {
412				previousTime = message.Time
413			}
414		}
415		for _, content := range message.Content {
416			switch block := content.(type) {
417			case provider.ToolCallBlock:
418				entries = append(entries, timelineEntry{
419					Kind: "tool", Turn: turn, Label: "TOOL " + block.Name, Summary: summarizeToolArguments(block.Arguments),
420					Time: message.Time, Payload: prettyTimelineJSON(block.Arguments), Schema: toolSchemas[block.Name], ToolCall: block.ID,
421				})
422				idx := len(entries) - 1
423				pending[block.ID] = append(pending[block.ID], idx)
424				callTimes[idx] = message.Time
425			case provider.ToolResultBlock:
426				queue := pending[block.CallID]
427				result := prettyTimelineValue(timelineResultPayload(block))
428				if len(queue) == 0 {
429					// A result with no open call (for example after a
430					// compaction boundary) stays visible, marked unpaired.
431					entries = append(entries, timelineEntry{
432						Kind: "tool", Turn: turn, Label: "TOOL RESULT", Summary: "(unpaired result)",
433						Time: message.Time, Result: result, IsError: block.IsError, ToolCall: block.CallID,
434					})
435					continue
436				}
437				idx := queue[0]
438				pending[block.CallID] = queue[1:]
439				entries[idx].Result = result
440				entries[idx].IsError = block.IsError
441				if started := callTimes[idx]; !started.IsZero() && message.Time.After(started) {
442					entries[idx].Duration = message.Time.Sub(started).Round(time.Millisecond).String()
443				}
444			}
445		}
446	}
447	return entries
448}
449
450func timelineMessageText(message provider.Message) []string {
451	var out []string
452	for _, content := range message.Content {
453		switch block := content.(type) {
454		case provider.TextBlock:
455			if strings.TrimSpace(block.Text) != "" {
456				out = append(out, block.Text)
457			}
458		case provider.ImageBlock:
459			out = append(out, fmt.Sprintf("[image %s, %d bytes]", block.MimeType, len(block.Data)))
460		}
461	}
462	return out
463}
464
465// timelineMessagePayload describes a message without provider-private data:
466// reasoning shows only its readable summary (never the encrypted content), text
467// omits thought signatures, and images show only type and size.
468func timelineMessagePayload(message provider.Message) any {
469	blocks := make([]any, 0, len(message.Content))
470	for _, content := range message.Content {
471		switch block := content.(type) {
472		case provider.TextBlock:
473			blocks = append(blocks, map[string]any{"type": "text", "text": block.Text})
474		case provider.ReasoningBlock:
475			blocks = append(blocks, map[string]any{"type": "reasoning", "summary": block.Summary})
476		case provider.ImageBlock:
477			blocks = append(blocks, map[string]any{"type": "image", "mime_type": block.MimeType, "bytes": len(block.Data)})
478		}
479	}
480	return map[string]any{"role": message.Role, "content": blocks, "meta": message.Meta}
481}
482
483func timelineResultPayload(result provider.ToolResultBlock) any {
484	parts := make([]any, 0, len(result.Content))
485	for _, content := range result.Content {
486		switch block := content.(type) {
487		case provider.TextBlock:
488			parts = append(parts, map[string]any{"type": "text", "text": block.Text})
489		case provider.ImageBlock:
490			parts = append(parts, map[string]any{"type": "image", "mime_type": block.MimeType, "bytes": len(block.Data)})
491		default:
492			parts = append(parts, fmt.Sprintf("%T", content))
493		}
494	}
495	return map[string]any{"content": parts, "is_error": result.IsError}
496}
497
498func filterTimelineEntries(entries []timelineEntry, filter string) []timelineEntry {
499	filter = strings.ToLower(strings.TrimSpace(filter))
500	if filter == "" {
501		return entries
502	}
503	out := make([]timelineEntry, 0, len(entries))
504	for _, entry := range entries {
505		haystack := strings.ToLower(strings.Join([]string{entry.Label, entry.Summary, entry.Payload, entry.Result}, "\n"))
506		if strings.Contains(haystack, filter) {
507			out = append(out, entry)
508		}
509	}
510	return out
511}
512
513func renderTimelineContext(th tui.Theme, width int, data timelineData) []string {
514	est := data.Est
515	if !est.ok {
516		est = computeTimelineEstimates(data.System, data.Tools, data.Messages)
517	}
518	system, tools, messages := est.system, est.tools, est.messages
519
520	usage := fmt.Sprintf("context %s", compactTimelineNumber(data.ContextUsed))
521	if data.ContextMax > 0 {
522		pct := float64(data.ContextUsed) * 100 / float64(data.ContextMax)
523		usage = fmt.Sprintf("context %s / %s (%.1f%%)", compactTimelineNumber(data.ContextUsed), compactTimelineNumber(data.ContextMax), pct)
524	}
525	composition := fmt.Sprintf("estimated composition: system %s  tools %s  messages %s", compactTimelineNumber(system), compactTimelineNumber(tools), compactTimelineNumber(messages))
526	return []string{tui.Bold(fitTimelineLine(usage, width)), th.FGColor(th.Muted, fitTimelineLine(composition, width))}
527}
528
529func computeTimelineEstimates(system string, tools []provider.Tool, messages []provider.Message) timelineEstimates {
530	toolBytes, _ := json.Marshal(tools)
531	return timelineEstimates{
532		system:   estimateTimelineTokens(system),
533		tools:    estimateTimelineTokens(string(toolBytes)),
534		messages: estimateTimelineMessageTokens(messages),
535		ok:       true,
536	}
537}
538
539func formatTimelineRow(entry timelineEntry, width int) string {
540	stamp := "        "
541	if !entry.Time.IsZero() {
542		stamp = entry.Time.Format("15:04:05")
543	}
544	turn := "   "
545	if entry.Turn > 0 {
546		turn = fmt.Sprintf("%3d", entry.Turn)
547	}
548	line := fmt.Sprintf("%s  %s  %-16s  %s", stamp, turn, sanitizeTimelineLine(entry.Label), sanitizeTimelineLine(entry.Summary))
549	return fitTimelineLine(line, width)
550}
551
552func timelineColor(th tui.Theme, entry timelineEntry, line string) string {
553	color := th.Muted
554	switch entry.Kind {
555	case "user":
556		color = th.User
557	case "assistant", "reasoning":
558		color = th.Assistant
559	case "tool":
560		color = th.Tool
561	case "system":
562		color = th.Warning
563	}
564	if entry.IsError {
565		color = th.Error
566	}
567	return th.FGColor(color, line)
568}
569
570func renderTimelineTabs(th tui.Theme, width, selected int) string {
571	if selected < 0 || selected >= len(timelineTabNames) || width < 1 {
572		return ""
573	}
574	start, end := selected, selected+1
575	for start > 0 || end < len(timelineTabNames) {
576		grew := false
577		if start > 0 && timelineTabsWidth(start-1, end) <= width {
578			start--
579			grew = true
580		}
581		if end < len(timelineTabNames) && timelineTabsWidth(start, end+1) <= width {
582			end++
583			grew = true
584		}
585		if !grew {
586			break
587		}
588	}
589
590	tabs := make([]string, 0, end-start)
591	for idx := start; idx < end; idx++ {
592		name := timelineTabNames[idx]
593		if idx == selected {
594			tabs = append(tabs, tui.Bold(th.FGColor(th.Accent, "["+name+"]")))
595		} else {
596			tabs = append(tabs, th.FGColor(th.Muted, " "+name+" "))
597		}
598	}
599	return strings.Join(tabs, "  ")
600}
601
602func timelineTabsWidth(start, end int) int {
603	width := 2 * (end - start - 1)
604	for idx := start; idx < end; idx++ {
605		width += runewidth.StringWidth(timelineTabNames[idx]) + 2
606	}
607	return width
608}
609
610func timelineDetailRows(th tui.Theme, width int, entry timelineEntry, tab int) []string {
611	var detail string
612	switch tab {
613	case 0:
614		detail = fmt.Sprintf("type: %s\nturn: %d\nstatus: %s\nsummary: %s", entry.Kind, entry.Turn, timelineStatus(entry), entry.Summary)
615	case 1:
616		detail = entry.Payload
617	case 2:
618		detail = entry.Result
619	case 3:
620		detail = entry.Schema
621	case 4:
622		started := "not recorded"
623		if !entry.Time.IsZero() {
624			started = entry.Time.Format(time.RFC3339Nano)
625		}
626		duration := entry.Duration
627		if duration == "" {
628			duration = "not recorded"
629		}
630		detail = "started: " + started + "\nduration: " + duration + "\nsource: transcript timestamps"
631	}
632	detail = sanitizeTimelineBlock(detail)
633	if strings.TrimSpace(detail) == "" {
634		detail = "not available for this event"
635	}
636	if entry.Kind == "reasoning" && (tab == 0 || tab == 1) {
637		return renderDialogMarkdownRows(sanitizeTimelineBlock(entry.Payload), th, width)
638	}
639	var rows []string
640	for _, line := range strings.Split(detail, "\n") {
641		for _, wrapped := range tui.WrapANSILine(line, max(1, width-2)) {
642			rows = append(rows, "  "+th.FGColor(th.ToolOut, wrapped))
643		}
644	}
645	return rows
646}
647
648func timelineStatus(entry timelineEntry) string {
649	if entry.IsError {
650		return "error"
651	}
652	if entry.Kind == "tool" && entry.Result == "" {
653		return "result unavailable"
654	}
655	return "completed"
656}
657
658// Export writes the timeline as JSON into dir (the default export directory
659// when empty). The file is created exclusively with owner-only permissions so
660// an existing file or symlink is never overwritten or widened.
661func (v *timelineView) Export(dir string, data timelineData) (string, error) {
662	if dir == "" {
663		dir = defaultExportDir()
664	}
665	if err := os.MkdirAll(dir, 0o700); err != nil {
666		return "", fmt.Errorf("create export directory: %w", err)
667	}
668	payload := struct {
669		ExportedAt  time.Time       `json:"exported_at"`
670		ContextUsed int             `json:"context_used"`
671		ContextMax  int             `json:"context_max"`
672		Events      []timelineEntry `json:"events"`
673	}{time.Now(), data.ContextUsed, data.ContextMax, buildTimelineEntries(data)}
674	encoded, err := json.MarshalIndent(payload, "", "  ")
675	if err != nil {
676		return "", fmt.Errorf("encode timeline: %w", err)
677	}
678	name := "zut-timeline-" + time.Now().Format("20060102-150405.000000000") + ".json"
679	path := filepath.Join(dir, name)
680	file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
681	if err != nil {
682		return "", fmt.Errorf("write timeline: %w", err)
683	}
684	if _, err := file.Write(append(encoded, '\n')); err != nil {
685		_ = file.Close()
686		_ = os.Remove(path)
687		return "", fmt.Errorf("write timeline: %w", err)
688	}
689	if err := file.Close(); err != nil {
690		_ = os.Remove(path)
691		return "", fmt.Errorf("write timeline: %w", err)
692	}
693	return path, nil
694}
695
696func firstTimelineLine(s string) string {
697	for _, line := range strings.Split(s, "\n") {
698		if line = strings.TrimSpace(line); line != "" {
699			return fitTimelineLine(line, 120)
700		}
701	}
702	return "(empty)"
703}
704
705func summarizeToolArguments(raw json.RawMessage) string {
706	var object map[string]any
707	if json.Unmarshal(raw, &object) == nil {
708		for _, key := range []string{"command", "path", "query", "pattern"} {
709			if value, ok := object[key]; ok {
710				return firstTimelineLine(fmt.Sprint(value))
711			}
712		}
713	}
714	return firstTimelineLine(string(raw))
715}
716
717func prettyTimelineJSON(raw json.RawMessage) string {
718	if len(raw) == 0 {
719		return ""
720	}
721	var value any
722	if json.Unmarshal(raw, &value) != nil {
723		return string(raw)
724	}
725	return prettyTimelineValue(value)
726}
727
728func prettyTimelineValue(value any) string {
729	encoded, err := json.MarshalIndent(value, "", "  ")
730	if err != nil {
731		return fmt.Sprint(value)
732	}
733	return string(encoded)
734}
735
736func estimateTimelineTokens(s string) int {
737	if s == "" {
738		return 0
739	}
740	return (len([]byte(s)) + 3) / 4
741}
742
743func estimateTimelineMessageTokens(messages []provider.Message) int {
744	bytes := 0
745	for _, message := range messages {
746		bytes += len(message.Role) + 12
747		for _, content := range message.Content {
748			switch block := content.(type) {
749			case provider.TextBlock:
750				bytes += len(block.Text) + len(block.ThoughtSignature)
751			case provider.ReasoningBlock:
752				// Sizes only: the encrypted payload is counted toward the
753				// estimate but never displayed.
754				bytes += len(block.Summary) + len(block.Encrypted)
755			case provider.ImageBlock:
756				// JSON transports image bytes as base64, which expands by roughly 4/3.
757				bytes += (len(block.Data)*4 + 2) / 3
758			case provider.ToolCallBlock:
759				bytes += len(block.ID) + len(block.Name) + len(block.Arguments)
760			case provider.ToolResultBlock:
761				bytes += len(block.CallID) + estimateTimelineResultBytes(block.Content)
762			}
763		}
764	}
765	return (bytes + 3) / 4
766}
767
768func estimateTimelineResultBytes(contents []provider.Content) int {
769	bytes := 0
770	for _, content := range contents {
771		switch block := content.(type) {
772		case provider.TextBlock:
773			bytes += len(block.Text)
774		case provider.ImageBlock:
775			bytes += (len(block.Data)*4 + 2) / 3
776		}
777	}
778	return bytes
779}
780
781func compactTimelineNumber(n int) string {
782	if n >= 1_000_000 {
783		return fmt.Sprintf("%.1fm", float64(n)/1_000_000)
784	}
785	if n >= 1_000 {
786		return fmt.Sprintf("%.1fk", float64(n)/1_000)
787	}
788	return fmt.Sprintf("%d", n)
789}
790
791func fitTimelineLine(s string, width int) string {
792	if width < 1 {
793		return ""
794	}
795	if runewidth.StringWidth(s) <= width {
796		return s
797	}
798	return runewidth.Truncate(s, width, "...")
799}
800
801// timelineDataLocked returns the timeline snapshot for the live agent using
802// its locked accessors. Host-internal continuation prompts are dropped exactly
803// as the chat transcript drops them. The transcript and tool specs are only
804// copied when the transcript revision or the system/tool content hash changed;
805// otherwise the cached snapshot (and its token estimates) is reused. The
806// caller must hold i.mu.
807func (i *Interactive) timelineDataLocked() timelineData {
808	if i.timeline == nil {
809		i.timeline = newTimelineView()
810	}
811	v := i.timeline
812	var data timelineData
813	if i.agent != nil {
814		system, registry := i.agent.PromptConfig()
815		specs := registry.Specs()
816		// Read the revision before the messages: a concurrent append can only
817		// make the cached snapshot look stale, never newer than it is.
818		key := timelineCacheKey{agent: i.agent, rev: i.agent.Revision(), prompt: timelinePromptHash(system, specs)}
819		if v.snapValid && v.snapKey == key {
820			data = v.snap
821		} else {
822			data = timelineData{System: system, Tools: specs, Key: key}
823			data.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
824			data.Est = computeTimelineEstimates(system, specs, data.Messages)
825			v.snapKey, v.snap, v.snapValid = key, data, true
826		}
827	}
828	data.ContextUsed = i.lastCtxInput
829	if model, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
830		data.ContextMax = model.ContextWindow
831	}
832	return data
833}
834
835// timelinePromptHash fingerprints the system prompt and tool specs by content,
836// so a same-length edit to either still invalidates the cached snapshot.
837func timelinePromptHash(system string, tools []provider.Tool) uint64 {
838	h := fnv.New64a()
839	write := func(s string) {
840		_, _ = h.Write([]byte(s))
841		_, _ = h.Write([]byte{0})
842	}
843	write(system)
844	for _, tool := range tools {
845		write(tool.Name)
846		write(tool.Description)
847		_, _ = h.Write(tool.Schema)
848		_, _ = h.Write([]byte{0})
849		if tool.Deferred {
850			write("deferred")
851		}
852	}
853	return h.Sum64()
854}
855
856// openTimeline shows the inspector in the floating pane.
857func (i *Interactive) openTimeline() {
858	i.mu.Lock()
859	if i.timeline == nil {
860		i.timeline = newTimelineView()
861	}
862	i.timeline.Open(i.timelineDataLocked())
863	i.statusErr = ""
864	i.statusOK = ""
865	i.mu.Unlock()
866	i.invalidate()
867}
868
869// handleTimelineKey routes one key to the open timeline. View state is shared
870// with the renderer, so it is only touched under i.mu; the export's file I/O
871// runs outside the lock on the snapshot taken with the key.
872func (i *Interactive) handleTimelineKey(k tui.Key) {
873	i.mu.Lock()
874	data := i.timelineDataLocked()
875	act := i.timeline.HandleKey(k, data)
876	i.mu.Unlock()
877	if act.Export {
878		path, err := i.timeline.Export("", data)
879		i.mu.Lock()
880		if err != nil {
881			i.timeline.SetNotice("export failed: " + err.Error())
882		} else {
883			i.timeline.SetNotice("exported to " + friendlyPath(path))
884		}
885		i.mu.Unlock()
886	}
887	i.invalidate()
888}

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 476

↑↓ moveenter openesc close