v0.2.31 85a3c3b

session.go RawHistoryFolder

1package core
2
3import (
4	"bufio"
5	"bytes"
6	"crypto/sha256"
7	"encoding/hex"
8	"encoding/json"
9	"fmt"
10	"io"
11	"os"
12	"path/filepath"
13	"sort"
14	"strings"
15	"time"
16
17	"github.com/google/uuid"
18	"github.com/patriceckhart/zot/packages/provider"
19)
20
21// Session is a JSONL-backed conversation transcript tied to a cwd.
22type Session struct {
23	ID     string
24	Path   string
25	Meta   SessionMeta
26	writer *os.File
27	buf    *bufio.Writer
28
29	// freshFile is true when the file was created by NewSession (this
30	// process owns it) and false when OpenSession reopened an existing
31	// transcript. Used by Close() to delete the file if the run never
32	// appended any messages — prevents a flood of empty session files
33	// from sessions the user opens then exits without prompting.
34	freshFile bool
35
36	// messagesAppended counts AppendMessage calls. Combined with
37	// freshFile it tells Close() whether the session left any content
38	// worth keeping.
39	messagesAppended int
40}
41
42// SessionMeta is written as the first line of every session file.
43type SessionMeta struct {
44	ID       string    `json:"id"`
45	CWD      string    `json:"cwd"`
46	Model    string    `json:"model"`
47	Provider string    `json:"provider"`
48	Started  time.Time `json:"started"`
49	Version  string    `json:"version"`
50	Title    string    `json:"title,omitempty"`
51
52	// Parent is the ID of the session this one was forked from, or
53	// empty for top-level sessions. The tree picker walks parents
54	// upward and sibling files (same cwd dir, same parent ID)
55	// laterally to render the branch topology.
56	Parent string `json:"parent,omitempty"`
57	// ForkPoint is the 0-indexed message position within the parent
58	// transcript where this branch diverges. Messages 0..ForkPoint-1
59	// are copied from the parent verbatim; the user's next turn on
60	// the child session continues from there.
61	ForkPoint int `json:"fork_point,omitempty"`
62}
63
64// sessionLine is the on-disk row type. Message is kept as a raw
65// JSON message on reads (because Content is an interface slice that
66// the default unmarshaler cannot reconstruct); it is written with a
67// regular provider.Message value.
68type sessionLine struct {
69	Type       string             `json:"type"`
70	Meta       *SessionMeta       `json:"meta,omitempty"`
71	Message    *provider.Message  `json:"message,omitempty"`
72	Messages   []provider.Message `json:"messages,omitempty"`
73	Usage      *provider.Usage    `json:"usage,omitempty"`
74	Cumulative *provider.Usage    `json:"cumulative,omitempty"`
75}
76
77type sessionLineHead struct {
78	Type string `json:"type"`
79}
80
81// SessionsDir returns the per-cwd sessions directory under root.
82func SessionsDir(root, cwd string) string {
83	sum := sha256.Sum256([]byte(cwd))
84	short := hex.EncodeToString(sum[:8])
85	return filepath.Join(root, "sessions", short)
86}
87
88// NewSession creates and opens a new session file under
89// SessionsDir(root, cwd) with an autogenerated, time-stamped name.
90func NewSession(root, cwd, providerName, model, version string) (*Session, error) {
91	dir := SessionsDir(root, cwd)
92	if err := os.MkdirAll(dir, 0o755); err != nil {
93		return nil, err
94	}
95	id := uuid.NewString()
96	name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), id[:8])
97	p := filepath.Join(dir, name)
98	return newSessionAt(p, cwd, providerName, model, version)
99}
100
101// NewSessionAtPath creates a session at an explicit file path. Used
102// by callers (notably the swarm-agent child) that need the session
103// file to live at a path chosen by their parent rather than under
104// SessionsDir. Returns an error if the file already exists — use
105// OpenSession for that case.
106func NewSessionAtPath(path, cwd, providerName, model, version string) (*Session, error) {
107	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
108		return nil, err
109	}
110	return newSessionAt(path, cwd, providerName, model, version)
111}
112
113// newSessionAt is the shared implementation. Both NewSession and
114// NewSessionAtPath funnel through here so the meta-line layout,
115// freshFile bookkeeping, and id format stay identical.
116func newSessionAt(p, cwd, providerName, model, version string) (*Session, error) {
117	id := uuid.NewString()
118	f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
119	if err != nil {
120		return nil, err
121	}
122	s := &Session{
123		ID:        id,
124		Path:      p,
125		Meta:      SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},
126		writer:    f,
127		buf:       bufio.NewWriter(f),
128		freshFile: true,
129	}
130	if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
131		f.Close()
132		return nil, err
133	}
134	return s, nil
135}
136
137func forEachJSONLLine(r io.Reader, fn func([]byte) error) error {
138	br := bufio.NewReader(r)
139	for {
140		line, err := br.ReadBytes('\n')
141		if len(line) > 0 {
142			line = bytes.TrimRight(line, "\r\n")
143			if len(line) > 0 {
144				if ferr := fn(line); ferr != nil {
145					return ferr
146				}
147			}
148		}
149		if err == io.EOF {
150			return nil
151		}
152		if err != nil {
153			return err
154		}
155	}
156}
157
158// SessionUsage returns the most recent cumulative usage row stored in
159// a session file. Sessions append one usage row per completed turn; the
160// latest row's cumulative field is the session total. Missing usage rows
161// are valid for old/empty sessions and return the zero value.
162func SessionUsage(path string) (provider.Usage, error) {
163	cum, _, err := SessionUsageDetail(path)
164	return cum, err
165}
166
167// SessionUsageDetail returns the latest cumulative usage and the
168// per-turn usage of the final completed turn. The per-turn row drives
169// the live "context used" gauge in the status bar (input + cache
170// approximates the prompt size the model just saw), letting the TUI
171// rehydrate the gauge on resume instead of starting at 0% until the
172// next turn lands.
173func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err error) {
174	f, ferr := os.Open(path)
175	if ferr != nil {
176		return provider.Usage{}, provider.Usage{}, ferr
177	}
178	defer f.Close()
179
180	// Some historical sessions logged the per-turn `usage` field as a copy
181	// of `cumulative` instead of the true delta. To recover an accurate
182	// last-turn snapshot (used by the status-bar context gauge on resume),
183	// we always derive lastTurn from the delta between the final two
184	// cumulative rows. For prompt-size purposes, cache_read/cache_write
185	// reflect the most recent prompt directly, so we take those from the
186	// final cumulative row as-is rather than as a delta.
187	var prevCum provider.Usage
188	var haveCum bool
189	if ierr := forEachJSONLLine(f, func(line []byte) error {
190		var head sessionLineHead
191		if err := json.Unmarshal(line, &head); err != nil || head.Type != "usage" {
192			return nil
193		}
194		var row struct {
195			Cumulative provider.Usage `json:"cumulative"`
196		}
197		if err := json.Unmarshal(line, &row); err != nil {
198			return nil
199		}
200		if haveCum {
201			prevCum = cumulative
202		}
203		cumulative = row.Cumulative
204		haveCum = true
205		return nil
206	}); ierr != nil {
207		return provider.Usage{}, provider.Usage{}, ierr
208	}
209	if haveCum {
210		// input/output are monotonic totals -> per-turn = delta.
211		lastTurn.InputTokens = nonNegDelta(cumulative.InputTokens, prevCum.InputTokens)
212		lastTurn.OutputTokens = nonNegDelta(cumulative.OutputTokens, prevCum.OutputTokens)
213		// cache_read/write on the final row already represent the last prompt's
214		// cache hit/creation, not a running total of bytes; use directly.
215		lastTurn.CacheReadTokens = cumulative.CacheReadTokens - prevCum.CacheReadTokens
216		if lastTurn.CacheReadTokens < 0 {
217			lastTurn.CacheReadTokens = cumulative.CacheReadTokens
218		}
219		lastTurn.CacheWriteTokens = cumulative.CacheWriteTokens - prevCum.CacheWriteTokens
220		if lastTurn.CacheWriteTokens < 0 {
221			lastTurn.CacheWriteTokens = cumulative.CacheWriteTokens
222		}
223		lastTurn.CostUSD = cumulative.CostUSD - prevCum.CostUSD
224		if lastTurn.CostUSD < 0 {
225			lastTurn.CostUSD = 0
226		}
227	}
228	return cumulative, lastTurn, nil
229}
230
231func nonNegDelta(cur, prev int) int {
232	if cur < prev {
233		return cur
234	}
235	return cur - prev
236}
237
238// OpenSession opens an existing session for appending.
239func OpenSession(path string) (*Session, []provider.Message, error) {
240	f, err := os.Open(path)
241	if err != nil {
242		return nil, nil, err
243	}
244	defer f.Close()
245
246	var meta SessionMeta
247	var messages []provider.Message
248	if err := forEachJSONLLine(f, func(line []byte) error {
249		var head sessionLineHead
250		if err := json.Unmarshal(line, &head); err != nil {
251			return nil
252		}
253		switch head.Type {
254		case "meta":
255			var row struct {
256				Meta SessionMeta `json:"meta"`
257			}
258			if err := json.Unmarshal(line, &row); err == nil {
259				meta = row.Meta
260			}
261		case "message":
262			if msg, err := hydrateMessage(line); err == nil && len(msg.Content) > 0 {
263				messages = append(messages, msg)
264			}
265		case "compaction":
266			if compacted, err := hydrateCompaction(line); err == nil {
267				messages = compacted
268			}
269		}
270		return nil
271	}); err != nil {
272		return nil, nil, err
273	}
274	messages = repairToolUseResultPairs(messages)
275	out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
276	if err != nil {
277		return nil, nil, err
278	}
279	s := &Session{ID: meta.ID, Path: path, Meta: meta, writer: out, buf: bufio.NewWriter(out)}
280	return s, messages, nil
281}
282
283// repairToolUseResultPairs walks a restored transcript and
284// synthesises stub tool_result blocks for any assistant
285// tool_use blocks that aren't paired with a matching result in
286// the next message. Anthropic (and OpenAI via the responses API)
287// reject any request whose transcript leaves a tool_use without
288// its matching tool_result immediately after, with errors like:
289//
290//	messages.8: `tool_use` ids were found without `tool_result`
291//	blocks immediately after
292//
293// Corruption gets into the transcript two ways we know of:
294//
295//   - Older zot builds that persisted the assistant tool_use row
296//     before the tool_result row, then crashed between the two.
297//   - Abort paths in older builds that didn't drop the mid-turn
298//     assistant message cleanly.
299//
300// Rather than change runtime semantics (which would risk hiding a
301// real bug), we scrub on load: any unmatched tool_use gets a stub
302// tool_result injected as a RoleTool message so the next
303// outbound request passes the provider's validity check. The stub
304// reads "tool call was aborted; no result recorded." so the
305// model can see what happened and decide whether to retry.
306//
307// Runs once per OpenSession call. No cost on the hot path.
308func repairToolUseResultPairs(msgs []provider.Message) []provider.Message {
309	if len(msgs) == 0 {
310		return msgs
311	}
312	out := make([]provider.Message, 0, len(msgs)+2)
313	for i, m := range msgs {
314		out = append(out, m)
315		if m.Role != provider.RoleAssistant {
316			continue
317		}
318		// Collect tool_use ids in this assistant message.
319		var ids []string
320		for _, c := range m.Content {
321			if tc, ok := c.(provider.ToolCallBlock); ok {
322				ids = append(ids, tc.ID)
323			}
324		}
325		if len(ids) == 0 {
326			continue
327		}
328		// Look at the next message (if any) and collect tool_result
329		// CallIDs it covers.
330		have := map[string]bool{}
331		if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool {
332			for _, c := range msgs[i+1].Content {
333				if tr, ok := c.(provider.ToolResultBlock); ok {
334					have[tr.CallID] = true
335				}
336			}
337		}
338		// Build stubs for any missing id.
339		var stubs []provider.Content
340		for _, id := range ids {
341			if have[id] {
342				continue
343			}
344			stubs = append(stubs, provider.ToolResultBlock{
345				CallID:  id,
346				Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}},
347				IsError: true,
348			})
349		}
350		if len(stubs) == 0 {
351			continue
352		}
353		// Merge into the next tool-role message if present,
354		// otherwise insert a synthetic one right after the
355		// assistant message. Merging keeps the tool-role row
356		// count stable; inserting handles the common case where
357		// no tool message was persisted at all.
358		if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool {
359			msgs[i+1].Content = append(msgs[i+1].Content, stubs...)
360			// We already appended m to out; the modified next
361			// message will be appended on the following iteration.
362			continue
363		}
364		out = append(out, provider.Message{
365			Role:    provider.RoleTool,
366			Content: stubs,
367			Time:    m.Time,
368		})
369	}
370	return out
371}
372
373// LatestSession returns the most recent session file for cwd, or "".
374func LatestSession(root, cwd string) string {
375	paths := ListSessions(root, cwd)
376	if len(paths) == 0 {
377		return ""
378	}
379	return paths[0]
380}
381
382// SessionSummary describes one on-disk session at a glance for UI pickers.
383type SessionSummary struct {
384	Path          string
385	Started       time.Time
386	Model         string
387	Provider      string
388	MessageCount  int
389	FirstUserText string
390	TotalCost     float64
391	Title         string
392}
393
394// RenameSession updates the title field in the session's meta line.
395// It rewrites the first line of the file (the meta line) with the
396// updated title.
397// RenameSession appends a rename line to the session file. This is
398// safe even for the currently active session because it opens the
399// file independently and appends (doesn't rewrite).
400func RenameSession(path, title string) error {
401	f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644)
402	if err != nil {
403		return err
404	}
405	defer f.Close()
406	line, _ := json.Marshal(map[string]string{"type": "rename", "title": title})
407	line = append(line, '\n')
408	_, err = f.Write(line)
409	return err
410}
411
412// DescribeSessions returns lightweight summaries for every session in
413// cwd, newest first. Parses only the first few lines and the last usage
414// line so it's cheap to run on every dialog open.
415func DescribeSessions(root, cwd string) []SessionSummary {
416	paths := ListSessions(root, cwd)
417	summaries := make([]SessionSummary, 0, len(paths))
418	for _, p := range paths {
419		summaries = append(summaries, describeSession(p))
420	}
421	return summaries
422}
423
424func describeSession(path string) SessionSummary {
425	s := SessionSummary{Path: path}
426	f, err := os.Open(path)
427	if err != nil {
428		return s
429	}
430	defer f.Close()
431	_ = forEachJSONLLine(f, func(line []byte) error {
432		var head sessionLineHead
433		if err := json.Unmarshal(line, &head); err != nil {
434			return nil
435		}
436		switch head.Type {
437		case "meta":
438			var row struct {
439				Meta SessionMeta `json:"meta"`
440			}
441			if err := json.Unmarshal(line, &row); err == nil {
442				s.Started = row.Meta.Started
443				s.Model = row.Meta.Model
444				s.Provider = row.Meta.Provider
445				s.Title = row.Meta.Title
446			}
447		case "message":
448			s.MessageCount++
449			if s.FirstUserText == "" {
450				s.FirstUserText = firstUserText(line)
451			}
452		case "compaction":
453			if compacted, err := hydrateCompaction(line); err == nil {
454				s.MessageCount = len(compacted)
455				if s.FirstUserText == "" && len(compacted) > 0 {
456					s.FirstUserText = firstTextFromMessage(compacted[0])
457				}
458			}
459		case "rename":
460			var row struct {
461				Title string `json:"title"`
462			}
463			if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
464				s.Title = row.Title
465			}
466		case "usage":
467			var row struct {
468				Cumulative provider.Usage `json:"cumulative"`
469			}
470			if err := json.Unmarshal(line, &row); err == nil {
471				s.TotalCost = row.Cumulative.CostUSD
472			}
473		}
474		return nil
475	})
476	return s
477}
478
479func firstUserText(line []byte) string {
480	var row struct {
481		Message struct {
482			Role    string `json:"role"`
483			Content []struct {
484				Text string `json:"text"`
485			} `json:"content"`
486		} `json:"message"`
487	}
488	if err := json.Unmarshal(line, &row); err != nil {
489		return ""
490	}
491	if row.Message.Role != "user" {
492		return ""
493	}
494	for _, c := range row.Message.Content {
495		if c.Text != "" {
496			return c.Text
497		}
498	}
499	return ""
500}
501
502func firstTextFromMessage(msg provider.Message) string {
503	for _, c := range msg.Content {
504		if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" {
505			return tb.Text
506		}
507	}
508	return ""
509}
510
511// PruneEmptySessions deletes session files in cwd's session directory
512// that contain only a meta line (no messages were ever appended).
513// Cleans up the backlog of empty stubs created by old zot versions
514// that wrote a meta line at NewSession time and never followed up.
515// Errors are swallowed; the caller treats this as best-effort.
516func PruneEmptySessions(root, cwd string) {
517	dir := SessionsDir(root, cwd)
518	entries, err := os.ReadDir(dir)
519	if err != nil {
520		return
521	}
522	for _, e := range entries {
523		if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
524			continue
525		}
526		p := filepath.Join(dir, e.Name())
527		if sessionHasNoMessages(p) {
528			_ = os.Remove(p)
529		}
530	}
531}
532
533// sessionHasNoMessages returns true when the file at path contains
534// no lines of type "message". Meta-only / usage-only files count as
535// empty. Used by PruneEmptySessions and the Describe path.
536func sessionHasNoMessages(path string) bool {
537	f, err := os.Open(path)
538	if err != nil {
539		return false
540	}
541	defer f.Close()
542	hasMessage := false
543	_ = forEachJSONLLine(f, func(line []byte) error {
544		var head sessionLineHead
545		if err := json.Unmarshal(line, &head); err != nil {
546			return nil
547		}
548		if head.Type == "message" {
549			hasMessage = true
550			return io.EOF
551		}
552		return nil
553	})
554	return !hasMessage
555}
556
557// ListSessions returns session file paths for cwd, most-recently-
558// modified first. Sorting on filesystem ModTime instead of the
559// timestamp embedded in the filename means a long-running session
560// the user actually returned to recently floats to the top of
561// /sessions, /continue, and the resume picker, even when it was
562// originally created days earlier than newer but idle sessions.
563// Files with identical ModTime fall back to filename desc so the
564// order stays stable across calls.
565func ListSessions(root, cwd string) []string {
566	dir := SessionsDir(root, cwd)
567	entries, err := os.ReadDir(dir)
568	if err != nil {
569		return nil
570	}
571	type rec struct {
572		path string
573		mod  time.Time
574	}
575	var files []rec
576	for _, e := range entries {
577		if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
578			continue
579		}
580		p := filepath.Join(dir, e.Name())
581		info, err := e.Info()
582		if err != nil {
583			continue
584		}
585		files = append(files, rec{path: p, mod: info.ModTime()})
586	}
587	sort.Slice(files, func(i, j int) bool {
588		if !files[i].mod.Equal(files[j].mod) {
589			return files[i].mod.After(files[j].mod)
590		}
591		return files[i].path > files[j].path
592	})
593	out := make([]string, 0, len(files))
594	for _, r := range files {
595		out = append(out, r.path)
596	}
597	return out
598}
599
600// AppendMessage writes a message to the session.
601func (s *Session) AppendMessage(m provider.Message) error {
602	if s == nil {
603		return nil
604	}
605	if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil {
606		return err
607	}
608	s.messagesAppended++
609	return nil
610}
611
612// AppendCompaction writes a checkpoint that replaces all earlier
613// transcript rows when the session is resumed. The old rows remain in
614// the JSONL file for audit/export, while loaders use the latest
615// compaction row as the effective transcript.
616func (s *Session) AppendCompaction(messages []provider.Message) error {
617	if s == nil {
618		return nil
619	}
620	if err := s.writeLine(sessionLine{Type: "compaction", Messages: messages}); err != nil {
621		return err
622	}
623	s.messagesAppended = len(messages)
624	return nil
625}
626
627// UpdateModel records a provider/model switch in the session file.
628// The reader keeps the most recent meta entry, so the session resumes
629// with the updated model.
630func (s *Session) UpdateModel(providerName, model string) error {
631	if s == nil {
632		return nil
633	}
634	s.Meta.Provider = providerName
635	s.Meta.Model = model
636	return s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta})
637}
638
639// AppendUsage writes a usage row to the session.
640func (s *Session) AppendUsage(u, cum provider.Usage) error {
641	if s == nil {
642		return nil
643	}
644	return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum})
645}
646
647// Close flushes and closes the session file. If the session was
648// freshly created in this process and never had any messages
649// appended (the user opened zot, looked around, and exited without
650// prompting), the file is deleted on close so the sessions list
651// doesn't fill up with empty meta-only stubs.
652func (s *Session) Close() error {
653	if s == nil {
654		return nil
655	}
656	flushErr := s.buf.Flush()
657	closeErr := s.writer.Close()
658	if s.freshFile && s.messagesAppended == 0 {
659		// Best-effort cleanup. We deliberately don't propagate the
660		// remove error: if it fails (file already gone, perms changed)
661		// the worst case is one stale empty file in the listing.
662		_ = os.Remove(s.Path)
663	}
664	if flushErr != nil {
665		return flushErr
666	}
667	return closeErr
668}
669
670func (s *Session) writeLine(row sessionLine) error {
671	b, err := json.Marshal(row)
672	if err != nil {
673		return err
674	}
675	if _, err := s.buf.Write(b); err != nil {
676		return err
677	}
678	if err := s.buf.WriteByte('\n'); err != nil {
679		return err
680	}
681	return s.buf.Flush()
682}
683
684// ---- content (de)serialization ----
685//
686// provider.Content is an interface; encoding/json drops type information.
687// We persist messages by reading the raw "message" object back and
688// rebuilding Content from discriminated fields.
689
690func hydrateCompaction(lineBytes []byte) ([]provider.Message, error) {
691	var row struct {
692		Messages []json.RawMessage `json:"messages"`
693	}
694	if err := json.Unmarshal(lineBytes, &row); err != nil {
695		return nil, err
696	}
697	messages := make([]provider.Message, 0, len(row.Messages))
698	for _, raw := range row.Messages {
699		msg, err := hydrateMessageObject(raw)
700		if err == nil && len(msg.Content) > 0 {
701			messages = append(messages, msg)
702		}
703	}
704	return messages, nil
705}
706
707func hydrateMessage(lineBytes []byte) (provider.Message, error) {
708	var row struct {
709		Message json.RawMessage `json:"message"`
710	}
711	if err := json.Unmarshal(lineBytes, &row); err != nil {
712		return provider.Message{}, err
713	}
714	return hydrateMessageObject(row.Message)
715}
716
717func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
718	var row struct {
719		Role    provider.Role     `json:"role"`
720		Content []json.RawMessage `json:"content"`
721		Time    time.Time         `json:"time"`
722		Meta    map[string]string `json:"meta,omitempty"`
723	}
724	if err := json.Unmarshal(rawMessage, &row); err != nil {
725		return provider.Message{}, err
726	}
727	msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta}
728	for _, raw := range row.Content {
729		var head struct {
730			Text        string `json:"text"`
731			MimeType    string `json:"mime_type"`
732			Data        []byte `json:"data"`
733			ID          string `json:"id"`
734			Name        string `json:"name"`
735			CallID      string `json:"call_id"`
736			ReasoningID string `json:"reasoning_id"`
737			Summary     string `json:"summary"`
738			Encrypted   string `json:"encrypted_content"`
739			// ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError
740		}
741		if err := json.Unmarshal(raw, &head); err != nil {
742			continue
743		}
744		// Discriminate by presence of fields.
745		switch {
746		case head.ReasoningID != "" || head.Encrypted != "":
747			msg.Content = append(msg.Content, provider.ReasoningBlock{
748				ID:        head.ReasoningID,
749				Summary:   head.Summary,
750				Encrypted: head.Encrypted,
751			})
752		case head.Name != "" && head.ID != "":
753			var tc struct {
754				ID        string          `json:"id"`
755				Name      string          `json:"name"`
756				Arguments json.RawMessage `json:"arguments"`
757			}
758			_ = json.Unmarshal(raw, &tc)
759			msg.Content = append(msg.Content, provider.ToolCallBlock{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments})
760		case head.CallID != "":
761			var tr struct {
762				CallID  string            `json:"call_id"`
763				Content []json.RawMessage `json:"content"`
764				IsError bool              `json:"is_error"`
765			}
766			_ = json.Unmarshal(raw, &tr)
767			block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError}
768			for _, c := range tr.Content {
769				var inner struct {
770					Text     string `json:"text"`
771					MimeType string `json:"mime_type"`
772					Data     []byte `json:"data"`
773				}
774				_ = json.Unmarshal(c, &inner)
775				if inner.MimeType != "" {
776					block.Content = append(block.Content, provider.ImageBlock{MimeType: inner.MimeType, Data: inner.Data})
777				} else {
778					block.Content = append(block.Content, provider.TextBlock{Text: inner.Text})
779				}
780			}
781			msg.Content = append(msg.Content, block)
782		case head.MimeType != "":
783			msg.Content = append(msg.Content, provider.ImageBlock{MimeType: head.MimeType, Data: head.Data})
784		default:
785			msg.Content = append(msg.Content, provider.TextBlock{Text: head.Text})
786		}
787	}
788	return msg, nil
789}

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