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 CHANGED
@@ -14,7 +14,11 @@ A quieter transcript for [Pi](https://pi.dev): turn bulky tool calls into one-li
14
14
 
15
15
  <img width="699" height="50" alt="after" src="https://github.com/user-attachments/assets/c0e8bca4-afa7-42c1-9bf0-69f267d18d9f" />
16
16
 
17
- Press **Ctrl+O** to expand tool output—including diffs and syntax highlighting. Nothing is removed from the session or hidden from the model; only the display changes.
17
+ **Click** a folded line to open it into separate rows. Click one row to see only its output. Click the `▾ Expanded · click to fold` line above an opened run to fold it back.
18
+
19
+ <img src="docs/click.png" width="600" alt="Clicking a folded line opens it into rows; clicking one row expands only that row">
20
+
21
+ Press **Ctrl+O** to expand tool output—including diffs and syntax highlighting. Expanded rows also wrap long commands instead of cutting them off. Nothing is removed from the session or hidden from the model; only the display changes.
18
22
 
19
23
  ## Install
20
24
 
@@ -33,7 +37,25 @@ Run **`/minimalist`** to change settings live. Start with compact tool rows, or
33
37
  - **Compact thinking rows**, or keep thinking visible as it arrives.
34
38
  - **Keep running tools visible** while grouping other calls, with an elapsed timer to show how long they take.
35
39
 
36
- Compact tool rows and consecutive-call grouping are **on by default**. Activity folding and compact thinking are opt-in. Disable grouping if you prefer to see each call separately.
40
+ A fresh install starts on the **`full`** preset (see below): compact tool rows, grouped calls and compact thinking. Activity folding is opt-in. Choose `lite` if you prefer to see each call separately.
41
+
42
+ ### Presets
43
+
44
+ The **Preset** row at the top of `/minimalist` sets four options at once: **Compact tool rows**, **Combine consecutive tool calls**, **Collapse earlier activity** and **Compact thinking rows**.
45
+
46
+ | Preset | What you get |
47
+ | --- | --- |
48
+ | `off` | Pi's native tool cards. |
49
+ | `lite` | One-line tool rows, each call on its own line. |
50
+ | `full` | `lite` plus combined consecutive calls and compact thinking rows. |
51
+ | `max` | `full` plus collapsed earlier activity (`Worked for …`). |
52
+ | `custom` | Your own values for those four options. |
53
+
54
+ <img src="docs/presets.png" width="600" alt="Clicking a folded line opens it into rows; clicking one row expands only that row">
55
+
56
+ A preset never overwrites your settings. While `off`, `lite`, `full` or `max` is selected, only those four rows are greyed out and show the preset's values; pressing Enter on one does nothing. Every other option (Left border, Elapsed timer, Symbols and so on) stays yours and stays editable. Your own values are kept and come back when you choose `custom`. The first time you choose `custom`, it starts from `lite`.
57
+
58
+ **Upgrading:** if your settings already contain a `minimalist` block, you stay on `custom` and nothing changes. Only a user with no `minimalist` settings at all starts on `full`, which makes thinking rows compact. To pick another look, change **Preset**.
37
59
 
38
60
  ### Everyday controls
39
61
 
@@ -43,15 +65,14 @@ Compact tool rows and consecutive-call grouping are **on by default**. Activity
43
65
  | `/minimalist status` | Show current settings |
44
66
  | Ctrl+O | Expand or collapse tool output |
45
67
  | Ctrl+T | Expand or collapse thinking |
46
- | Restore defaults | Reset display settings in the editor |
47
68
 
48
69
  Settings apply to existing conversation history and are saved globally. If a symbol looks wrong in your terminal, choose **Symbols → ASCII**. No Nerd Font required.
49
70
 
50
71
  ## Go back to Pi's usual view
51
72
 
52
- Turn off **Compact tool rows** to restore native tool cards. Other folding options are independent; **Restore defaults** returns to the extension's initial settings, not native Pi.
73
+ Turn off **Compact tool rows** to restore native tool cards. Or choose the `off` preset.
53
74
 
54
- To disable the extension completely, run `pi config`, disable pi-minimalist, and restart Pi. To uninstall:
75
+ To disable the extension completely, run `pi config`, disable pi-minimalist, and restart Pi
55
76
 
56
77
  ## Compatibility
57
78
 
package/index.ts CHANGED
@@ -22,8 +22,8 @@ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
22
22
  // keybindings and colours, as Pi's own `/settings`.
23
23
  import { SettingsList } from "@earendil-works/pi-tui";
24
24
  import { installBridges, sharedState } from "./src/bridge.ts";
25
- import { BASIC_KEYS, DEFAULT_BASIC, type BasicKey, type BasicSettings, type Config } from "./src/config.ts";
26
- import { loadSettings, migratedQuiet, saveBasicSettings } from "./src/config-file.ts";
25
+ import { type BasicKey, type BasicSettings, type Config } from "./src/config.ts";
26
+ import { customSeed, loadSettings, saveBasicSettings } from "./src/config-file.ts";
27
27
  import { argumentCompletions, createConfigScreen, summary } from "./src/config-ui.ts";
28
28
  import { patchCore } from "./src/core-patch.ts";
29
29
  import { refreshMcpTools } from "./src/tools.ts";
@@ -34,15 +34,8 @@ export default function (pi: ExtensionAPI) {
34
34
  // strand every row on stale state.
35
35
  const { config, grouping } = sharedState(loadSettings());
36
36
 
37
- // One-time migration of the preference written by the removed `/quiet`
38
- // command. A session override, so the per-turn re-read below cannot revert it
39
- // before the user has saved anything.
40
- const migrated = migratedQuiet();
41
- if (migrated !== undefined) config.setSessionOverride("groupToolRuns", migrated);
42
-
43
37
  installBridges({ config, grouping });
44
- // Runtime replacement for editing Pi's compiled bundle. Idempotent, so a
45
- // /reload only refreshes the bridge slots the wrappers read.
38
+ // Idempotent, so a /reload only refreshes the Bridge the wrappers read.
46
39
  patchCore(core);
47
40
 
48
41
  const refresh = () => {
@@ -82,7 +75,6 @@ export default function (pi: ExtensionAPI) {
82
75
  theme: getSettingsListTheme(),
83
76
  config,
84
77
  onChange: (key, value) => persist(ctx, config, key, value),
85
- onReset: () => persistDefaults(ctx, config),
86
78
  onClose: () => done(),
87
79
  }),
88
80
  );
@@ -92,37 +84,23 @@ export default function (pi: ExtensionAPI) {
92
84
 
93
85
  type NotifyContext = { ui: { notify(message: string, type?: "info" | "warning" | "error"): void } };
94
86
 
95
- /**
96
- * Write only the changed key to the global agent settings, and be honest when that is impossible.
97
- *
98
- * The live value has already changed, so a silent failure would leave the UI and
99
- * the file disagreeing. Keeping it as a session override means the per-turn
100
- * re-read cannot revert what the message says was applied.
101
- */
102
- function persistDefaults(ctx: NotifyContext, config: Config): void {
103
- const result = saveBasicSettings(DEFAULT_BASIC);
104
- for (const key of BASIC_KEYS) {
105
- if (result.ok) config.clearSessionOverride(key);
106
- else config.setSessionOverride(key, config.get(key));
107
- }
108
- if (!result.ok) {
109
- ctx.ui.notify(`Defaults applied for this session only; settings.json could not be written (${result.reason}).`, "warning");
110
- }
111
- }
112
-
113
87
  function persist(
114
88
  ctx: NotifyContext,
115
89
  config: Config,
116
90
  key: BasicKey,
117
91
  value: BasicSettings[BasicKey],
118
92
  ): void {
119
- const result = saveBasicSettings({ [key]: value });
120
- if (result.ok) {
121
- // The file now agrees, so it becomes the source of truth again.
122
- config.clearSessionOverride(key);
123
- return;
93
+ // The first switch to `custom` starts from the `lite` look (see customSeed).
94
+ const seed = key === "preset" && value === "custom" ? customSeed() : undefined;
95
+ const changes: Partial<Record<BasicKey, unknown>> = { ...seed, [key]: value };
96
+ const result = saveBasicSettings(changes as Partial<BasicSettings>);
97
+ for (const [k, v] of Object.entries(changes) as [BasicKey, never][]) {
98
+ config.set(k, v);
99
+ // On success the file agrees, so it becomes the source of truth again.
100
+ if (result.ok) config.clearSessionOverride(k);
101
+ else config.setSessionOverride(k, v);
124
102
  }
125
- config.setSessionOverride(key, value);
103
+ if (result.ok) return;
126
104
  ctx.ui.notify(
127
105
  result.reason === "comments"
128
106
  ? `Applied for this session only. settings.json has comments, which JSON.stringify would delete — set "minimalist": { "${key}": ${JSON.stringify(value)} } by hand to persist.`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-minimalist",
3
- "version": "0.1.2",
3
+ "version": "0.2.2",
4
4
  "description": "Compact one-line tool and thinking rows for Pi's interactive transcript",
5
5
  "keywords": ["pi-package"],
6
6
  "license": "MIT",
package/src/bridge.ts CHANGED
@@ -2,56 +2,72 @@
2
2
  * The boundary between this extension and Pi's core components.
3
3
  *
4
4
  * Nothing on disk is patched. `core-patch.ts` wraps Pi's real component
5
- * prototypes at load time and READS the process-global slots below at CALL time,
6
- * so `/reload` swaps behavior without re-wrapping anything, and an unloaded
7
- * extension degrades to Pi's native rendering.
5
+ * prototypes at load time and READS ONE process-global object, the `Bridge`
6
+ * below, at CALL time. Every install replaces that object as a whole, so
7
+ * `/reload` swaps behavior without re-wrapping anything, and an unloaded
8
+ * extension (no object) degrades to Pi's native rendering.
8
9
  *
9
- * SINGLE SOURCE OF TRUTH for the symbol names, shared by the wrappers, the state
10
- * that must survive `/reload`, and the tests.
10
+ * SINGLE SOURCE OF TRUTH for the global key names, shared by the wrappers, the
11
+ * state that must survive `/reload`, and the tests.
11
12
  */
12
13
 
13
14
  import { Config } from "./config.ts";
14
15
  import { RunGrouping } from "./run-grouping.ts";
15
- import { Painter, type ThemeLike } from "./row.ts";
16
- import { createToolRenderer } from "./tool-renderer.ts";
17
- import { createThinkingPreview, singleHueThinkingTheme } from "./thinking.ts";
16
+ import { Painter, type Row, type ThemeLike } from "./row.ts";
17
+ import { createToolRenderer, type ToolRenderer } from "./tool-renderer.ts";
18
+ import { createThinkingPreview, singleHueThinkingTheme, type ThinkingPreview } from "./thinking.ts";
18
19
  import type { Timers } from "./components.ts";
19
20
 
20
21
  export const BRIDGE_SYMBOLS = {
21
- /** `{ renderShell, handles, renderCall, renderResult }` for every tool row. */
22
- toolRenderer: "pi.defaultToolRenderer",
23
- /** `(text, theme, pad, streaming, owner, runIndex) => Component`. */
24
- thinkingPreview: "pi.thinkingPreview",
25
- /** `(markdownTheme, theme) => markdownTheme` for expanded thinking. */
26
- thinkingMarkdownTheme: "pi.thinkingMarkdownTheme",
27
-
22
+ /** The `Bridge` object: everything the prototype wrappers call. */
23
+ bridge: "pi.minimalist.bridge",
28
24
  /** Live settings, retained across a /reload. */
29
25
  config: "pi.minimalist.config",
30
26
  /** Run-grouping state, retained across a /reload. */
31
27
  grouping: "pi.minimalist.grouping",
28
+ } as const;
29
+
30
+ /**
31
+ * What the prototype wrappers may call. Functions, not values: the wrappers read
32
+ * the object on every call, so a settings toggle takes effect without
33
+ * reinstalling anything.
34
+ */
35
+ export type Bridge = {
36
+ /** `{ renderShell, handles, renderCall, renderResult }` for every tool row. */
37
+ toolRenderer: ToolRenderer;
38
+ thinkingPreview: ThinkingPreview;
39
+ /** Expanded thinking: the Markdown theme with every token in the thinking hue. */
40
+ thinkingMarkdownTheme: (base: Record<string, unknown>, theme: ThemeLike) => Record<string, unknown>;
41
+ /** `thinkingAsToolCall`: do collapsed/expanded thinking rows belong to us? */
42
+ compactThinking: () => boolean;
43
+ /** Keep a STREAMING thinking block expanded. */
44
+ keepActiveThinkingExpanded: () => boolean;
32
45
 
33
46
  /** Chronology hooks that keep thinking and prose in transcript order. */
34
- observeThinking: "pi.minimalist.observeThinking",
35
- observeProse: "pi.minimalist.observeProse",
47
+ observeThinking: (owner: object, runIndex: number, streaming: boolean, hidden: boolean, timestamp?: number) => string;
48
+ observeProse: RunGrouping["observeProse"];
49
+ /** A visible row we do not fold (user message, excluded tool); it cuts runs. */
50
+ observeBarrier: (id: string) => void;
36
51
  /** `undefined` = native prose, `null` = hidden, Row = folded summary. */
37
- proseView: "pi.minimalist.proseView",
38
- /** `(toolCallId) => boolean`: omit core's separator before an activity summary. */
39
- activitySummaryRow: "pi.minimalist.activitySummaryRow",
40
- /** `(owner) => normal|hidden|summary`: normalize assistant host spacing. */
41
- activityMessageView: "pi.minimalist.activityMessageView",
52
+ proseView: (id: string, theme: ThemeLike) => Row | null | undefined;
53
+ /** Header of an opened run, drawn by a prose/native-thinking host. */
54
+ proseHeader: (id: string, theme: ThemeLike) => Row | undefined;
55
+ /** A click on a summary or header; true when handled. */
56
+ click: (id: string, onHeader: boolean) => boolean;
57
+ /** Omit core's separator before an activity summary. */
58
+ activitySummaryRow: (id: string) => boolean;
59
+ /** Normalize assistant host spacing. */
60
+ activityMessageView: (owner: object) => "normal" | "hidden" | "summary";
42
61
  /**
43
62
  * Spacer suppression for a fully hidden assistant message.
44
63
  *
45
64
  * There is deliberately NO tool-row equivalent: a compact row uses
46
65
  * `renderShell: "self"`, and core's self-shell branch emits its separator
47
66
  * inline and returns ZERO lines when the row renders nothing — spacer
48
- * included. An earlier `quietSpacer` bridge was compensating for a problem core
49
- * already handles.
67
+ * included.
50
68
  */
51
- messageSpacer: "pi.minimalist.messageSpacer",
52
- /** `() => boolean`: keep a STREAMING thinking block expanded. */
53
- keepActiveThinkingExpanded: "pi.minimalist.keepActiveThinkingExpanded",
54
- } as const;
69
+ messageSpacer: (owner: object) => boolean;
70
+ };
55
71
 
56
72
  type Globals = Record<symbol, unknown>;
57
73
 
@@ -59,6 +75,11 @@ function slot(key: keyof typeof BRIDGE_SYMBOLS): symbol {
59
75
  return Symbol.for(BRIDGE_SYMBOLS[key]);
60
76
  }
61
77
 
78
+ /** The installed bridge, or `undefined` when the extension is not loaded. */
79
+ export function readBridge(): Bridge | undefined {
80
+ return (globalThis as Globals)[slot("bridge")] as Bridge | undefined;
81
+ }
82
+
62
83
  /**
63
84
  * State that must survive `/reload`.
64
85
  *
@@ -85,40 +106,40 @@ export type InstallOptions = SharedState & {
85
106
  };
86
107
 
87
108
  /**
88
- * Install every bridge.
109
+ * Install the bridge.
89
110
  *
90
- * Each `/reload` overwrites these slots with fresh instances. Do NOT clear them
111
+ * Each `/reload` overwrites the slots with fresh instances. Do NOT clear them
91
112
  * from `session_shutdown`: reload ordering can let an old shutdown hook erase the
92
- * newly installed bridges. Process exit clears globalThis naturally.
113
+ * newly installed bridge. Process exit clears globalThis naturally.
93
114
  */
94
115
  export function installBridges({ config, grouping, timers }: InstallOptions): void {
95
116
  const globals = globalThis as Globals;
117
+ const painter = (theme: ThemeLike) => new Painter(theme, config);
96
118
 
97
- globals[slot("config")] = config;
98
- globals[slot("grouping")] = grouping;
99
- globals[slot("toolRenderer")] = createToolRenderer({ config, grouping, timers });
100
- globals[slot("thinkingPreview")] = createThinkingPreview({ config, grouping, timers });
101
- globals[slot("thinkingMarkdownTheme")] = (base: Record<string, unknown>, theme: never) =>
102
- singleHueThinkingTheme(base, theme, config);
119
+ const bridge: Bridge = {
120
+ toolRenderer: createToolRenderer({ config, grouping, timers }),
121
+ thinkingPreview: createThinkingPreview({ config, grouping, timers }),
122
+ thinkingMarkdownTheme: (base, theme) => singleHueThinkingTheme(base, theme, config),
123
+ compactThinking: () => config.get("thinkingAsToolCall") === true,
124
+ keepActiveThinkingExpanded: () => config.get("keepActiveThinkingExpanded"),
103
125
 
104
- // Chronology: core walks message content in transcript order, and run folding
105
- // depends on that order.
106
- globals[slot("observeThinking")] = (owner: object, runIndex: number, streaming: boolean, hidden: boolean) => {
107
- grouping.observeThinking(owner, runIndex, !streaming, !hidden);
108
- return grouping.thinkingId(owner, runIndex);
126
+ // Chronology: core walks message content in transcript order, and run
127
+ // folding depends on that order.
128
+ observeThinking: (owner, runIndex, streaming, hidden, timestamp) => {
129
+ grouping.observeThinking(owner, runIndex, !streaming, !hidden, timestamp);
130
+ return grouping.thinkingId(owner, runIndex);
131
+ },
132
+ observeProse: (owner, contentIndex, signal) => grouping.observeProse(owner, contentIndex, signal),
133
+ observeBarrier: (id) => grouping.observeBarrier(id),
134
+ proseView: (id, theme) => grouping.proseView(id, painter(theme)),
135
+ proseHeader: (id, theme) => grouping.headerFor(id, painter(theme)),
136
+ click: (id, onHeader) => grouping.click(id, onHeader),
137
+ activitySummaryRow: (id) => grouping.isActivitySummary(id),
138
+ activityMessageView: (owner) => grouping.activityMessageView(owner),
139
+ messageSpacer: (owner) => grouping.showsMessageSpacer(owner),
109
140
  };
110
- globals[slot("observeProse")] = (
111
- owner: object,
112
- contentIndex: number,
113
- signal: Parameters<RunGrouping["observeProse"]>[2],
114
- ) => grouping.observeProse(owner, contentIndex, signal);
115
- globals[slot("proseView")] = (id: string, theme: ThemeLike) =>
116
- grouping.proseView(id, new Painter(theme, config));
117
- globals[slot("activitySummaryRow")] = (id: string) => grouping.isActivitySummary(id);
118
- globals[slot("activityMessageView")] = (owner: object) => grouping.activityMessageView(owner);
119
- globals[slot("messageSpacer")] = (owner: object) => grouping.showsMessageSpacer(owner);
120
141
 
121
- // A function, not a value: the prototype wrapper reads it on every call, so a
122
- // toggle takes effect without reinstalling anything.
123
- globals[slot("keepActiveThinkingExpanded")] = () => config.get("keepActiveThinkingExpanded");
142
+ globals[slot("config")] = config;
143
+ globals[slot("grouping")] = grouping;
144
+ globals[slot("bridge")] = bridge;
124
145
  }
package/src/components.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * production components without loading an extension runtime.
7
7
  */
8
8
 
9
- import { truncateToWidth } from "@earendil-works/pi-tui";
9
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
10
10
  import type { Component, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
11
11
  import { gutterWidth, type Row } from "./row.ts";
12
12
 
@@ -40,17 +40,57 @@ export const realTimers: Timers = {
40
40
  */
41
41
  export type ResolveRow = (width: number) => Row | null;
42
42
 
43
+ /** Columns the details must keep on each line before wrapping is worth it. */
44
+ const MIN_WRAP_COLUMNS = 10;
45
+
46
+ /**
47
+ * An expanded row's details wrapped under themselves (a hanging indent below
48
+ * `glyph label`), or undefined when the terminal is too narrow to wrap
49
+ * usefully: the caller then truncates as usual. A body that fits wraps to a
50
+ * single line.
51
+ */
52
+ function wrapped({ head, body }: { head: string; body: string }, room: number): string[] | undefined {
53
+ const indent = visibleWidth(head) + 1;
54
+ if (room - indent < MIN_WRAP_COLUMNS) return undefined;
55
+ const pad = " ".repeat(indent);
56
+ return wrapTextWithAnsi(body, room - indent).map((line, i) => (i === 0 ? `${head} ` : pad) + line);
57
+ }
58
+
43
59
  /**
44
- * One physical terminal line with width-aware truncation and full-width color.
60
+ * Paint a resolved row at `width`; `null` renders ZERO lines.
61
+ *
62
+ * TUI pads each line to terminal width, so no manual trailing padding is
63
+ * needed. truncateToWidth understands ANSI codes and wide Unicode glyphs, so
64
+ * colored text truncates at VISIBLE columns. The gutter is a fixed-width
65
+ * prefix, so the content gets the remaining columns. Width comes from the
66
+ * row's OWN gutter text, so a disabled gutter reclaims those columns.
67
+ */
68
+ function renderRow(row: Row | null, width: number): string[] {
69
+ if (row === null) return [];
70
+ const room = Math.max(1, width - gutterWidth(row.gutter));
71
+ const paint = (line: string) => row.gutter + line;
72
+ const lines = row.wrap ? wrapped(row.wrap, room) : undefined;
73
+ if (lines) return lines.map(paint);
74
+ return [paint(truncateToWidth(row.text, room, "…"))];
75
+ }
76
+
77
+ /**
78
+ * One physical terminal line (several for an expanded row) with width-aware
79
+ * truncation and full-width color.
45
80
  *
46
81
  * Pi's Text component wraps long strings. That is correct for prose but made a
47
- * long path spill onto a second line in a supposedly single-line renderer. TUI
82
+ * long path spill onto a second line in a renderer meant for one line. TUI
48
83
  * only supplies the real terminal width during render(), so truncating with a
49
84
  * fixed character count in the caller cannot solve this reliably.
85
+ *
86
+ * The one exception is an EXPANDED row (`row.wrap`): there the user asked to see
87
+ * everything, so a long command wraps under its own details instead.
50
88
  */
51
89
  export class CompactLine implements Component {
52
90
  private resolve: ResolveRow = () => null;
53
- private click?: () => boolean;
91
+ private click?: (onHeader: boolean) => boolean;
92
+ /** Did the last render draw a header line at y=0? */
93
+ private headed = false;
54
94
  private ticker: unknown;
55
95
  private timers: Timers;
56
96
 
@@ -60,9 +100,10 @@ export class CompactLine implements Component {
60
100
 
61
101
  /**
62
102
  * Install the row resolver. Called on every updateDisplay with fresh state.
63
- * `click` returning true stops Pi's own left-click (expand) handler.
103
+ * `click` returning true stops Pi's own left-click (expand) handler; its
104
+ * argument says whether the click landed on the row's header line.
64
105
  */
65
- setRow(resolve: ResolveRow, click?: () => boolean): void {
106
+ setRow(resolve: ResolveRow, click?: (onHeader: boolean) => boolean): void {
66
107
  this.resolve = resolve;
67
108
  this.click = click;
68
109
  }
@@ -71,7 +112,8 @@ export class CompactLine implements Component {
71
112
  // the region's own expand/collapse toggle.
72
113
  handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
73
114
  if (event.type !== "click" || event.button !== "left") return undefined;
74
- return this.click?.() ? { handled: true } : undefined;
115
+ // `y` is local to THIS component (Container subtracts earlier siblings).
116
+ return this.click?.(this.headed && event.y === 0) ? { handled: true } : undefined;
75
117
  }
76
118
 
77
119
  /**
@@ -91,22 +133,10 @@ export class CompactLine implements Component {
91
133
  }
92
134
  }
93
135
 
94
- /** Test/inspection helper: is a repaint interval currently registered? */
95
- isTicking(): boolean {
96
- return this.ticker !== undefined;
97
- }
98
-
99
136
  render(width: number): string[] {
100
137
  const row = this.resolve(width);
101
- if (row === null) return [];
102
-
103
- // TUI pads each line to terminal width, so no manual trailing padding is
104
- // needed. truncateToWidth understands ANSI codes and wide Unicode glyphs,
105
- // so colored text truncates at VISIBLE columns. The gutter is a fixed-width
106
- // prefix, so the content gets the remaining columns. Width comes from the
107
- // row's OWN gutter text, so a disabled gutter reclaims those columns.
108
- const line = truncateToWidth(row.text, Math.max(1, width - gutterWidth(row.gutter)), "…");
109
- return [row.gutter + (row.highlight ? row.highlight(line) : line)];
138
+ this.headed = row?.header !== undefined;
139
+ return [...(row?.header ? [...renderRow(row.header, width), ""] : []), ...renderRow(row, width)];
110
140
  }
111
141
 
112
142
  // Component contract allows cached components to be invalidated. This class
@@ -115,28 +145,55 @@ export class CompactLine implements Component {
115
145
  }
116
146
 
117
147
  /**
118
- * Collapsed results must render ZERO lines, not one blank line.
119
- *
120
- * Pi's Text component returns [""] for empty strings (one blank row), which
121
- * doubled the height of every collapsed tool call. This is the only safe way to
122
- * say "no content at all" while still returning a Component, as the
123
- * renderResult slot contract requires.
148
+ * Assistant prose or native thinking that a fold may take over: the resolver
149
+ * returns a Row to draw it instead, `null` to hide it (zero lines), or
150
+ * `undefined` to leave `inner` untouched (with an opened run's header line
151
+ * above it, when `header` supplies one). The header is followed by one blank line. Clicks on the summary or header go to
152
+ * `click`.
124
153
  */
125
154
  export class FoldableProse implements Component {
126
155
  private inner: Component;
127
156
  private resolve: (width: number) => Row | null | undefined;
157
+ private header: () => Row | undefined;
158
+ private click: (onHeader: boolean) => boolean;
159
+ /** What the last render drew: a folded summary, or a header line above native output. */
160
+ private summary = false;
161
+ private headed = false;
128
162
 
129
- constructor(inner: Component, resolve: (width: number) => Row | null | undefined) {
163
+ /**
164
+ * `header` is the opened-run header to draw above the native output; `click`
165
+ * has the same contract as `CompactLine.setRow`'s: it gets whether the click
166
+ * landed on that header line.
167
+ */
168
+ constructor(
169
+ inner: Component,
170
+ resolve: (width: number) => Row | null | undefined,
171
+ header: () => Row | undefined,
172
+ click: (onHeader: boolean) => boolean,
173
+ ) {
130
174
  this.inner = inner;
131
175
  this.resolve = resolve;
176
+ this.header = header;
177
+ this.click = click;
132
178
  }
133
179
 
134
180
  render(width: number): string[] {
135
181
  const row = this.resolve(width);
136
- if (row === undefined) return this.inner.render(width);
137
- const line = new CompactLine();
138
- line.setRow(() => row);
139
- return line.render(width);
182
+ this.summary = row !== undefined && row !== null;
183
+ const header = row === undefined ? this.header() : undefined;
184
+ this.headed = header !== undefined;
185
+ if (row !== undefined) return renderRow(row, width);
186
+ return header ? [...renderRow(header, width), "", ...this.inner.render(width)] : this.inner.render(width);
187
+ }
188
+
189
+ // Only the summary or the header line is clickable; the native output below
190
+ // keeps whatever click behavior its own host (e.g. a thinking MouseRegion) has.
191
+ // `y` is local to THIS component, like CompactLine's.
192
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
193
+ if (event.type !== "click" || event.button !== "left") return undefined;
194
+ const onHeader = this.headed && event.y === 0;
195
+ if (!this.summary && !onHeader) return undefined;
196
+ return this.click(onHeader) ? { handled: true } : undefined;
140
197
  }
141
198
 
142
199
  invalidate(): void {
@@ -144,6 +201,14 @@ export class FoldableProse implements Component {
144
201
  }
145
202
  }
146
203
 
204
+ /**
205
+ * Collapsed results must render ZERO lines, not one blank line.
206
+ *
207
+ * Pi's Text component returns [""] for empty strings (one blank row), which
208
+ * doubled the height of every collapsed tool call. This is the only safe way to
209
+ * say "no content at all" while still returning a Component, as the
210
+ * renderResult slot contract requires.
211
+ */
147
212
  export class EmptyComponent implements Component {
148
213
  render(): string[] {
149
214
  return [];