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}