okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petfix(tui): wrap capability notes without padding every linec030244

main
16.5 KiB572 linesraw
1package tui
2
3import (
4	"cmp"
5	"fmt"
6	"path"
7	"regexp"
8	"slices"
9	"strings"
10
11	"albedo/cli/internal/daemon"
12	"github.com/charmbracelet/x/ansi"
13)
14
15// A burst is the work between two pieces of prose: a run of compact entries,
16// tool calls and thinking spells, drawn as one summary row. It renders once
17// something else ends it; until then it is drawn live, so settled rows stay
18// append-only.
19
20// trailingBurst is the run of compact entries that ends entries.
21func trailingBurst(entries []HistoryEntry, flags DisplayFlags) []HistoryEntry {
22	i := len(entries)
23	for i > 0 && Compact(entries[i-1], flags) {
24		i--
25	}
26	return entries[i:]
27}
28
29// burstNamed is the span of entries holding the burst that key names, or
30// false when none is retained.
31func burstNamed(entries []HistoryEntry, key string, flags DisplayFlags) (start, end int, ok bool) {
32	for i, entry := range entries {
33		if !Compact(entry, flags) || i > 0 && Compact(entries[i-1], flags) || factsOf(entry).key != key {
34			continue
35		}
36		end = i + 1
37		for end < len(entries) && Compact(entries[end], flags) {
38			end++
39		}
40		return i, end, true
41	}
42	return 0, 0, false
43}
44
45// tally keeps labels in the order they first came, with how often each did.
46type tally struct {
47	counts map[string]int
48	keys   []string
49}
50
51func (t *tally) add(key string) {
52	if key == "" {
53		return
54	}
55	if t.counts == nil {
56		t.counts = map[string]int{}
57	}
58	if t.counts[key] == 0 {
59		t.keys = append(t.keys, key)
60	}
61	t.counts[key]++
62}
63
64// dedup keeps first order and drops repeats naming produced.
65func dedup(keys []string) []string {
66	seen := make(map[string]bool, len(keys))
67	kept := keys[:0:0]
68	for _, key := range keys {
69		if !seen[key] {
70			seen[key] = true
71			kept = append(kept, key)
72		}
73	}
74	return kept
75}
76
77func (t *tally) addAll(keys []string) {
78	for _, key := range keys {
79		t.add(key)
80	}
81}
82
83// counted labels each key, marking repeats: `go test ×4`.
84func (t tally) counted() []string {
85	labels := make([]string, len(t.keys))
86	for i, key := range t.keys {
87		labels[i] = key
88		if n := t.counts[key]; n > 1 {
89			labels[i] += fmt.Sprintf(" ×%d", n)
90		}
91	}
92	return labels
93}
94
95// entryFacts is what one entry contributes to a burst, computed once when the
96// entry settles. Targets stay as the trace recorded them; naming them for the
97// workspace happens at render time, so a workspace change re-names old rows.
98type entryFacts struct {
99	// key is the entry's entryKey, which names the burst it starts
100	key       string
101	read      []string
102	edited    []string
103	searched  []string
104	looked    []lookup
105	ran       []string
106	py        []string
107	used      []string
108	changes   []daemon.FileChange
109	thoughtMs int64
110	added     int
111	removed   int
112	untimed   bool
113	failed    bool
114}
115
116// lookup is one web search a call made: what it asked, what it found, and the
117// key its fold opens by.
118type lookup struct{ key, query, detail string }
119
120// factsOf reads one entry's contribution, using what append already computed
121// when it can.
122func factsOf(entry HistoryEntry) entryFacts {
123	if entry.facts != nil {
124		return *entry.facts
125	}
126	f := entryFacts{key: entryKey(entry)}
127	if entry.Kind == EntryThinking {
128		f.thoughtMs, f.untimed = entry.ElapsedMs, entry.ElapsedMs <= 0
129		return f
130	}
131	if entry.Kind != EntryTool {
132		return f
133	}
134	f.failed = toolFailed(entry)
135	if trace := entry.ToolTrace; trace != nil && (len(trace.Activities) > 0 || len(trace.Changes) > 0) {
136		for _, change := range trace.Changes {
137			f.edited = append(f.edited, prepareTarget(change.Path))
138			f.added += change.Added
139			f.removed += change.Removed
140			f.changes = append(f.changes, change)
141		}
142		for _, activity := range trace.Activities {
143			switch activity.Kind {
144			case "read":
145				f.read = append(f.read, activity.Target)
146			case "list":
147				f.read = append(f.read, strings.TrimSuffix(activity.Target, "/")+"/")
148			case "search":
149				f.searched = append(f.searched, oneLine(activity.Target))
150			case "web":
151				key := fmt.Sprintf("%x-%x", entry.Timestamp, fnv1a(fnvOffset64, f.key+"\x00"+activity.Target))
152				f.looked = append(f.looked, lookup{key, activity.Target, activity.Detail})
153			case "run":
154				f.ran = append(f.ran, commandName(activity.Target))
155			}
156		}
157		return f
158	}
159	arg := func(key string) string {
160		s, _ := entry.ToolArgs[key].(string)
161		return s
162	}
163	switch entry.ToolName {
164	case "python":
165		f.py = append(f.py, cmp.Or(pyLabel(arg("code")), "cell"))
166	case "shell", "bash":
167		f.ran = append(f.ran, cmp.Or(commandName(arg("command")), entry.ToolName))
168	case "read_file":
169		f.read = append(f.read, prepareTarget(arg("path")))
170	case "write_file", "edit_file":
171		f.edited = append(f.edited, prepareTarget(arg("path")))
172	default:
173		f.used = append(f.used, entry.ToolName)
174	}
175	return f
176}
177
178type burst struct {
179	read      tally
180	edited    tally
181	searched  tally
182	looked    []lookup
183	ran       tally
184	py        tally
185	used      tally
186	changes   []daemon.FileChange
187	failed    []HistoryEntry
188	thoughtMs int64
189	thoughts  int
190	added     int
191	removed   int
192	untimed   bool // a thought from before this client watched has no duration
193}
194
195// merge folds one entry's facts into a burst.
196func (b *burst) merge(f entryFacts, entry HistoryEntry) {
197	if f.thoughtMs != 0 || f.untimed {
198		b.thoughts++
199		b.thoughtMs += f.thoughtMs
200		b.untimed = b.untimed || f.untimed
201	}
202	b.read.addAll(f.read)
203	b.edited.addAll(f.edited)
204	b.searched.addAll(f.searched)
205	b.looked = append(b.looked, f.looked...)
206	b.ran.addAll(f.ran)
207	b.py.addAll(f.py)
208	b.used.addAll(f.used)
209	b.added += f.added
210	b.removed += f.removed
211	b.changes = append(b.changes, f.changes...)
212	if f.failed {
213		b.failed = append(b.failed, entry)
214	}
215}
216
217// collect merges every entry's facts; entries settled through the chat carry
218// theirs already, so this only pays for tool results once per entry.
219func collect(entries []HistoryEntry) burst {
220	var b burst
221	for _, entry := range entries {
222		b.merge(factsOf(entry), entry)
223	}
224	return b
225}
226
227// pyLabel is what a python cell that ran nothing external was doing: the
228// first line of it, folded and capped.
229func pyLabel(code string) string {
230	line := oneLine(firstLine(code))
231	return ansi.Truncate(line, 48, "…")
232}
233
234// clause is a verb and what it acted on: n things, noun in the plural,
235// listed as items. shown items fit, the rest count; none shown is a count.
236type clause struct {
237	verb, noun string
238	items      []string
239	n          int
240	shown      int
241}
242
243func (c clause) String() string {
244	if c.shown == 0 {
245		noun := c.noun
246		if c.n == 1 {
247			noun = strings.TrimSuffix(noun, "s")
248		}
249		return fmt.Sprintf("%s %d %s", c.verb, c.n, noun)
250	}
251	text := c.verb + " " + strings.Join(c.items[:c.shown], ", ")
252	if hidden := len(c.items) - c.shown; hidden > 0 {
253		text += fmt.Sprintf(" +%d", hidden)
254	}
255	return text
256}
257
258// summary is the burst's one row: what it thought, read, changed, and ran,
259// with the lists shortened until the row fits width.
260func (r TranscriptRenderer) summary(b burst, width int, n namer) string {
261	var head []string
262	if b.thoughts > 0 {
263		thought := "thought"
264		if !b.untimed && b.thoughtMs >= 1000 {
265			thought += " " + formatElapsed(b.thoughtMs)
266		}
267		head = append(head, thought)
268	}
269	// naming can make two recorded paths the same ("cli/a.go" and the absolute
270	// form), so lists dedupe after naming, and reads defer to edits in that
271	// same named space
272	edited := dedup(n.all(b.edited.keys))
273	reads := slices.DeleteFunc(dedup(n.all(b.read.keys)), func(p string) bool { return slices.Contains(edited, p) })
274	var clauses []*clause
275	for _, c := range []*clause{
276		{verb: "read", noun: "files", n: len(reads), items: pathGroups(reads)},
277		{verb: "searched", noun: "patterns", n: len(b.searched.keys), items: dedup(n.all(b.searched.keys))},
278		{verb: "looked up", noun: "searches", n: len(b.looked), items: queries(b.looked)},
279		{verb: "edited", noun: "files", n: len(edited), items: pathGroups(edited)},
280		{verb: "ran", noun: "commands", n: len(b.ran.keys), items: b.ran.counted()},
281		{verb: "python", noun: "cells", n: len(b.py.keys), items: b.py.counted()},
282		{verb: "used", noun: "tools", n: len(b.used.keys), items: b.used.counted()},
283	} {
284		if len(c.items) > 0 {
285			c.shown = len(c.items)
286			clauses = append(clauses, c)
287		}
288	}
289	line := func() string {
290		parts := slices.Clone(head)
291		for _, c := range clauses {
292			parts = append(parts, c.String())
293		}
294		return strings.Join(parts, " · ")
295	}
296	tail := ""
297	if n := len(b.failed); n > 0 {
298		tail += DefaultStyles.Error.Render(fmt.Sprintf(" · %d failed", n))
299	}
300	if b.added+b.removed > 0 {
301		tail += "  " + r.diffCounts(daemon.FileChange{Added: b.added, Removed: b.removed})
302	}
303	// shorten the longest list first; once each shows one item, the lists
304	// become counts, what was read before what was edited
305	room := width - ansi.StringWidth(tail)
306	for ansi.StringWidth(line()) > room && shorten(clauses) {
307	}
308	return fit(DefaultStyles.Faint.Render(line()), tail, width)
309}
310
311// queries names each search by its query, quoted, on one line.
312func queries(looked []lookup) []string {
313	names := make([]string, len(looked))
314	for i, look := range looked {
315		names[i] = "“" + oneLine(look.query) + "”"
316	}
317	return dedup(names)
318}
319
320// A web search's answer shows its first lookupRows rows under the burst and a
321// click target for the rest.
322const lookupRows = 4
323
324// foldLookup is a search's answer under its burst: the first rows, then a
325// row that opens the rest until its key is in Open.
326func (r TranscriptRenderer) foldLookup(look lookup, width int) []string {
327	if look.detail == "" {
328		return nil
329	}
330	rows := r.faintMarkdownRows(look.detail, width-2)
331	for i := range rows {
332		rows[i] = markChrome + "  " + rows[i]
333	}
334	if len(rows) <= lookupRows+foldSlack {
335		return rows
336	}
337	if r.Open[look.key] {
338		return append(rows, r.foldRow(look.key, "  "+toggleOpen+"show less"))
339	}
340	label := fmt.Sprintf("  %s%d more lines · click to expand", toggleClosed, len(rows)-lookupRows)
341	return append(rows[:lookupRows:lookupRows], r.foldRow(look.key, label))
342}
343
344// countedFirst ranks which list turns into a count first.
345var countedFirst = map[string]int{"read": 0, "searched": 1, "used": 2, "ran": 3, "python": 4, "looked up": 5, "edited": 6}
346
347// shorten hides one more item, and reports whether any was left to hide.
348func shorten(clauses []*clause) bool {
349	var pick *clause
350	for _, c := range clauses {
351		if c.shown > 1 && (pick == nil || c.shown > pick.shown) {
352			pick = c
353		}
354	}
355	if pick == nil {
356		for _, c := range clauses {
357			if c.shown == 1 && (pick == nil || countedFirst[c.verb] < countedFirst[pick.verb]) {
358				pick = c
359			}
360		}
361	}
362	if pick == nil {
363		return false
364	}
365	pick.shown--
366	return true
367}
368
369// A burst's summary leads with a triangle when it has calls to list.
370const (
371	toggleClosed = "▸ "
372	toggleOpen   = "▾ "
373)
374
375// RenderBurst is a burst's summary row, its steps when it is open, then the
376// row of each call that failed, and with diffs shown, each change.
377func (r TranscriptRenderer) RenderBurst(entries []HistoryEntry, flags DisplayFlags, width int) string {
378	b, n := collect(entries), r.namer()
379	key := factsOf(entries[0]).key
380	opens := slices.ContainsFunc(entries, func(e HistoryEntry) bool { return e.Kind == EntryTool })
381	open := opens && r.Open[key]
382	head, room := markChrome, width
383	if opens {
384		// the triangle says the row opens, and which way it is now
385		toggle := toggleClosed
386		if open {
387			toggle = toggleOpen
388		}
389		head += rowAction{verbOpen, key}.mark() + DefaultStyles.Faint.Render(toggle)
390		room -= ansi.StringWidth(toggle)
391	}
392	rows := []string{head + r.summary(b, room, n)}
393	if open {
394		// failed calls are among the steps
395		for _, step := range r.steps(entries, width-2) {
396			rows = append(rows, "  "+step)
397		}
398	} else {
399		for _, entry := range b.failed {
400			rows = append(rows, markChrome+DefaultStyles.Error.Render(toolRow(entry, true, "", width)))
401		}
402	}
403	for _, look := range b.looked {
404		rows = append(rows, r.foldLookup(look, width)...)
405	}
406	if flags.Diffs {
407		for _, change := range b.changes {
408			if change.Kind == "diff" {
409				rows = append(rows, r.renderDiffPath(change.Diff, change.Path, width))
410			} else {
411				rows = append(rows, DefaultStyles.Faint.Render(n.name(prepareTarget(change.Path))+": "+change.Reason))
412			}
413		}
414	}
415	return strings.Join(rows, "\n")
416}
417
418// steps is one row for each thing a burst's calls did, in order: the full
419// command, pattern, or path, which the summary row only counts.
420func (r TranscriptRenderer) steps(entries []HistoryEntry, width int) []string {
421	var rows []string
422	for _, entry := range entries {
423		if entry.Kind != EntryTool {
424			continue
425		}
426		trace := entry.ToolTrace
427		if trace == nil || len(trace.Activities)+len(trace.Changes) == 0 {
428			failed, style := toolFailed(entry), DefaultStyles.Faint
429			if failed {
430				style = DefaultStyles.Error
431			}
432			rows = append(rows, style.Render(toolRow(entry, failed, "", width)))
433			continue
434		}
435		for _, act := range trace.Activities {
436			style := DefaultStyles.Faint
437			rows = append(rows, style.Render(r.traceLine(stepLabel(act.Kind), act.Target, isPath(act), width)))
438		}
439		for _, change := range trace.Changes {
440			counts := ""
441			if change.Kind == "diff" {
442				counts = "  " + r.diffCounts(change)
443			}
444			line := r.traceLine(stepLabel("edit"), change.Path, true, width-ansi.StringWidth(counts))
445			rows = append(rows, fit(DefaultStyles.Faint.Render(line), counts, width))
446		}
447	}
448	return rows
449}
450
451// stepLabel pads a step's kind so the targets after it line up.
452func stepLabel(kind string) string {
453	return fmt.Sprintf("%-6s", kind)
454}
455
456// pathGroups names paths by their parent and name, and paths that share a
457// parent together: `tui/{chat,transcript}.go`.
458func pathGroups(paths []string) []string {
459	var dirs []string
460	names := map[string][]string{}
461	for _, p := range paths {
462		dir, name := path.Split(strings.TrimSuffix(p, "/"))
463		if strings.HasSuffix(p, "/") {
464			name += "/"
465		}
466		if _, seen := names[dir]; !seen {
467			dirs = append(dirs, dir)
468		}
469		names[dir] = append(names[dir], name)
470	}
471	labels := make([]string, len(dirs))
472	parents := map[string]int{}
473	for _, dir := range dirs {
474		parents[parentName(dir)]++
475	}
476	for i, dir := range dirs {
477		parent := parentName(dir)
478		if parents[parent] > 1 {
479			parent = dir
480		}
481		labels[i] = parent + braced(names[dir])
482	}
483	return labels
484}
485
486// parentName is the last directory of dir with its slash, or nothing at the root.
487func parentName(dir string) string {
488	if dir = strings.TrimSuffix(dir, "/"); dir == "" {
489		return ""
490	}
491	parent := path.Base(dir) + "/"
492	if strings.HasPrefix(dir, "/") {
493		parent = "/" + parent
494	}
495	return parent
496}
497
498// braced joins names in braces, a shared extension outside them.
499func braced(names []string) string {
500	if len(names) == 1 {
501		return names[0]
502	}
503	ext := path.Ext(names[0])
504	stems := make([]string, len(names))
505	for i, name := range names {
506		if ext == "" || path.Ext(name) != ext || strings.HasSuffix(name, "/") {
507			return "{" + strings.Join(names, ",") + "}"
508		}
509		stems[i] = strings.TrimSuffix(name, ext)
510	}
511	return "{" + strings.Join(stems, ",") + "}" + ext
512}
513
514var (
515	commandBreak = regexp.MustCompile(`&&|\|\||[;|\n]`)
516	assignment   = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`)
517	subcommand   = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
518	// setup that comes before the command a line is about
519	preamble = wordSet("cd", "export", "set", "source", ".")
520	// programs whose first word names what they do; for the rest it is data,
521	// like grep's pattern
522	subcommanded = wordSet("git", "jj", "go", "cargo", "gleam", "npm", "pnpm", "yarn", "bun", "uv",
523		"pip", "nix", "brew", "docker", "kubectl", "systemctl", "tg", "gh", "make", "just")
524)
525
526func wordSet(names ...string) map[string]bool {
527	m := make(map[string]bool, len(names))
528	for _, name := range names {
529		m[name] = true
530	}
531	return m
532}
533
534// commandName is what a command line runs, the program and its subcommand
535// when it has one: `cd cli && go test ./...` ran `go test`.
536func commandName(command string) string {
537	for _, part := range commandBreak.Split(strings.ReplaceAll(command, "\\\n", ""), -1) {
538		fields := strings.Fields(part)
539		for len(fields) > 0 && (assignment.MatchString(fields[0]) || fields[0] == "exec" || fields[0] == "time") {
540			fields = fields[1:]
541		}
542		if len(fields) == 0 || preamble[fields[0]] {
543			continue
544		}
545		name := fields[0]
546		if strings.HasPrefix(name, "/") {
547			name = path.Base(name)
548		}
549		if len(fields) > 1 && subcommanded[name] && subcommand.MatchString(fields[1]) {
550			name += " " + fields[1]
551		}
552		return name
553	}
554	return oneLine(command)
555}
556
557// thinkingLine follows the newest nonempty line, even when thinking arrives
558// in fragments or ends in a newline; one-line summaries still show their header.
559func thinkingLine(text string) string {
560	for text != "" {
561		at := strings.LastIndexByte(text, '\n')
562		line := strings.TrimLeft(strings.TrimSpace(text[at+1:]), "#> ")
563		if line = oneLine(strings.NewReplacer("**", "", "__", "").Replace(line)); line != "" {
564			return line
565		}
566		if at < 0 {
567			break
568		}
569		text = text[:at]
570	}
571	return ""
572}