pi-zen 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,190 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ import { ROW_MARKER, type RowPalette } from "./tool-row.ts";
4
+
5
+ /**
6
+ * Labels a folded call can carry.
7
+ *
8
+ * Each one is the verb the call's own row would have shown, so a group line and
9
+ * an ungrouped row name the same activity the same way.
10
+ */
11
+ export const GROUP_LABELS = ["read", "list", "find", "grep", "run"] as const;
12
+
13
+ /** One folded-call label. */
14
+ export type GroupLabel = (typeof GROUP_LABELS)[number];
15
+
16
+ /** How many calls of each label a group has folded. */
17
+ export type GroupCounts = ReadonlyMap<GroupLabel, number>;
18
+
19
+ const SEPARATOR = " · ";
20
+ const ELLIPSIS = "…";
21
+
22
+ type RankedEntry = {
23
+ readonly label: GroupLabel;
24
+ readonly count: number;
25
+ };
26
+
27
+ function ranked(counts: GroupCounts): RankedEntry[] {
28
+ const entries: RankedEntry[] = [];
29
+ for (const label of GROUP_LABELS) {
30
+ const count = counts.get(label) ?? 0;
31
+ if (count > 0) entries.push({ label, count });
32
+ }
33
+ // Busiest label first. Sort is stable, so equal counts keep GROUP_LABELS order.
34
+ return entries.sort((left, right) => right.count - left.count);
35
+ }
36
+
37
+ /**
38
+ * Format the one line that stands in for a run of folded calls.
39
+ *
40
+ * A run of a single kind needs no breakdown, so it reads `- 3 read`. A mixed run
41
+ * leads with the total and then names each kind, busiest first. As the line
42
+ * narrows it drops whole segments from the end rather than truncating mid-count,
43
+ * because a half-written number says less than nothing.
44
+ *
45
+ * @param counts - How many calls of each label the group folded.
46
+ * @param width - Visible terminal width.
47
+ * @param palette - Theme slice used to color the line.
48
+ * @returns One line, never wider than `width`, empty when nothing was folded.
49
+ */
50
+ export function formatGroupLine(counts: GroupCounts, width: number, palette: RowPalette): string {
51
+ if (width <= 0) return "";
52
+
53
+ const entries = ranked(counts);
54
+ const [only] = entries;
55
+ if (only === undefined) return "";
56
+
57
+ const total = entries.reduce((sum, entry) => sum + entry.count, 0);
58
+ const segments =
59
+ entries.length === 1
60
+ ? [`${total} ${only.label}`]
61
+ : [`${total} calls`, ...entries.map((entry) => `${entry.count} ${entry.label}`)];
62
+
63
+ const prefix = ROW_MARKER + " ";
64
+ const room = width - visibleWidth(prefix);
65
+ if (room <= 0) return "";
66
+
67
+ let kept = segments.length;
68
+ while (kept > 1 && visibleWidth(segments.slice(0, kept).join(SEPARATOR)) > room) kept -= 1;
69
+ const body = segments.slice(0, kept).join(SEPARATOR);
70
+ const text = visibleWidth(body) <= room ? body : truncateToWidth(body, room, ELLIPSIS);
71
+
72
+ return palette.fg("dim", ROW_MARKER) + " " + palette.fg("muted", text);
73
+ }
74
+
75
+ /**
76
+ * A run of folded calls that share one line.
77
+ *
78
+ * The line is rendered by the run's leading member, so it lands where the first
79
+ * of these calls appears in the transcript. A member that turns out to need a row
80
+ * of its own leaves the run, and the lead passes to the next surviving member —
81
+ * which keeps the line below any failure row that displaced it.
82
+ */
83
+ export class CallGroup {
84
+ private readonly counts = new Map<GroupLabel, number>();
85
+ private readonly members: number[] = [];
86
+ private nextId = 0;
87
+
88
+ /**
89
+ * Join the run.
90
+ *
91
+ * @returns The new member's id.
92
+ */
93
+ join(): number {
94
+ const id = this.nextId;
95
+ this.nextId += 1;
96
+ this.members.push(id);
97
+ return id;
98
+ }
99
+
100
+ /**
101
+ * Leave the run, without being counted in it.
102
+ *
103
+ * @param id - The member id returned by `join`.
104
+ */
105
+ leave(id: number): void {
106
+ const at = this.members.indexOf(id);
107
+ if (at !== -1) this.members.splice(at, 1);
108
+ }
109
+
110
+ /**
111
+ * Whether this member renders the run's line.
112
+ *
113
+ * @param id - The member id returned by `join`.
114
+ * @returns True for the earliest member still in the run.
115
+ */
116
+ leads(id: number): boolean {
117
+ return this.members[0] === id;
118
+ }
119
+
120
+ /**
121
+ * Count one settled call.
122
+ *
123
+ * @param label - The verb the call would have shown on its own row.
124
+ */
125
+ add(label: GroupLabel): void {
126
+ this.counts.set(label, (this.counts.get(label) ?? 0) + 1);
127
+ }
128
+
129
+ /**
130
+ * How many calls the run has counted.
131
+ *
132
+ * @returns The total across every label.
133
+ */
134
+ total(): number {
135
+ let total = 0;
136
+ for (const count of this.counts.values()) total += count;
137
+ return total;
138
+ }
139
+
140
+ /**
141
+ * The counts the run's line renders from.
142
+ *
143
+ * This is the run's live map, not a copy: the line is rendered by the leading
144
+ * call, and it has to keep growing as the calls behind it settle.
145
+ *
146
+ * @returns The current counts.
147
+ */
148
+ snapshot(): GroupCounts {
149
+ return this.counts;
150
+ }
151
+ }
152
+
153
+ /** A folded call's membership of a run. */
154
+ export type GroupSlot = {
155
+ /** The run this call joined. */
156
+ readonly group: CallGroup;
157
+ /** This call's member id within the run. */
158
+ readonly id: number;
159
+ };
160
+
161
+ /**
162
+ * Tracks which run of folded calls is still open.
163
+ *
164
+ * Reads, listings, searches, and commands are how the model looks around; they
165
+ * are rarely what the user came to see. One open run swallows them all until
166
+ * something worth a line of its own happens — a code edit, a failure, or the end
167
+ * of the turn — after which the next folded call starts a fresh run.
168
+ */
169
+ export class CallGrouper {
170
+ private open: CallGroup | undefined;
171
+
172
+ /**
173
+ * Join a call to the open run, opening one if there is none.
174
+ *
175
+ * Called while the call is still streaming, so a run's line lands at the
176
+ * position of the first call in the run rather than the first one to finish.
177
+ *
178
+ * @returns The call's slot in the run.
179
+ */
180
+ claim(): GroupSlot {
181
+ const group = this.open ?? new CallGroup();
182
+ this.open = group;
183
+ return { group, id: group.join() };
184
+ }
185
+
186
+ /** End the open run, so the next folded call starts a new line. */
187
+ close(): void {
188
+ this.open = undefined;
189
+ }
190
+ }