pi-minimalist 0.1.2 → 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 +26 -5
- package/index.ts +13 -35
- package/package.json +1 -1
- package/src/bridge.ts +76 -55
- package/src/components.ts +97 -32
- package/src/config-file.ts +26 -81
- package/src/config-ui.ts +42 -34
- package/src/config.ts +59 -37
- 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/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 =
|
package/src/tools.ts
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tool vocabulary: how each tool describes itself
|
|
2
|
+
* Tool vocabulary: how each tool describes itself (a label and its details).
|
|
3
3
|
*
|
|
4
4
|
* Pure and deterministic — no components, no theme, no globals. Which tools get
|
|
5
5
|
* compacted is `Config.compacts()`, not this file.
|
|
6
6
|
*
|
|
7
7
|
* `describeTool` returns the label and details SEPARATELY and must never join
|
|
8
|
-
* them: the painter colors each part, and
|
|
9
|
-
*
|
|
10
|
-
* assuming no label ever contains one.
|
|
8
|
+
* them: the painter colors each part, and one joined string would
|
|
9
|
+
* force it to split on the first space, assuming no label contains one.
|
|
11
10
|
*/
|
|
12
11
|
|
|
13
|
-
import type { Config } from "./config.ts";
|
|
14
|
-
|
|
15
12
|
/** Native tools with a per-tool details extractor below. */
|
|
16
13
|
// `as const` preserves literal names instead of widening every item to string.
|
|
17
14
|
export const BUILT_INS = ["read", "bash", "edit", "write", "grep", "find", "ls"] as const;
|
|
@@ -19,7 +16,7 @@ export const BUILT_INS = ["read", "bash", "edit", "write", "grep", "find", "ls"]
|
|
|
19
16
|
// `(typeof BUILT_INS)[number]` turns the tuple values into a union type.
|
|
20
17
|
export type ToolName = (typeof BUILT_INS)[number];
|
|
21
18
|
|
|
22
|
-
/** True for native tools with a hand-written
|
|
19
|
+
/** True for native tools with a hand-written description. */
|
|
23
20
|
export function isBuiltIn(name: string): name is ToolName {
|
|
24
21
|
return BUILT_INS.includes(name as ToolName);
|
|
25
22
|
}
|
|
@@ -30,8 +27,8 @@ const mcpTools = new Set<string>();
|
|
|
30
27
|
/**
|
|
31
28
|
* Refresh after MCP adapter registration.
|
|
32
29
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
30
|
+
* Decides what gets LABELED `mcp` (not what gets compacted; the blacklist does
|
|
31
|
+
* that). It is real information: a bare
|
|
35
32
|
* `atlassian_getConfluencePage` row does not tell you it crossed an MCP server.
|
|
36
33
|
*/
|
|
37
34
|
export function refreshMcpTools(tools: { name: string; sourceInfo: { path: string } }[]): void {
|
|
@@ -48,60 +45,80 @@ export function isMcpTool(name: string): boolean {
|
|
|
48
45
|
/**
|
|
49
46
|
* Collapse whitespace and truncate for one-line display.
|
|
50
47
|
*
|
|
51
|
-
* `max` is a DISPLAY budget in characters
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* `CompactLine` truncates at the real viewport column count.
|
|
48
|
+
* `max` is a DISPLAY budget in characters. It is not the final word on width —
|
|
49
|
+
* `CompactLine` truncates at the real viewport column count. There is no other
|
|
50
|
+
* cap: with no budget the whole value is kept.
|
|
55
51
|
*/
|
|
56
|
-
export function compact(value: unknown, max = Number.POSITIVE_INFINITY
|
|
57
|
-
const limit = Math.min(max, hardCap);
|
|
58
|
-
// Slice BEFORE the regex so a huge string is never fully scanned. The extra
|
|
59
|
-
// headroom leaves room for whitespace runs that collapse away.
|
|
52
|
+
export function compact(value: unknown, max = Number.POSITIVE_INFINITY): string {
|
|
60
53
|
const raw = String(value ?? "");
|
|
61
|
-
|
|
54
|
+
// With a finite budget, slice BEFORE the regex so a huge string is never fully
|
|
55
|
+
// scanned for a collapsed row. The extra headroom leaves room for whitespace
|
|
56
|
+
// runs that collapse away.
|
|
57
|
+
const sliced = Number.isFinite(max) ? raw.slice(0, max * 2 + 16) : raw;
|
|
62
58
|
// Replacing every whitespace run prevents multi-line call rows.
|
|
63
59
|
const text = sliced.replace(/\s+/g, " ").trim();
|
|
64
|
-
return text.length >
|
|
60
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The value for an EXPANDED row: complete, with its line structure kept.
|
|
65
|
+
* `\r\n` becomes `\n`, tabs become two spaces, trailing whitespace goes, and
|
|
66
|
+
* blank lines at the start and end of the whole value go. Each line keeps its
|
|
67
|
+
* own leading indentation; `CompactLine` wraps every line under the details.
|
|
68
|
+
*/
|
|
69
|
+
export function block(value: unknown): string {
|
|
70
|
+
const lines = String(value ?? "")
|
|
71
|
+
// \v, \f, \u2028, \u2029 and \u0085 would reach the terminal raw (the
|
|
72
|
+
// collapsed path folds them away with \s+).
|
|
73
|
+
.replace(/[\v\f\u2028\u2029\u0085]/g, " ")
|
|
74
|
+
.replace(/\r\n?/g, "\n")
|
|
75
|
+
.split("\n")
|
|
76
|
+
.map((line) => line.replace(/\t/g, " ").trimEnd());
|
|
77
|
+
while (lines.length > 0 && lines[0] === "") lines.shift();
|
|
78
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
79
|
+
return lines.join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A budget means a collapsed one-line row; no budget means an expanded row. */
|
|
83
|
+
function field(value: unknown, budget: number): string {
|
|
84
|
+
return Number.isFinite(budget) ? compact(value, budget) : block(value);
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
/** Minimum columns a composed field keeps, so it never vanishes entirely. */
|
|
68
88
|
const MIN_FIELD = 12;
|
|
69
89
|
|
|
70
90
|
export type DescribeOptions = {
|
|
71
|
-
/** Expanded rows keep full-length details (still on ONE physical line). */
|
|
72
|
-
expanded?: boolean;
|
|
73
91
|
/**
|
|
74
92
|
* Columns available for the details segment.
|
|
75
93
|
*
|
|
76
94
|
* Approximate on purpose: `CompactLine` performs the exact, ANSI-aware,
|
|
77
95
|
* wide-character-aware truncation. This budget only decides WHICH field is
|
|
78
96
|
* sacrificed when a composed detail (`/pattern/ in path`) cannot fit.
|
|
97
|
+
* Omitted (expanded rows): full-length details, wrapped by `CompactLine`.
|
|
79
98
|
*/
|
|
80
99
|
budget?: number;
|
|
81
|
-
config?: Config;
|
|
82
100
|
};
|
|
83
101
|
|
|
84
102
|
/**
|
|
85
|
-
* The
|
|
103
|
+
* The description of a call: `read` + `src/a.ts:1-50`, `bash` +
|
|
86
104
|
* `go test ./...`, or a third-party tool's name plus its most identifying
|
|
87
105
|
* argument.
|
|
88
106
|
*/
|
|
89
107
|
export function describeTool(name: string, args: any, options: DescribeOptions = {}): { label: string; details: string } {
|
|
90
|
-
const hardCap = options.config?.get("maxDetailChars") ?? 4000;
|
|
91
108
|
const budget = options.budget ?? Number.POSITIVE_INFINITY;
|
|
92
109
|
const a = args ?? {};
|
|
93
110
|
|
|
94
|
-
if (name === "mcp") return { label: "mcp", details: mcpDetails(a, budget
|
|
95
|
-
if (name === "mcpScript") return { label: "mcpScript", details:
|
|
111
|
+
if (name === "mcp") return { label: "mcp", details: mcpDetails(a, budget) };
|
|
112
|
+
if (name === "mcpScript") return { label: "mcpScript", details: field(a.code, budget) };
|
|
96
113
|
if (name.startsWith("mcp__")) {
|
|
97
114
|
const server = name.slice("mcp__".length);
|
|
98
|
-
const tool =
|
|
115
|
+
const tool = field(a.tool, budget);
|
|
99
116
|
return { label: "mcp", details: tool ? `${tool} @ ${server}` : `@ ${server}` };
|
|
100
117
|
}
|
|
101
118
|
if (isMcpTool(name)) return { label: "mcp", details: name };
|
|
102
|
-
if (isBuiltIn(name)) return { label: name, details: builtInDetails(name, a, options
|
|
119
|
+
if (isBuiltIn(name)) return { label: name, details: builtInDetails(name, a, options) };
|
|
103
120
|
// Any other tool: show the name plus whichever argument identifies the call.
|
|
104
|
-
return { label: name, details: genericDetails(a, budget
|
|
121
|
+
return { label: name, details: genericDetails(a, budget) };
|
|
105
122
|
}
|
|
106
123
|
|
|
107
124
|
/**
|
|
@@ -120,10 +137,10 @@ export function summaryName(name: string, args: any): string {
|
|
|
120
137
|
return name;
|
|
121
138
|
}
|
|
122
139
|
|
|
123
|
-
function mcpDetails(args: any, budget: number
|
|
124
|
-
if (args.tool) return
|
|
140
|
+
function mcpDetails(args: any, budget: number): string {
|
|
141
|
+
if (args.tool) return field(args.tool, budget);
|
|
125
142
|
for (const key of ["search", "describe", "connect", "action"] as const) {
|
|
126
|
-
if (args[key]) return `${key} ${
|
|
143
|
+
if (args[key]) return `${key} ${field(args[key], budget)}`;
|
|
127
144
|
}
|
|
128
145
|
return "";
|
|
129
146
|
}
|
|
@@ -139,20 +156,19 @@ const GENERIC_KEYS = [
|
|
|
139
156
|
"tool", "action", "id", "target", "message", "text", "prompt",
|
|
140
157
|
] as const;
|
|
141
158
|
|
|
142
|
-
function genericDetails(args: any, budget: number
|
|
159
|
+
function genericDetails(args: any, budget: number): string {
|
|
143
160
|
for (const key of GENERIC_KEYS) {
|
|
144
161
|
const value = args[key];
|
|
145
|
-
// Only scalars: an object would serialize into noise on a
|
|
162
|
+
// Only scalars: an object would serialize into noise on a row.
|
|
146
163
|
if (value !== undefined && value !== null && typeof value !== "object") {
|
|
147
|
-
const text =
|
|
164
|
+
const text = field(value, budget);
|
|
148
165
|
if (text) return text;
|
|
149
166
|
}
|
|
150
167
|
}
|
|
151
168
|
return "";
|
|
152
169
|
}
|
|
153
170
|
|
|
154
|
-
function builtInDetails(name: ToolName, args: any, options: DescribeOptions
|
|
155
|
-
const expanded = options.expanded === true;
|
|
171
|
+
function builtInDetails(name: ToolName, args: any, options: DescribeOptions): string {
|
|
156
172
|
const budget = options.budget ?? Number.POSITIVE_INFINITY;
|
|
157
173
|
// Paths are shown verbatim (no ~/ home abbreviation — it was decorative and
|
|
158
174
|
// cost a homedir() call per render).
|
|
@@ -161,10 +177,9 @@ function builtInDetails(name: ToolName, args: any, options: DescribeOptions, har
|
|
|
161
177
|
switch (name) {
|
|
162
178
|
case "bash":
|
|
163
179
|
// ONE field, so no budget split is needed: CompactLine truncates at the
|
|
164
|
-
// real viewport width.
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
return compact(args.command, expanded ? Number.POSITIVE_INFINITY : budget, hardCap);
|
|
180
|
+
// real viewport width (no character cap). A collapsed row folds newlines into spaces; an expanded one
|
|
181
|
+
// keeps its lines (see `field`).
|
|
182
|
+
return field(args.command, budget);
|
|
168
183
|
case "read": {
|
|
169
184
|
// Show the line range when offset/limit were used, mirroring the built-in
|
|
170
185
|
// read tool's "path:start-end" notation.
|
|
@@ -186,7 +201,7 @@ function builtInDetails(name: ToolName, args: any, options: DescribeOptions, har
|
|
|
186
201
|
const wrapper = name === "grep" ? 2 : 0; // the two slashes in /pattern/
|
|
187
202
|
const overhead = where.length + " in ".length + wrapper;
|
|
188
203
|
const room = Number.isFinite(budget) ? Math.max(MIN_FIELD, budget - overhead) : Number.POSITIVE_INFINITY;
|
|
189
|
-
const pattern =
|
|
204
|
+
const pattern = field(args.pattern, room);
|
|
190
205
|
return name === "grep" ? `/${pattern}/ in ${where}` : `${pattern} in ${where}`;
|
|
191
206
|
}
|
|
192
207
|
case "ls":
|