pi-minimalist 0.2.0 → 0.2.2
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.
- package/README.md +3 -3
- package/index.ts +2 -9
- package/package.json +1 -1
- package/src/bridge.ts +76 -55
- package/src/components.ts +97 -32
- package/src/config-file.ts +7 -83
- package/src/config-ui.ts +0 -6
- package/src/config.ts +11 -33
- package/src/core-patch.ts +167 -178
- package/src/row.ts +44 -28
- package/src/run-grouping.ts +98 -52
- package/src/thinking.ts +5 -8
- package/src/tool-renderer.ts +21 -10
- package/src/tools.ts +57 -42
package/src/row.ts
CHANGED
|
@@ -4,15 +4,11 @@
|
|
|
4
4
|
* TWO RULES THIS FILE ENFORCES
|
|
5
5
|
* ---------------------------
|
|
6
6
|
* 1. Rows are described as DATA and painted exactly once. Nothing downstream may
|
|
7
|
-
* re-parse painted text
|
|
8
|
-
*
|
|
9
|
-
* assumed no label ever contains a space; run summaries were joined into
|
|
10
|
-
* `"read ×2, edit ×1"` and split back apart the same way.
|
|
7
|
+
* re-parse painted text (splitting `"read src/a.ts"` on the first space
|
|
8
|
+
* assumes no label contains a space).
|
|
11
9
|
*
|
|
12
|
-
* 2. Theme and settings are bound ONCE, in a `Painter
|
|
13
|
-
*
|
|
14
|
-
* functions, which made painting depend on load order and let one test leak
|
|
15
|
-
* settings into the next.
|
|
10
|
+
* 2. Theme and settings are bound ONCE, in a `Painter`, so painting never
|
|
11
|
+
* depends on load order and one test cannot leak settings into the next.
|
|
16
12
|
*
|
|
17
13
|
* Free of Pi extension-API runtime imports, so tests exercise real painting
|
|
18
14
|
* without an extension runtime.
|
|
@@ -20,12 +16,8 @@
|
|
|
20
16
|
|
|
21
17
|
// Type-only: erased at runtime, but makes a mistyped token ("succes") a compile
|
|
22
18
|
// error instead of a theme.fg() throw that core turns into a verbose card.
|
|
23
|
-
import type {
|
|
24
|
-
import type
|
|
25
|
-
|
|
26
|
-
// ThemeBg is not exported publicly, so recover it from Theme.bg's own signature
|
|
27
|
-
// rather than importing a deep dist path (which is not a package export).
|
|
28
|
-
type ThemeBg = Parameters<Theme["bg"]>[0];
|
|
19
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { OPEN_MARKER, type Config } from "./config.ts";
|
|
29
21
|
|
|
30
22
|
/**
|
|
31
23
|
* Minimal theme surface. Pi's real Theme satisfies it; tests pass a fake that
|
|
@@ -37,7 +29,6 @@ type ThemeBg = Parameters<Theme["bg"]>[0];
|
|
|
37
29
|
*/
|
|
38
30
|
export type ThemeLike = {
|
|
39
31
|
fg(token: ThemeColor, text: string): string;
|
|
40
|
-
bg(token: ThemeBg, text: string): string;
|
|
41
32
|
};
|
|
42
33
|
|
|
43
34
|
/** One painted row. `text` is already colored; truncation happens at render. */
|
|
@@ -46,16 +37,21 @@ export type Row = {
|
|
|
46
37
|
gutter: string;
|
|
47
38
|
/** Fully colored row content. */
|
|
48
39
|
text: string;
|
|
49
|
-
/**
|
|
50
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Set on an expanded row: wrap instead of truncating. `head` is the colored
|
|
42
|
+
* `glyph label` prefix (no timer badge, whose width changes) and `body` the
|
|
43
|
+
* colored `[badge] details`; continuation lines are indented under `head`.
|
|
44
|
+
*/
|
|
45
|
+
wrap?: { head: string; body: string };
|
|
46
|
+
/** Header line drawn above an opened run's first row (then one blank line); clicking it folds the run back. */
|
|
47
|
+
header?: Row;
|
|
51
48
|
};
|
|
52
49
|
|
|
53
50
|
/**
|
|
54
51
|
* Visible columns a gutter occupies: one space, the glyph, one space.
|
|
55
52
|
*
|
|
56
|
-
* DERIVED from the row's own gutter text, never configured
|
|
57
|
-
*
|
|
58
|
-
* shift every row.
|
|
53
|
+
* DERIVED from the row's own gutter text, never configured, so it cannot
|
|
54
|
+
* disagree with the real glyph width and shift every row.
|
|
59
55
|
*/
|
|
60
56
|
export function gutterWidth(gutter: string): number {
|
|
61
57
|
return gutter === "" ? 0 : 3;
|
|
@@ -78,8 +74,8 @@ export type Labeled = {
|
|
|
78
74
|
badge?: string;
|
|
79
75
|
/** Path, command, or preview text. */
|
|
80
76
|
details?: string;
|
|
81
|
-
/**
|
|
82
|
-
|
|
77
|
+
/** Wrap long details onto more lines instead of truncating (expanded tool rows). */
|
|
78
|
+
wrap?: boolean;
|
|
83
79
|
};
|
|
84
80
|
|
|
85
81
|
/** One folded name and how many times it appeared in a run. */
|
|
@@ -101,8 +97,6 @@ export type RunSummary = {
|
|
|
101
97
|
running: Count[];
|
|
102
98
|
};
|
|
103
99
|
|
|
104
|
-
export const EMPTY_SUMMARY: RunSummary = { done: [], failed: [], running: [] };
|
|
105
|
-
|
|
106
100
|
/**
|
|
107
101
|
* Paints rows for one theme and one settings object.
|
|
108
102
|
*
|
|
@@ -144,14 +138,24 @@ export class Painter {
|
|
|
144
138
|
const parts: string[] = [];
|
|
145
139
|
if (spec.glyph) parts.push(this.theme.fg(spec.glyphColor ?? tokens.label, spec.glyph));
|
|
146
140
|
parts.push(this.theme.fg(labelColor, spec.label));
|
|
147
|
-
|
|
148
|
-
|
|
141
|
+
// The wrap indent is `glyph label` ONLY: the timer badge changes width every
|
|
142
|
+
// 1s/10s/100s and must not shift (re-wrap) the continuation lines.
|
|
143
|
+
const head = parts.join(" ");
|
|
144
|
+
// `text` is always ONE line. Details with real newlines (an expanded
|
|
145
|
+
// command) are flattened for it and kept for `wrap`, colored line by line so
|
|
146
|
+
// no color escape spans a newline.
|
|
147
|
+
const details = spec.details ?? "";
|
|
148
|
+
const flat = details.includes("\n") ? details.replace(/\s+/g, " ").trim() : details;
|
|
149
|
+
const paintDetails = (text: string) => this.theme.fg(tokens.details, text);
|
|
150
|
+
const badge = spec.badge ? this.theme.fg(tokens.label, spec.badge) : "";
|
|
151
|
+
const rest = [badge, flat && paintDetails(flat)].filter(Boolean);
|
|
152
|
+
const wrapBody = [badge, details && details.split("\n").map(paintDetails).join("\n")].filter(Boolean);
|
|
149
153
|
return {
|
|
150
154
|
// The gutter follows the label's hue, so thinking rows stay purple and tool
|
|
151
155
|
// rows stay green without either caller naming a gutter color.
|
|
152
156
|
gutter: this.gutter(labelColor),
|
|
153
|
-
text:
|
|
154
|
-
|
|
157
|
+
text: [head, ...rest].join(" "),
|
|
158
|
+
wrap: spec.wrap && spec.details ? { head, body: wrapBody.join(" ") } : undefined,
|
|
155
159
|
};
|
|
156
160
|
}
|
|
157
161
|
|
|
@@ -176,6 +180,18 @@ export class Painter {
|
|
|
176
180
|
return { gutter: this.gutter(label), text: groups.join(this.theme.fg(details, " · ")) };
|
|
177
181
|
}
|
|
178
182
|
|
|
183
|
+
/**
|
|
184
|
+
* The header of an opened run: a muted control line, not data, so it carries
|
|
185
|
+
* no counts. Clicking it folds the run back. ASCII mode swaps `·` for `-`.
|
|
186
|
+
* No gutter bar: blank padding of the gutter width keeps its text aligned with
|
|
187
|
+
* the rows below it (no padding at all when the gutter is off).
|
|
188
|
+
*/
|
|
189
|
+
header(): Row {
|
|
190
|
+
const ascii = this.config.get("glyphStyle") === "ascii";
|
|
191
|
+
const text = `${OPEN_MARKER[this.config.get("glyphStyle")]} Expanded ${ascii ? "-" : "·"} click to fold`;
|
|
192
|
+
return { gutter: this.config.get("gutter") ? " " : "", text: this.theme.fg("muted", text) };
|
|
193
|
+
}
|
|
194
|
+
|
|
179
195
|
private counts(counts: Count[], color: ThemeColor): string {
|
|
180
196
|
const tokens = this.config.tokens();
|
|
181
197
|
const glyphs = this.config.glyphs();
|
package/src/run-grouping.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Folding adjacent low-noise rows into one summary (the `groupToolRuns` setting).
|
|
3
3
|
*
|
|
4
|
-
* Formerly `quiet-mode.ts` / `QuietMode`, named after a `/quiet` command that no
|
|
5
|
-
* longer exists.
|
|
6
|
-
*
|
|
7
4
|
* WHY THIS STATE EXISTS AT ALL
|
|
8
5
|
* ---------------------------
|
|
9
6
|
* Each tool row is an independent transcript entry that knows nothing about its
|
|
@@ -19,7 +16,8 @@ import type { Count, Painter, Row, RunSummary } from "./row.ts";
|
|
|
19
16
|
|
|
20
17
|
export type Outcome = "success" | "failure" | "pending";
|
|
21
18
|
|
|
22
|
-
|
|
19
|
+
/** "other" = visible in the transcript but not ours to fold (user message, excluded tool card). */
|
|
20
|
+
type EntryKind = "tool" | "thinking" | "prose" | "other";
|
|
23
21
|
|
|
24
22
|
type Entry = {
|
|
25
23
|
id: string;
|
|
@@ -33,11 +31,13 @@ type Entry = {
|
|
|
33
31
|
foldable: boolean;
|
|
34
32
|
};
|
|
35
33
|
|
|
36
|
-
type Cycle = { startedAt: number; settledAt?: number
|
|
34
|
+
type Cycle = { startedAt: number; settledAt?: number };
|
|
37
35
|
|
|
38
36
|
/** "show" keeps the row as-is, "hide" draws nothing, a summary folds a whole run. */
|
|
39
37
|
export type RowView = "show" | "hide" | RunSummary;
|
|
40
38
|
type ActivityState = "show" | "hide" | "summary";
|
|
39
|
+
/** A run the user opened. Every member id maps to the same object. */
|
|
40
|
+
type OpenRun = { members: Entry[]; activity: boolean };
|
|
41
41
|
type ViewCache = {
|
|
42
42
|
key: string;
|
|
43
43
|
activity: Map<string, ActivityState>;
|
|
@@ -51,12 +51,14 @@ export class RunGrouping {
|
|
|
51
51
|
private byId = new Map<string, Entry>();
|
|
52
52
|
private ownerIds = new WeakMap<object, number>();
|
|
53
53
|
private ownerEntries = new Map<number, Set<string>>();
|
|
54
|
+
/** Message identity per owner. Rebuilt components of one message share it, so they share entries. */
|
|
55
|
+
private messageKeys = new WeakMap<object, string>();
|
|
54
56
|
private nextOwnerId = 1;
|
|
55
57
|
private cycle = 0;
|
|
56
58
|
private cycles = new Map<number, Cycle>();
|
|
57
59
|
private agentActive = false;
|
|
58
|
-
/** Rows the user opened out of a summary by clicking it.
|
|
59
|
-
private opened = new
|
|
60
|
+
/** Rows the user opened out of a summary by clicking it. They stay out of folds until `close()`. */
|
|
61
|
+
private opened = new Map<string, OpenRun>();
|
|
60
62
|
private version = 0;
|
|
61
63
|
private cache?: ViewCache;
|
|
62
64
|
|
|
@@ -73,7 +75,7 @@ export class RunGrouping {
|
|
|
73
75
|
// Replay leaves `cycle` pointing at the next empty slot. A subsequent live
|
|
74
76
|
// interaction can use it directly; consecutive live interactions advance.
|
|
75
77
|
if (this.entries.some((entry) => entry.cycle === this.cycle)) this.cycle++;
|
|
76
|
-
this.cycles.set(this.cycle, { startedAt: now
|
|
78
|
+
this.cycles.set(this.cycle, { startedAt: now });
|
|
77
79
|
this.agentActive = true;
|
|
78
80
|
this.invalidateViews();
|
|
79
81
|
}
|
|
@@ -116,50 +118,49 @@ export class RunGrouping {
|
|
|
116
118
|
this.invalidateViews();
|
|
117
119
|
}
|
|
118
120
|
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Record something visible that we never fold or draw (a user message, an
|
|
123
|
+
* excluded tool card). It only exists to cut runs, so no summary jumps over it.
|
|
124
|
+
*/
|
|
125
|
+
observeBarrier(id: string): void {
|
|
126
|
+
this.observe(id, "other", "success", false, false, "other");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Observe a thinking block by its message identity and local run index. */
|
|
130
|
+
observeThinking(owner: object, runIndex: number, done: boolean, expanded: boolean, timestamp?: number): void {
|
|
131
|
+
this.identify(owner, timestamp);
|
|
121
132
|
const id = this.thinkingId(owner, runIndex);
|
|
122
133
|
this.rememberOwnerEntry(owner, id);
|
|
123
134
|
this.observe(id, "think", done ? "success" : "pending", expanded, true, "thinking");
|
|
124
135
|
}
|
|
125
136
|
|
|
126
|
-
/**
|
|
127
|
-
* Record prose and return its stable id. `stopReason=stop` is available on the
|
|
128
|
-
* first empty final-answer update, before OpenAI streams its first text delta;
|
|
129
|
-
* the finalized block later confirms the same fact through `phase`.
|
|
130
|
-
*/
|
|
137
|
+
/** Record prose and return its stable id. `stopReason=stop` marks a final answer. */
|
|
131
138
|
observeProse(
|
|
132
139
|
owner: object,
|
|
133
140
|
contentIndex: number,
|
|
134
141
|
signal: {
|
|
135
|
-
phase?: "commentary" | "final_answer";
|
|
136
142
|
stopReason?: string;
|
|
137
143
|
streaming?: boolean;
|
|
138
144
|
timestamp?: number;
|
|
139
145
|
} = {},
|
|
140
146
|
): string {
|
|
141
|
-
|
|
147
|
+
this.identify(owner, signal.timestamp);
|
|
148
|
+
const id = `prose:${this.messageKey(owner)}:${contentIndex}`;
|
|
142
149
|
const isNew = !this.byId.has(id);
|
|
143
150
|
const timestamp = signal.timestamp ?? Date.now();
|
|
144
151
|
if (!this.cycles.has(this.cycle)) {
|
|
145
|
-
this.cycles.set(this.cycle, { startedAt: timestamp
|
|
152
|
+
this.cycles.set(this.cycle, { startedAt: timestamp });
|
|
146
153
|
}
|
|
147
154
|
this.rememberOwnerEntry(owner, id);
|
|
148
155
|
this.observe(id, "prose", "pending", false, false, "prose");
|
|
149
156
|
|
|
150
|
-
const isFinal = signal.
|
|
151
|
-
const cycle = this.cycles.get(this.byId.get(id)!.cycle)!;
|
|
152
|
-
if (isFinal && !cycle.finalAnswerStarted) {
|
|
153
|
-
cycle.finalAnswerStarted = true;
|
|
154
|
-
this.invalidateViews();
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
+
const isFinal = signal.stopReason === "stop";
|
|
157
158
|
// session replay has no agent_start/agent_settled events. A newly observed,
|
|
158
159
|
// finalized answer is therefore the only reliable boundary between old user
|
|
159
160
|
// interactions. Advance AFTER assigning the answer so it remains the latest
|
|
160
161
|
// prose of its own cycle; stable ids prevent re-renders from advancing twice.
|
|
161
162
|
if (!this.agentActive && isNew && isFinal && !signal.streaming) {
|
|
162
|
-
cycle
|
|
163
|
+
this.cycles.get(this.byId.get(id)!.cycle)!.settledAt = timestamp;
|
|
163
164
|
this.cycle++;
|
|
164
165
|
}
|
|
165
166
|
return id;
|
|
@@ -193,7 +194,7 @@ export class RunGrouping {
|
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
thinkingId(owner: object, runIndex: number): string {
|
|
196
|
-
return `think:${this.
|
|
197
|
+
return `think:${this.messageKey(owner)}:${runIndex}`;
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
/** Hide a message's leading spacer only when every one of its rows is hidden. */
|
|
@@ -223,7 +224,7 @@ export class RunGrouping {
|
|
|
223
224
|
if (activity === "summary") return this.activitySummary(entry!, painter);
|
|
224
225
|
|
|
225
226
|
const view = this.view(id);
|
|
226
|
-
if (view === "show") return base();
|
|
227
|
+
if (view === "show") return entry ? this.withHeader(entry, base(), painter) : base();
|
|
227
228
|
if (view === "hide") return null;
|
|
228
229
|
return painter.summary(view);
|
|
229
230
|
}
|
|
@@ -240,44 +241,69 @@ export class RunGrouping {
|
|
|
240
241
|
if (!entry) return false;
|
|
241
242
|
const { activity, grouped } = this.views();
|
|
242
243
|
const members = [entry];
|
|
243
|
-
|
|
244
|
-
|
|
244
|
+
const isActivity = activity.get(id) === "summary";
|
|
245
|
+
if (isActivity) {
|
|
246
|
+
members.unshift(...this.entries.filter((e) => e.cycle === entry.cycle && activity.get(e.id) === "hide"));
|
|
245
247
|
} else if (typeof grouped.get(id) === "object") {
|
|
246
248
|
// A run's hidden members are the contiguous "hide" rows before its tail.
|
|
247
249
|
for (let i = this.entries.indexOf(entry) - 1; i >= 0 && grouped.get(this.entries[i].id) === "hide"; i--) {
|
|
248
|
-
members.
|
|
250
|
+
members.unshift(this.entries[i]);
|
|
249
251
|
}
|
|
250
252
|
} else {
|
|
251
253
|
return false;
|
|
252
254
|
}
|
|
253
|
-
|
|
255
|
+
const run: OpenRun = { members, activity: isActivity };
|
|
256
|
+
for (const member of members) this.opened.set(member.id, run);
|
|
254
257
|
this.invalidateViews();
|
|
255
258
|
return true;
|
|
256
259
|
}
|
|
257
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Fold an opened run back, after a click on its header. `CompactLine` reports
|
|
263
|
+
* `onHeader` only for a row that drew the header, so `id` is that row.
|
|
264
|
+
*/
|
|
265
|
+
close(id: string): boolean {
|
|
266
|
+
const run = this.opened.get(id);
|
|
267
|
+
if (!run) return false;
|
|
268
|
+
for (const member of run.members) this.opened.delete(member.id);
|
|
269
|
+
this.invalidateViews();
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The click handler of a row drawn by `CompactLine`: header folds back, anything else opens. */
|
|
274
|
+
click(id: string, onHeader: boolean): boolean {
|
|
275
|
+
return onHeader ? this.close(id) : this.open(id);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The header of an opened run, on its first member only, while the fold that made it is on. */
|
|
279
|
+
headerFor(id: string, painter: Painter): Row | undefined {
|
|
280
|
+
const entry = this.byId.get(id);
|
|
281
|
+
const run = this.opened.get(id);
|
|
282
|
+
if (!entry || !run || run.members[0].id !== id || !(run.activity ? this.config.get("foldIntermediateActivity") : this.enabled)) {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
return painter.header();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private withHeader(entry: Entry, row: Row, painter: Painter): Row {
|
|
289
|
+
const header = this.headerFor(entry.id, painter);
|
|
290
|
+
return header ? { ...row, header } : row;
|
|
291
|
+
}
|
|
292
|
+
|
|
258
293
|
view(id: string): RowView {
|
|
259
294
|
return this.views().grouped.get(id) ?? "show";
|
|
260
295
|
}
|
|
261
296
|
|
|
262
|
-
/**
|
|
263
|
-
* Can this entry disappear into a run summary?
|
|
264
|
-
*
|
|
265
|
-
* A RUNNING entry folds by default: folds are recomputed on every render, so
|
|
266
|
-
* the row reappears the moment it matters, and the summary still counts it in
|
|
267
|
-
* its `running` group. `keepActiveToolsExpanded` opts out for people who want
|
|
268
|
-
* to watch a long command in place.
|
|
269
|
-
*/
|
|
270
297
|
/** Collapse every collapsible row before the latest prose into ONE activity summary. */
|
|
271
298
|
private activityState(entry: Entry | undefined): ActivityState {
|
|
272
299
|
return entry ? (this.views().activity.get(entry.id) ?? "show") : "show";
|
|
273
300
|
}
|
|
274
301
|
|
|
302
|
+
/** The `activitySummary` mode decides what a folded activity run shows. */
|
|
275
303
|
private activitySummary(entry: Entry, painter: Painter, now = Date.now()): Row {
|
|
276
|
-
const cycle = this.cycles.get(entry.cycle);
|
|
277
304
|
const actions = this.views().actions.get(entry.cycle);
|
|
278
|
-
if (this.config.get("activitySummary") === "tools" && actions)
|
|
279
|
-
|
|
280
|
-
}
|
|
305
|
+
if (this.config.get("activitySummary") === "tools" && actions) return painter.summary(actions);
|
|
306
|
+
const cycle = this.cycles.get(entry.cycle);
|
|
281
307
|
const elapsed = Math.max(0, (cycle?.settledAt ?? now) - (cycle?.startedAt ?? now));
|
|
282
308
|
return painter.labeled({ label: "Worked", details: `for ${formatDuration(elapsed)}` });
|
|
283
309
|
}
|
|
@@ -288,7 +314,6 @@ export class RunGrouping {
|
|
|
288
314
|
this.config.get("groupToolRuns"),
|
|
289
315
|
this.config.get("thinkingAsToolCall"),
|
|
290
316
|
this.config.get("foldIntermediateActivity"),
|
|
291
|
-
this.config.get("foldActivityOnFinalAnswer"),
|
|
292
317
|
this.config.get("keepActiveToolsExpanded"),
|
|
293
318
|
].join(":");
|
|
294
319
|
if (this.cache?.key === key) return this.cache;
|
|
@@ -304,7 +329,12 @@ export class RunGrouping {
|
|
|
304
329
|
continue;
|
|
305
330
|
}
|
|
306
331
|
let last = first + 1;
|
|
307
|
-
|
|
332
|
+
// A new agent cycle (user prompt) also cuts the run.
|
|
333
|
+
while (
|
|
334
|
+
last < this.entries.length &&
|
|
335
|
+
this.foldable(this.entries[last]) &&
|
|
336
|
+
this.entries[last].cycle === this.entries[first].cycle
|
|
337
|
+
) last++;
|
|
308
338
|
if (last - first > 1) {
|
|
309
339
|
for (let index = first; index < last - 1; index++) grouped.set(this.entries[index].id, "hide");
|
|
310
340
|
grouped.set(this.entries[last - 1].id, summarize(this.entries.slice(first, last)));
|
|
@@ -322,20 +352,18 @@ export class RunGrouping {
|
|
|
322
352
|
}
|
|
323
353
|
for (const [cycleId, entries] of byCycle) {
|
|
324
354
|
const prose = entries.filter((entry) => entry.kind === "prose");
|
|
325
|
-
|
|
326
|
-
if (
|
|
327
|
-
prose.length < 2 ||
|
|
328
|
-
(this.config.get("foldActivityOnFinalAnswer") && !cycle?.finalAnswerStarted)
|
|
329
|
-
) continue;
|
|
355
|
+
if (prose.length < 2) continue;
|
|
330
356
|
|
|
331
357
|
const beforeLatest = entries.slice(0, entries.indexOf(prose.at(-1)!));
|
|
358
|
+
// A cycle of consecutive prose only (no tool, no thinking) has nothing to fold.
|
|
359
|
+
if (!beforeLatest.some(isAction)) continue;
|
|
332
360
|
const collapsible = beforeLatest.filter((entry) => this.activityFoldable(entry));
|
|
333
361
|
for (let index = 0; index < collapsible.length - 1; index++) {
|
|
334
362
|
activity.set(collapsible[index].id, "hide");
|
|
335
363
|
}
|
|
336
364
|
const tail = collapsible.at(-1);
|
|
337
365
|
if (tail) activity.set(tail.id, "summary");
|
|
338
|
-
const actionEntries = beforeLatest.filter(
|
|
366
|
+
const actionEntries = beforeLatest.filter(isAction);
|
|
339
367
|
if (actionEntries.length > 0) actions.set(cycleId, summarize(actionEntries));
|
|
340
368
|
}
|
|
341
369
|
}
|
|
@@ -349,6 +377,7 @@ export class RunGrouping {
|
|
|
349
377
|
}
|
|
350
378
|
|
|
351
379
|
private activityFoldable(entry: Entry): boolean {
|
|
380
|
+
if (entry.kind === "other") return false;
|
|
352
381
|
if (entry.expanded || this.opened.has(entry.id)) return false;
|
|
353
382
|
if (entry.outcome === "pending" && entry.kind !== "prose" && this.config.get("keepActiveToolsExpanded")) {
|
|
354
383
|
return false;
|
|
@@ -373,6 +402,20 @@ export class RunGrouping {
|
|
|
373
402
|
entries.add(entryId);
|
|
374
403
|
}
|
|
375
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Ids follow the MESSAGE, not the component: Pi builds new components for the
|
|
407
|
+
* same messages on every rebuild (Ctrl+T, tree navigation), and ids tied to
|
|
408
|
+
* the dropped components would leave ghost entries counted in summaries.
|
|
409
|
+
* `timestamp` is set once when a message starts streaming and never changes.
|
|
410
|
+
*/
|
|
411
|
+
private identify(owner: object, timestamp?: number): void {
|
|
412
|
+
if (timestamp !== undefined) this.messageKeys.set(owner, `t${timestamp}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private messageKey(owner: object): string {
|
|
416
|
+
return this.messageKeys.get(owner) ?? `o${this.ownerId(owner)}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
376
419
|
private ownerId(owner: object): number {
|
|
377
420
|
let id = this.ownerIds.get(owner);
|
|
378
421
|
if (id === undefined) {
|
|
@@ -383,6 +426,9 @@ export class RunGrouping {
|
|
|
383
426
|
}
|
|
384
427
|
}
|
|
385
428
|
|
|
429
|
+
/** A tool or thinking row: what a "Worked for" fold and its tool counts are made of. */
|
|
430
|
+
const isAction = (entry: Entry) => entry.kind === "tool" || entry.kind === "thinking";
|
|
431
|
+
|
|
386
432
|
/** Group a run's entries by outcome, each in first-seen order. */
|
|
387
433
|
export function summarize(run: Entry[]): RunSummary {
|
|
388
434
|
const named = (outcome: Outcome) =>
|
package/src/thinking.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FEATURE 2: thinking blocks.
|
|
3
3
|
*
|
|
4
|
-
* Collapsed — one line replacing Pi's bare "Thinking..." label, installed
|
|
5
|
-
* `
|
|
4
|
+
* Collapsed — one line replacing Pi's bare "Thinking..." label, installed as
|
|
5
|
+
* `Bridge.thinkingPreview`:
|
|
6
6
|
*
|
|
7
7
|
* • think The user wants me to… ← while streaming
|
|
8
8
|
* ✓ think The user wants me to… ← once the message completes
|
|
9
9
|
*
|
|
10
10
|
* Expanded (Ctrl+T) — Pi's native italic Markdown, but with every token-specific
|
|
11
|
-
* color replaced by the thinking hue,
|
|
11
|
+
* color replaced by the thinking hue, as `Bridge.thinkingMarkdownTheme`.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { CompactLine, realTimers, type Timers } from "./components.ts";
|
|
@@ -20,7 +20,6 @@ import type { Config } from "./config.ts";
|
|
|
20
20
|
export type ThinkingPreview = (
|
|
21
21
|
text: string,
|
|
22
22
|
theme: ThemeLike,
|
|
23
|
-
pad: number,
|
|
24
23
|
streaming?: boolean,
|
|
25
24
|
owner?: object,
|
|
26
25
|
runIndex?: number,
|
|
@@ -36,9 +35,7 @@ export function createThinkingPreview(deps: ThinkingDeps): ThinkingPreview {
|
|
|
36
35
|
const { config, grouping } = deps;
|
|
37
36
|
const timers = deps.timers ?? realTimers;
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
// positions the row, so honoring pad too would double-indent it.
|
|
41
|
-
return (text, theme, _pad, streaming, owner, runIndex) => {
|
|
38
|
+
return (text, theme, streaming, owner, runIndex) => {
|
|
42
39
|
const line = new CompactLine(timers);
|
|
43
40
|
|
|
44
41
|
// Takes no width: thinking has a single detail field, so CompactLine's
|
|
@@ -59,7 +56,7 @@ export function createThinkingPreview(deps: ThinkingDeps): ThinkingPreview {
|
|
|
59
56
|
|
|
60
57
|
if (owner && runIndex !== undefined) {
|
|
61
58
|
const id = grouping.thinkingId(owner, runIndex);
|
|
62
|
-
line.setRow(() => grouping.rowFor(id, new Painter(theme, config), paint), () => grouping.
|
|
59
|
+
line.setRow(() => grouping.rowFor(id, new Painter(theme, config), paint), (onHeader) => grouping.click(id, onHeader));
|
|
63
60
|
} else {
|
|
64
61
|
line.setRow(paint);
|
|
65
62
|
}
|
package/src/tool-renderer.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FEATURE 1: the compact tool renderer.
|
|
3
3
|
*
|
|
4
|
-
* Installed
|
|
5
|
-
*
|
|
4
|
+
* Installed as `Bridge.toolRenderer` (see `bridge.ts`) and consulted by the
|
|
5
|
+
* prototype wrappers in `core-patch.ts`.
|
|
6
6
|
*
|
|
7
7
|
* Core overrides RENDERING only. Native tool definitions keep their schema,
|
|
8
8
|
* `execute()`, and builtin source ownership — that ownership is what lets
|
|
@@ -172,17 +172,28 @@ export function createToolRenderer(deps: RendererDeps): ToolRenderer {
|
|
|
172
172
|
|
|
173
173
|
// Painted at render time, because the detail budget depends on the real
|
|
174
174
|
// viewport width and TUI only reveals it during render().
|
|
175
|
+
const expanded = context.expanded === true;
|
|
175
176
|
const paint = (width: number) => {
|
|
176
177
|
const painter = new Painter(theme, config);
|
|
177
178
|
const badge = timerBadge(status.elapsed, config);
|
|
179
|
+
// Expanded (Ctrl+O): the full details, wrapped by CompactLine instead of
|
|
180
|
+
// truncated, so a long command is readable.
|
|
178
181
|
const { label, details } = describeTool(name, args, {
|
|
179
|
-
expanded:
|
|
180
|
-
budget: detailBudget(width, status.glyph, name, badge),
|
|
181
|
-
config,
|
|
182
|
+
budget: expanded ? undefined : detailBudget(width, status.glyph, name, badge),
|
|
182
183
|
});
|
|
183
184
|
// No background while executing — the animated timer already signals
|
|
184
185
|
// activity, and a highlight flashing on each repaint was distracting.
|
|
185
|
-
|
|
186
|
+
// One colour for glyph, label and gutter (the gutter follows the label), so
|
|
187
|
+
// a failed row is red all over and a done one is label-coloured all over.
|
|
188
|
+
return painter.labeled({
|
|
189
|
+
glyph: status.glyph,
|
|
190
|
+
glyphColor: status.color,
|
|
191
|
+
label,
|
|
192
|
+
labelColor: status.color,
|
|
193
|
+
badge,
|
|
194
|
+
details,
|
|
195
|
+
wrap: expanded,
|
|
196
|
+
});
|
|
186
197
|
};
|
|
187
198
|
|
|
188
199
|
// EVERY call is observed, so a non-foldable one still cuts a run. Core
|
|
@@ -193,10 +204,10 @@ export function createToolRenderer(deps: RendererDeps): ToolRenderer {
|
|
|
193
204
|
component.setRow(paint);
|
|
194
205
|
return component;
|
|
195
206
|
}
|
|
196
|
-
grouping.observe(id, summaryName(name, args), status.outcome,
|
|
207
|
+
grouping.observe(id, summaryName(name, args), status.outcome, expanded);
|
|
197
208
|
component.setRow(
|
|
198
209
|
(width) => grouping.rowFor(id, new Painter(theme, config), () => paint(width)),
|
|
199
|
-
() => grouping.
|
|
210
|
+
(onHeader) => grouping.click(id, onHeader),
|
|
200
211
|
);
|
|
201
212
|
return component;
|
|
202
213
|
},
|
|
@@ -247,8 +258,8 @@ function detailBudget(width: number, glyph: string, label: string, badge: string
|
|
|
247
258
|
*
|
|
248
259
|
* Two caches on purpose: core's renderer path passes `lastComponent`, but its
|
|
249
260
|
* FALLBACK path always passes undefined, so `context.state` is the only stable
|
|
250
|
-
* per-call cache there. Allocating a fresh row per repaint
|
|
251
|
-
* 1s ticker while clearing none — an interval leak that
|
|
261
|
+
* per-call cache there. Allocating a fresh row per repaint would start another
|
|
262
|
+
* 1s ticker while clearing none — an interval leak that freezes the UI.
|
|
252
263
|
*/
|
|
253
264
|
function reuseRow(context: CallContext & { lastComponent?: unknown }, timers: Timers): CompactLine {
|
|
254
265
|
const cached =
|