pi-minimalist 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,10 @@ 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
+ **Click** a folded line to open it into separate rows. Click one row to see only its output.
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
+
17
21
  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.
18
22
 
19
23
  ## Install
@@ -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,13 +65,12 @@ 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
75
  To disable the extension completely, run `pi config`, disable pi-minimalist, and restart Pi. To uninstall:
55
76
 
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, migratedQuiet, 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";
@@ -82,7 +82,6 @@ export default function (pi: ExtensionAPI) {
82
82
  theme: getSettingsListTheme(),
83
83
  config,
84
84
  onChange: (key, value) => persist(ctx, config, key, value),
85
- onReset: () => persistDefaults(ctx, config),
86
85
  onClose: () => done(),
87
86
  }),
88
87
  );
@@ -92,37 +91,23 @@ export default function (pi: ExtensionAPI) {
92
91
 
93
92
  type NotifyContext = { ui: { notify(message: string, type?: "info" | "warning" | "error"): void } };
94
93
 
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
94
  function persist(
114
95
  ctx: NotifyContext,
115
96
  config: Config,
116
97
  key: BasicKey,
117
98
  value: BasicSettings[BasicKey],
118
99
  ): 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;
100
+ // The first switch to `custom` starts from the `lite` look (see customSeed).
101
+ const seed = key === "preset" && value === "custom" ? customSeed() : undefined;
102
+ const changes: Partial<Record<BasicKey, unknown>> = { ...seed, [key]: value };
103
+ const result = saveBasicSettings(changes as Partial<BasicSettings>);
104
+ for (const [k, v] of Object.entries(changes) as [BasicKey, never][]) {
105
+ config.set(k, v);
106
+ // On success the file agrees, so it becomes the source of truth again.
107
+ if (result.ok) config.clearSessionOverride(k);
108
+ else config.setSessionOverride(k, v);
124
109
  }
125
- config.setSessionOverride(key, value);
110
+ if (result.ok) return;
126
111
  ctx.ui.notify(
127
112
  result.reason === "comments"
128
113
  ? `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.1",
3
+ "version": "0.2.0",
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/components.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { truncateToWidth } from "@earendil-works/pi-tui";
10
- import type { Component } from "@earendil-works/pi-tui";
10
+ import type { Component, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
11
11
  import { gutterWidth, type Row } from "./row.ts";
12
12
 
13
13
  /**
@@ -50,6 +50,7 @@ export type ResolveRow = (width: number) => Row | null;
50
50
  */
51
51
  export class CompactLine implements Component {
52
52
  private resolve: ResolveRow = () => null;
53
+ private click?: () => boolean;
53
54
  private ticker: unknown;
54
55
  private timers: Timers;
55
56
 
@@ -57,9 +58,20 @@ export class CompactLine implements Component {
57
58
  this.timers = timers;
58
59
  }
59
60
 
60
- /** Install the row resolver. Called on every updateDisplay with fresh state. */
61
- setRow(resolve: ResolveRow): void {
61
+ /**
62
+ * Install the row resolver. Called on every updateDisplay with fresh state.
63
+ * `click` returning true stops Pi's own left-click (expand) handler.
64
+ */
65
+ setRow(resolve: ResolveRow, click?: () => boolean): void {
62
66
  this.resolve = resolve;
67
+ this.click = click;
68
+ }
69
+
70
+ // Pi's MouseRegion asks its child first, so a handled click never reaches
71
+ // the region's own expand/collapse toggle.
72
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
73
+ if (event.type !== "click" || event.button !== "left") return undefined;
74
+ return this.click?.() ? { handled: true } : undefined;
63
75
  }
64
76
 
65
77
  /**
@@ -29,6 +29,11 @@ import {
29
29
  DEFAULTS,
30
30
  DEFAULT_TOKENS,
31
31
  GLYPH_PRESETS,
32
+ FRESH_PRESET,
33
+ LOOK_KEYS,
34
+ PRESETS,
35
+ isFresh,
36
+ isPreset,
32
37
  SETTINGS_KEY,
33
38
  type BasicSettings,
34
39
  type Glyphs,
@@ -153,8 +158,10 @@ function readSettingsFile(path: string): Json {
153
158
  * this is hand-edited input.
154
159
  */
155
160
  function coerce(raw: unknown, into: Settings): Settings {
156
- if (!isRecord(raw)) return into;
157
161
  const out = { ...into };
162
+ // A user with no basic key has never configured anything: they get the default preset.
163
+ if (isFresh(raw)) out.preset = FRESH_PRESET;
164
+ if (!isRecord(raw)) return out;
158
165
 
159
166
  for (const key of BASIC_KEYS) {
160
167
  if (typeof DEFAULT_BASIC[key] === "boolean" && typeof raw[key] === "boolean") {
@@ -169,6 +176,7 @@ function coerce(raw: unknown, into: Settings): Settings {
169
176
  if (Array.isArray(raw.excludeTools)) {
170
177
  out.excludeTools = raw.excludeTools.filter((name): name is string => typeof name === "string");
171
178
  }
179
+ if (isPreset(raw.preset)) out.preset = raw.preset;
172
180
  if (raw.glyphStyle === "unicode" || raw.glyphStyle === "ascii") {
173
181
  out.glyphStyle = raw.glyphStyle;
174
182
  }
@@ -204,6 +212,17 @@ export function loadSettings(env = process.env): Settings {
204
212
  return coerce(global[SETTINGS_KEY], DEFAULTS);
205
213
  }
206
214
 
215
+ /**
216
+ * Look keys to write when the user switches to `custom` for the first time: the
217
+ * `lite` look, so "custom" starts from something known. Undefined when the file
218
+ * already holds any of the user's own look keys (they are kept untouched).
219
+ */
220
+ export function customSeed(env = process.env): Record<string, boolean> | undefined {
221
+ const block = readSettingsFile(globalSettingsPath(env))[SETTINGS_KEY];
222
+ const own = isRecord(block) && LOOK_KEYS.some((key) => key in block);
223
+ return own ? undefined : PRESETS.lite;
224
+ }
225
+
207
226
  /**
208
227
  * One-time migration of the old `/quiet` preference.
209
228
  *
@@ -232,7 +251,7 @@ export type SaveResult =
232
251
 
233
252
  /**
234
253
  * Persist only the supplied editor-managed settings into the global agent file.
235
- * A normal edit passes one key; Restore defaults intentionally passes all.
254
+ * A normal edit passes one key.
236
255
  * Hand-written `glyphs`/`tokens`/`excludeTools` values survive untouched.
237
256
  *
238
257
  * REFUSES to write a file containing comments. `JSON.stringify` would silently
@@ -263,6 +282,8 @@ export function saveBasicSettings(settings: Partial<BasicSettings>, env = proces
263
282
  }
264
283
  const existing = isRecord(root[SETTINGS_KEY]) ? root[SETTINGS_KEY] : {};
265
284
  const merged: Json = { ...existing };
285
+ // The first write must pin the fresh-install preset, or the file would stop looking fresh and become `custom`.
286
+ if (isFresh(existing)) merged.preset = FRESH_PRESET;
266
287
  for (const key of BASIC_KEYS) {
267
288
  if (settings[key] !== undefined) merged[key] = settings[key];
268
289
  }
package/src/config-ui.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  import { truncateToWidth, type AutocompleteItem, type Component, type SettingItem, type SettingsList, type SettingsListTheme } from "@earendil-works/pi-tui";
4
4
  import {
5
5
  BASIC_KEYS,
6
- DEFAULT_BASIC,
6
+ LOOK_KEYS,
7
+ PRESET_NAMES,
8
+ isPreset,
7
9
  type BasicKey,
8
10
  type BasicSettings,
9
11
  type Config,
@@ -20,6 +22,11 @@ type Field = {
20
22
 
21
23
  /** Label and hover description for every basic setting, in display order. */
22
24
  export const FIELDS: Field[] = [
25
+ {
26
+ key: "preset",
27
+ label: "Preset",
28
+ description: "off = native Pi, lite = one-line rows, full = + groups and thinking rows, max = + collapsed earlier activity. custom uses your own values. A preset only sets the four look rows; your other settings are kept.",
29
+ },
23
30
  {
24
31
  key: "compactToolRows",
25
32
  label: "Compact tool rows",
@@ -83,18 +90,27 @@ type InputComponent = Component & { handleInput(data: string): void };
83
90
  type SettingsListConstructor = new (...args: ConstructorParameters<typeof SettingsList>) => SettingsList;
84
91
  type BasicValue = BasicSettings[BasicKey];
85
92
 
86
- /** Conditional rows plus their display values. */
87
- export function items(settings: Settings): SettingItem[] {
93
+ const CHOICES: Partial<Record<BasicKey, string[]>> = {
94
+ preset: [...PRESET_NAMES],
95
+ activitySummary: ["elapsed time", "tools used"],
96
+ glyphStyle: ["Unicode", "ASCII"],
97
+ };
98
+
99
+ const isLook = (key: string) => (LOOK_KEYS as readonly string[]).includes(key);
100
+
101
+ /** Conditional rows plus their display values. `dim` greys the look rows a preset owns. */
102
+ export function items(settings: Settings, dim?: (text: string) => string): SettingItem[] {
88
103
  return FIELDS.filter((field) => !field.activityOption || settings.foldIntermediateActivity).map(
89
104
  ({ key, label, description }) => {
90
- const value = settings[key];
91
- return {
92
- id: key,
93
- label,
94
- description,
95
- currentValue: displayValue(value),
96
- values: key === "activitySummary" ? ["elapsed time", "tools used"] : key === "glyphStyle" ? ["Unicode", "ASCII"] : ["on", "off"],
97
- };
105
+ const value = displayValue(settings[key]);
106
+ // SettingsList has no disabled rows, so a locked row shows its dimmed value
107
+ // as its ONLY value: Enter "cycles" to the same text and nothing changes.
108
+ if (dim && isLook(key)) {
109
+ const shown = dim(value);
110
+ const hint = `Set by the '${settings.preset}' preset. Switch Preset to 'custom' to edit (your custom values are kept).`;
111
+ return { id: key, label, description: hint, currentValue: shown, values: [shown] };
112
+ }
113
+ return { id: key, label, description, currentValue: value, values: CHOICES[key] ?? ["on", "off"] };
98
114
  },
99
115
  );
100
116
  }
@@ -104,39 +120,35 @@ export type ConfigScreenDeps = {
104
120
  theme: SettingsListTheme;
105
121
  config: Config;
106
122
  onChange: (key: BasicKey, value: BasicValue) => void;
107
- onReset: () => void;
108
123
  onClose: () => void;
109
124
  };
110
125
 
111
126
  /** Build a live screen; toggling prose folding immediately adds/removes its two child rows. */
112
127
  export function createConfigScreen(deps: ConfigScreenDeps): InputComponent {
113
- const { SettingsList, theme, config, onChange, onReset, onClose } = deps;
128
+ const { SettingsList, theme, config, onChange, onClose } = deps;
114
129
  let list: SettingsList;
130
+ let locked = false;
115
131
 
116
132
  const build = () => {
117
- const rows = [
118
- ...items(config.all()),
119
- {
120
- id: "restoreDefaults",
121
- label: "Restore defaults",
122
- description: "Reset the settings in this editor, including Symbols. Custom glyphs, colours, and excluded tools are kept.",
123
- currentValue: "Enter to restore",
124
- values: ["Restore"],
125
- },
126
- ];
133
+ const preset = config.all().preset;
134
+ locked = preset !== "custom";
135
+ const rows = items(config.all(), locked ? theme.hint : undefined);
127
136
  return new SettingsList(
128
137
  rows,
129
138
  rows.length,
130
139
  theme,
131
140
  (id, displayValue) => {
132
- if (id === "restoreDefaults") {
133
- for (const key of BASIC_KEYS) config.set(key, DEFAULT_BASIC[key]);
134
- onReset();
141
+ if (!isBasicKey(id)) return;
142
+ if (id === "preset") {
143
+ const value = isPreset(displayValue) ? displayValue : "custom";
144
+ config.set(id, value);
145
+ onChange(id, value);
135
146
  list = build();
136
147
  list.selectItem(id);
137
148
  return;
138
149
  }
139
- if (!isBasicKey(id)) return;
150
+ // Locked rows only echo their own text; do nothing (their description explains).
151
+ if (locked && isLook(id)) return;
140
152
  if (id === "activitySummary") {
141
153
  const value = displayValue === "tools used" ? "tools" : "elapsed";
142
154
  config.set(id, value);
@@ -176,6 +188,7 @@ function isBasicKey(id: string): id is BasicKey {
176
188
  }
177
189
 
178
190
  function displayValue(value: BasicValue): string {
191
+ if (isPreset(value)) return value;
179
192
  if (value === "elapsed") return "elapsed time";
180
193
  if (value === "tools") return "tools used";
181
194
  if (value === "unicode") return "Unicode";
@@ -199,7 +212,8 @@ export function summary(settings: Settings): string {
199
212
  const width = Math.max(...visible.map((field) => field.label.length));
200
213
  return [
201
214
  ...visible.map(({ key, label }) => {
202
- return ` ${label.padEnd(width)} ${displayValue(settings[key])}`;
215
+ const dimmed = settings.preset !== "custom" && isLook(key) ? " (from preset)" : "";
216
+ return ` ${label.padEnd(width)} ${displayValue(settings[key])}${dimmed}`;
203
217
  }),
204
218
  "",
205
219
  " /minimalist change these",
package/src/config.ts CHANGED
@@ -85,7 +85,28 @@ export const DEFAULT_TOKENS: Tokens = {
85
85
  /** Settings `/minimalist` can edit: visible effect, no vocabulary needed. */
86
86
  export type ActivitySummary = "elapsed" | "tools";
87
87
 
88
+ export const PRESET_NAMES = ["off", "lite", "full", "max", "custom"] as const;
89
+ export type Preset = (typeof PRESET_NAMES)[number];
90
+
91
+ export function isPreset(value: unknown): value is Preset {
92
+ return (PRESET_NAMES as readonly unknown[]).includes(value);
93
+ }
94
+
95
+ /** The only settings a preset names. Every other setting stays the user's own. */
96
+ export const LOOK_KEYS = ["compactToolRows", "groupToolRuns", "foldIntermediateActivity", "thinkingAsToolCall"] as const;
97
+ export type LookKey = (typeof LOOK_KEYS)[number];
98
+
99
+ /** Preset of a user who has never configured anything (no basic key in the `minimalist` block). */
100
+ export const FRESH_PRESET: Preset = "full";
101
+
102
+ /** True when the `minimalist` block holds no basic key, so the user never configured anything. */
103
+ export function isFresh(block: unknown): boolean {
104
+ return typeof block !== "object" || block === null || !BASIC_KEYS.some((key) => key in block);
105
+ }
106
+
88
107
  export type BasicSettings = {
108
+ /** A named bundle of the four look settings below. `custom` uses the user's own values for all of them. */
109
+ preset: Preset;
89
110
  /** Master switch. Off restores Pi's native tool cards entirely. */
90
111
  compactToolRows: boolean;
91
112
  /** Fold adjacent finished rows into one summary line. */
@@ -127,6 +148,7 @@ export type AdvancedSettings = {
127
148
  export type Settings = BasicSettings & AdvancedSettings;
128
149
 
129
150
  export const DEFAULT_BASIC: BasicSettings = {
151
+ preset: "custom",
130
152
  compactToolRows: true,
131
153
  groupToolRuns: true,
132
154
  foldIntermediateActivity: false,
@@ -140,6 +162,17 @@ export const DEFAULT_BASIC: BasicSettings = {
140
162
  glyphStyle: "unicode",
141
163
  };
142
164
 
165
+ /**
166
+ * The four look settings each non-custom preset forces. A preset is a LAYER
167
+ * applied on read (see `Config`); it never writes the user's own keys.
168
+ */
169
+ export const PRESETS: Record<Exclude<Preset, "custom">, Record<LookKey, boolean>> = {
170
+ off: { compactToolRows: false, groupToolRuns: false, foldIntermediateActivity: false, thinkingAsToolCall: false },
171
+ lite: { compactToolRows: true, groupToolRuns: false, foldIntermediateActivity: false, thinkingAsToolCall: false },
172
+ full: { compactToolRows: true, groupToolRuns: true, foldIntermediateActivity: false, thinkingAsToolCall: true },
173
+ max: { compactToolRows: true, groupToolRuns: true, foldIntermediateActivity: true, thinkingAsToolCall: true },
174
+ };
175
+
143
176
  export const DEFAULT_ADVANCED: AdvancedSettings = {
144
177
  // `subagent` ships a renderer showing run id, state and a ctrl+o hint that one
145
178
  // line cannot carry. Excluded by NAME rather than by the old "does this tool
@@ -167,6 +200,11 @@ export type BasicKey = keyof BasicSettings;
167
200
  // Live configuration
168
201
  // ---------------------------------------------------------------------------
169
202
 
203
+ /** defaults < the user's own keys < the preset's four look keys (none for `custom`). */
204
+ function layer(user: Settings): Settings {
205
+ return user.preset === "custom" ? user : { ...user, ...PRESETS[user.preset] };
206
+ }
207
+
170
208
  /**
171
209
  * The live settings object.
172
210
  *
@@ -175,7 +213,12 @@ export type BasicKey = keyof BasicSettings;
175
213
  * and no restart.
176
214
  */
177
215
  export class Config {
216
+ /** The user's own values (what settings.json says), before any preset. */
178
217
  private settings: Settings;
218
+ /** What the rest of the extension sees: `settings` with the preset layered on. */
219
+ private get view(): Settings {
220
+ return layer(this.settings);
221
+ }
179
222
 
180
223
  /**
181
224
  * Values changed in this session that are NOT in settings.json.
@@ -192,9 +235,10 @@ export class Config {
192
235
  }
193
236
 
194
237
  get<K extends keyof Settings>(key: K): Settings[K] {
195
- return this.settings[key];
238
+ return this.view[key];
196
239
  }
197
240
 
241
+ /** Writes the user's own value; a non-custom preset keeps shadowing it until `preset` is custom. */
198
242
  set<K extends keyof Settings>(key: K, value: Settings[K]): void {
199
243
  this.settings = { ...this.settings, [key]: value };
200
244
  }
@@ -211,7 +255,7 @@ export class Config {
211
255
  }
212
256
 
213
257
  all(): Settings {
214
- return this.settings;
258
+ return this.view;
215
259
  }
216
260
 
217
261
  /** Adopt freshly read settings, re-applying anything held only in memory. */
@@ -221,12 +265,12 @@ export class Config {
221
265
 
222
266
  /** Preset chosen by `glyphStyle`, then per-glyph overrides. */
223
267
  glyphs(): Glyphs {
224
- return { ...GLYPH_PRESETS[this.settings.glyphStyle], ...this.settings.glyphs };
268
+ return { ...GLYPH_PRESETS[this.view.glyphStyle], ...this.view.glyphs };
225
269
  }
226
270
 
227
271
  /** Default token map plus overrides. */
228
272
  tokens(): Tokens {
229
- return { ...DEFAULT_TOKENS, ...this.settings.tokens };
273
+ return { ...DEFAULT_TOKENS, ...this.view.tokens };
230
274
  }
231
275
 
232
276
  /**
@@ -236,11 +280,11 @@ export class Config {
236
280
  * alongside it wherever claiming happens.
237
281
  */
238
282
  isExcluded(toolName: string): boolean {
239
- return this.settings.excludeTools.includes(toolName);
283
+ return this.view.excludeTools.includes(toolName);
240
284
  }
241
285
 
242
286
  /** True when this tool should be drawn as a compact row. */
243
287
  compacts(toolName: string): boolean {
244
- return this.settings.compactToolRows && !this.isExcluded(toolName);
288
+ return this.view.compactToolRows && !this.isExcluded(toolName);
245
289
  }
246
290
  }
@@ -55,6 +55,8 @@ export class RunGrouping {
55
55
  private cycle = 0;
56
56
  private cycles = new Map<number, Cycle>();
57
57
  private agentActive = false;
58
+ /** Rows the user opened out of a summary by clicking it. Never fold again. */
59
+ private opened = new Set<string>();
58
60
  private version = 0;
59
61
  private cache?: ViewCache;
60
62
 
@@ -226,6 +228,33 @@ export class RunGrouping {
226
228
  return painter.summary(view);
227
229
  }
228
230
 
231
+ /**
232
+ * Open the summary this row draws back into separate rows, after a click.
233
+ *
234
+ * Needed because the summary is drawn by ONE member (the tail) and the others
235
+ * draw zero lines: Pi's own click handler would expand only the tail.
236
+ * Returns false when the row draws no summary, so Pi's click still applies.
237
+ */
238
+ open(id: string): boolean {
239
+ const entry = this.byId.get(id);
240
+ if (!entry) return false;
241
+ const { activity, grouped } = this.views();
242
+ 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"));
245
+ } else if (typeof grouped.get(id) === "object") {
246
+ // A run's hidden members are the contiguous "hide" rows before its tail.
247
+ for (let i = this.entries.indexOf(entry) - 1; i >= 0 && grouped.get(this.entries[i].id) === "hide"; i--) {
248
+ members.push(this.entries[i]);
249
+ }
250
+ } else {
251
+ return false;
252
+ }
253
+ for (const member of members) this.opened.add(member.id);
254
+ this.invalidateViews();
255
+ return true;
256
+ }
257
+
229
258
  view(id: string): RowView {
230
259
  return this.views().grouped.get(id) ?? "show";
231
260
  }
@@ -320,7 +349,7 @@ export class RunGrouping {
320
349
  }
321
350
 
322
351
  private activityFoldable(entry: Entry): boolean {
323
- if (entry.expanded) return false;
352
+ if (entry.expanded || this.opened.has(entry.id)) return false;
324
353
  if (entry.outcome === "pending" && entry.kind !== "prose" && this.config.get("keepActiveToolsExpanded")) {
325
354
  return false;
326
355
  }
@@ -328,7 +357,7 @@ export class RunGrouping {
328
357
  }
329
358
 
330
359
  private foldable(entry: Entry | undefined): boolean {
331
- if (!entry?.foldable || entry.expanded) return false;
360
+ if (!entry?.foldable || entry.expanded || this.opened.has(entry.id)) return false;
332
361
  if (entry.kind === "thinking" && !this.config.get("thinkingAsToolCall")) return false;
333
362
  if (entry.outcome === "pending" && this.config.get("keepActiveToolsExpanded")) return false;
334
363
  return true;
package/src/thinking.ts CHANGED
@@ -59,7 +59,7 @@ export function createThinkingPreview(deps: ThinkingDeps): ThinkingPreview {
59
59
 
60
60
  if (owner && runIndex !== undefined) {
61
61
  const id = grouping.thinkingId(owner, runIndex);
62
- line.setRow(() => grouping.rowFor(id, new Painter(theme, config), paint));
62
+ line.setRow(() => grouping.rowFor(id, new Painter(theme, config), paint), () => grouping.open(id));
63
63
  } else {
64
64
  line.setRow(paint);
65
65
  }
@@ -194,7 +194,10 @@ export function createToolRenderer(deps: RendererDeps): ToolRenderer {
194
194
  return component;
195
195
  }
196
196
  grouping.observe(id, summaryName(name, args), status.outcome, context.expanded === true);
197
- component.setRow((width) => grouping.rowFor(id, new Painter(theme, config), () => paint(width)));
197
+ component.setRow(
198
+ (width) => grouping.rowFor(id, new Painter(theme, config), () => paint(width)),
199
+ () => grouping.open(id),
200
+ );
198
201
  return component;
199
202
  },
200
203