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.
@@ -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
- type EntryKind = "tool" | "thinking" | "prose";
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; finalAnswerStarted: boolean };
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. Never fold again. */
59
- private opened = new Set<string>();
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, finalAnswerStarted: false });
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
- /** Observe a thinking block by its component identity and local run index. */
120
- observeThinking(owner: object, runIndex: number, done: boolean, expanded: boolean): void {
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
- const id = `prose:${this.ownerId(owner)}:${contentIndex}`;
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, finalAnswerStarted: false });
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.phase === "final_answer" || signal.stopReason === "stop";
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.settledAt = timestamp;
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.ownerId(owner)}:${runIndex}`;
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
- if (activity.get(id) === "summary") {
244
- members.push(...this.entries.filter((e) => e.cycle === entry.cycle && activity.get(e.id) === "hide"));
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.push(this.entries[i]);
250
+ members.unshift(this.entries[i]);
249
251
  }
250
252
  } else {
251
253
  return false;
252
254
  }
253
- for (const member of members) this.opened.add(member.id);
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
- return painter.summary(actions);
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
- while (last < this.entries.length && this.foldable(this.entries[last])) last++;
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
- const cycle = this.cycles.get(cycleId);
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((entry) => entry.kind !== "prose");
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 on
5
- * `Symbol.for("pi.thinkingPreview")`:
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, on `Symbol.for("pi.thinkingMarkdownTheme")`.
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
- // `pad` is part of the core bridge signature but unused: the gutter already
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.open(id));
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
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * FEATURE 1: the compact tool renderer.
3
3
  *
4
- * Installed on the process-global bridge `Symbol.for("pi.defaultToolRenderer")`
5
- * and consulted by the prototype wrappers in `core-patch.ts`.
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: context.expanded === true,
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
- return painter.labeled({ glyph: status.glyph, glyphColor: status.color, label, badge, details });
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, context.expanded === true);
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.open(id),
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 used to start another
251
- * 1s ticker while clearing none — an interval leak that froze the UI.
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 on one line.
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 an earlier version that returned one
9
- * string forced the painter to split it back apart on the first space, silently
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 one-line description. */
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
- * No longer decides what gets COMPACTED (the blacklist does that). It is kept
34
- * because it decides what gets LABELED `mcp`, which is real information: a bare
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; `hardCap` is a sanity limit that
52
- * exists only so a multi-megabyte heredoc is not whitespace-collapsed, colored
53
- * and measured on every repaint. Neither is the final word on width —
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, hardCap = 4000): string {
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
- const sliced = Number.isFinite(limit) ? raw.slice(0, limit * 2 + 16) : raw.slice(0, hardCap * 2 + 16);
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 > limit ? `${text.slice(0, limit - 1)}…` : text;
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 one-line description of a call: `read` + `src/a.ts:1-50`, `bash` +
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, hardCap) };
95
- if (name === "mcpScript") return { label: "mcpScript", details: compact(a.code, budget, hardCap) };
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 = compact(a.tool, budget, hardCap);
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, hardCap) };
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, hardCap) };
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, hardCap: number): string {
124
- if (args.tool) return compact(args.tool, budget, hardCap);
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} ${compact(args[key], budget, hardCap)}`;
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, hardCap: number): string {
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 one-line row.
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 = compact(value, budget, hardCap);
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, hardCap: number): string {
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. An earlier version also capped this at 100
165
- // characters, which threw away ~89 usable columns on a 200-column
166
- // terminal. Newlines still collapse so a heredoc cannot escape the row.
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 = compact(args.pattern, room, hardCap);
204
+ const pattern = field(args.pattern, room);
190
205
  return name === "grep" ? `/${pattern}/ in ${where}` : `${pattern} in ${where}`;
191
206
  }
192
207
  case "ls":