pi-minimalist 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pinglei He
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # pi-minimalist
2
+
3
+ Make Pi's tool calls and optional thinking previews quieter: compact, one-line rows without losing access to the original tool output.
4
+
5
+ ## What does minimalist mode look like
6
+
7
+ Before pi-minimalist:
8
+
9
+ <img width="656" height="550" alt="before" src="https://github.com/user-attachments/assets/aa1602f7-5db1-49b8-a4d3-cbd1aef69ea0" />
10
+
11
+
12
+ After pi-minimalist:
13
+
14
+ <img width="699" height="50" alt="after" src="https://github.com/user-attachments/assets/c0e8bca4-afa7-42c1-9bf0-69f267d18d9f" />
15
+
16
+ ## Installation
17
+ Install with npm:
18
+ ```bash
19
+ pi install npm:pi-minimalist
20
+ ```
21
+
22
+ ## Configuration
23
+
24
+ You may configure different collapse options / verbosity levels with the slash command `/minimalist config`.
package/index.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * pi-minimalist — WIRING ONLY. All behavior lives in src/.
3
+ *
4
+ * 1. compact one-line tool rows, with Pi's own expanded output on Ctrl+O;
5
+ * 2. compact collapsed thinking previews;
6
+ * 3. optional folding of adjacent rows into one summary line.
7
+ *
8
+ * NOTHING ON DISK IS PATCHED. Pi's bundled CLI hands extensions its own live
9
+ * module namespaces (jiti `virtualModules`), so the component classes imported
10
+ * here are the exact objects the running TUI instantiates, and `core-patch.ts`
11
+ * wraps their prototypes at load time. See that file for the full rationale.
12
+ *
13
+ * This extension registers NO tools. Re-registering built-ins would change their
14
+ * source ownership and make pi-subagents drop them from child tool allowlists.
15
+ */
16
+
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import * as core from "@earendil-works/pi-coding-agent";
19
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
20
+ // SettingsList lives in pi-tui; its Pi-styled theme comes from pi-coding-agent.
21
+ // Using both means `/minimalist` is the same component, with the same
22
+ // keybindings and colours, as Pi's own `/settings`.
23
+ import { SettingsList } from "@earendil-works/pi-tui";
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";
27
+ import { argumentCompletions, createConfigScreen, summary } from "./src/config-ui.ts";
28
+ import { patchCore } from "./src/core-patch.ts";
29
+ import { refreshMcpTools } from "./src/tools.ts";
30
+
31
+ export default function (pi: ExtensionAPI) {
32
+ // Adopted from the previous load when reloading: existing transcript rows close
33
+ // over these and resolve appearance at render time, so replacing them would
34
+ // strand every row on stale state.
35
+ const { config, grouping } = sharedState(loadSettings());
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
+ 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.
46
+ patchCore(core);
47
+
48
+ const refresh = () => {
49
+ // MCP tool names depend on adapter configuration, so they are discovered from
50
+ // public source metadata rather than a maintained whitelist.
51
+ refreshMcpTools(pi.getAllTools());
52
+ // Re-read settings.json so an external edit (or pi-env) applies without a
53
+ // restart. Session overrides are re-applied by Config.replace().
54
+ config.replace(loadSettings());
55
+ };
56
+ pi.on("session_start", refresh);
57
+ pi.on("turn_start", refresh);
58
+ pi.on("agent_start", () => grouping.agentStarted());
59
+ pi.on("agent_settled", () => grouping.agentSettled());
60
+
61
+ pi.registerCommand("minimalist", {
62
+ description: "Open pi-minimalist settings; `status` shows current state",
63
+ getArgumentCompletions: argumentCompletions,
64
+ handler: async (args, ctx) => {
65
+ const command = args.trim().toLowerCase();
66
+ if (command && command !== "config" && command !== "status") {
67
+ ctx.ui.notify("Usage: /minimalist [config|status]", "warning");
68
+ return;
69
+ }
70
+ if (command === "status") {
71
+ ctx.ui.notify(`pi-minimalist\n${summary(config.all())}`, "info");
72
+ return;
73
+ }
74
+ if (!ctx.hasUI || ctx.mode !== "tui") {
75
+ ctx.ui.notify("/minimalist needs the interactive TUI; use /minimalist status for current settings", "warning");
76
+ return;
77
+ }
78
+
79
+ await ctx.ui.custom<void>((_tui, _theme, _keybindings, done) =>
80
+ createConfigScreen({
81
+ SettingsList,
82
+ theme: getSettingsListTheme(),
83
+ config,
84
+ onChange: (key, value) => persist(ctx, config, key, value),
85
+ onReset: () => persistDefaults(ctx, config),
86
+ onClose: () => done(),
87
+ }),
88
+ );
89
+ },
90
+ });
91
+ }
92
+
93
+ type NotifyContext = { ui: { notify(message: string, type?: "info" | "warning" | "error"): void } };
94
+
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
+ function persist(
114
+ ctx: NotifyContext,
115
+ config: Config,
116
+ key: BasicKey,
117
+ value: BasicSettings[BasicKey],
118
+ ): 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;
124
+ }
125
+ config.setSessionOverride(key, value);
126
+ ctx.ui.notify(
127
+ result.reason === "comments"
128
+ ? `Applied for this session only. settings.json has comments, which JSON.stringify would delete — set "minimalist": { "${key}": ${JSON.stringify(value)} } by hand to persist.`
129
+ : `Applied for this session only; settings.json could not be written (${result.reason}).`,
130
+ "warning",
131
+ );
132
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "pi-minimalist",
3
+ "version": "0.1.0",
4
+ "description": "Compact one-line tool and thinking rows for Pi's interactive transcript",
5
+ "keywords": ["pi-package"],
6
+ "license": "MIT",
7
+ "author": "Pinglei He",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/EviHex/pi-minimalist.git"
11
+ },
12
+ "type": "module",
13
+ "files": ["index.ts", "src/", "README.md", "LICENSE"],
14
+ "peerDependencies": {
15
+ "@earendil-works/pi-coding-agent": "*",
16
+ "@earendil-works/pi-tui": "*"
17
+ },
18
+ "pi": {
19
+ "extensions": ["./index.ts"]
20
+ }
21
+ }
package/src/bridge.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The boundary between this extension and Pi's core components.
3
+ *
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.
8
+ *
9
+ * SINGLE SOURCE OF TRUTH for the symbol names, shared by the wrappers, the state
10
+ * that must survive `/reload`, and the tests.
11
+ */
12
+
13
+ import { Config } from "./config.ts";
14
+ 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";
18
+ import type { Timers } from "./components.ts";
19
+
20
+ 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
+
28
+ /** Live settings, retained across a /reload. */
29
+ config: "pi.minimalist.config",
30
+ /** Run-grouping state, retained across a /reload. */
31
+ grouping: "pi.minimalist.grouping",
32
+
33
+ /** Chronology hooks that keep thinking and prose in transcript order. */
34
+ observeThinking: "pi.minimalist.observeThinking",
35
+ observeProse: "pi.minimalist.observeProse",
36
+ /** `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",
42
+ /**
43
+ * Spacer suppression for a fully hidden assistant message.
44
+ *
45
+ * There is deliberately NO tool-row equivalent: a compact row uses
46
+ * `renderShell: "self"`, and core's self-shell branch emits its separator
47
+ * 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.
50
+ */
51
+ messageSpacer: "pi.minimalist.messageSpacer",
52
+ /** `() => boolean`: keep a STREAMING thinking block expanded. */
53
+ keepActiveThinkingExpanded: "pi.minimalist.keepActiveThinkingExpanded",
54
+ } as const;
55
+
56
+ type Globals = Record<symbol, unknown>;
57
+
58
+ function slot(key: keyof typeof BRIDGE_SYMBOLS): symbol {
59
+ return Symbol.for(BRIDGE_SYMBOLS[key]);
60
+ }
61
+
62
+ /**
63
+ * State that must survive `/reload`.
64
+ *
65
+ * Existing transcript rows close over these objects and resolve their appearance
66
+ * at render time, so replacing an instance would strand every row on stale state.
67
+ */
68
+ export type SharedState = { config: Config; grouping: RunGrouping };
69
+
70
+ /** Adopt the previous load's state, or start fresh. */
71
+ export function sharedState(settings?: ConstructorParameters<typeof Config>[0]): SharedState {
72
+ const globals = globalThis as Globals;
73
+ const existingConfig = globals[slot("config")];
74
+ const existingGrouping = globals[slot("grouping")];
75
+ if (existingConfig instanceof Config && existingGrouping instanceof RunGrouping) {
76
+ return { config: existingConfig, grouping: existingGrouping };
77
+ }
78
+ const config = new Config(settings);
79
+ return { config, grouping: new RunGrouping(config) };
80
+ }
81
+
82
+ export type InstallOptions = SharedState & {
83
+ /** Injectable repaint scheduler; production uses Node's timers. */
84
+ timers?: Timers;
85
+ };
86
+
87
+ /**
88
+ * Install every bridge.
89
+ *
90
+ * Each `/reload` overwrites these slots with fresh instances. Do NOT clear them
91
+ * from `session_shutdown`: reload ordering can let an old shutdown hook erase the
92
+ * newly installed bridges. Process exit clears globalThis naturally.
93
+ */
94
+ export function installBridges({ config, grouping, timers }: InstallOptions): void {
95
+ const globals = globalThis as Globals;
96
+
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);
103
+
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);
109
+ };
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
+
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");
124
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * TUI components. Purely presentational: they render painted rows (see row.ts)
3
+ * and know nothing about themes, tools, or run grouping.
4
+ *
5
+ * Kept free of Pi extension API imports so unit tests can exercise the real
6
+ * production components without loading an extension runtime.
7
+ */
8
+
9
+ import { truncateToWidth } from "@earendil-works/pi-tui";
10
+ import type { Component } from "@earendil-works/pi-tui";
11
+ import { gutterWidth, type Row } from "./row.ts";
12
+
13
+ /**
14
+ * Injectable timer pair. Production passes Node's globals; tests pass fakes so
15
+ * the elapsed timer can be advanced without sleeping and leaks are detectable.
16
+ */
17
+ export type Timers = {
18
+ setInterval: (callback: () => void, ms: number) => unknown;
19
+ clearInterval: (handle: unknown) => void;
20
+ };
21
+
22
+ export const realTimers: Timers = {
23
+ setInterval: (callback, ms) => {
24
+ const handle = setInterval(callback, ms);
25
+ // A forgotten ticker must never hold the process open.
26
+ handle.unref?.();
27
+ return handle;
28
+ },
29
+ clearInterval: (handle) => clearInterval(handle as ReturnType<typeof setInterval>),
30
+ };
31
+
32
+ /**
33
+ * Resolve what to draw, at render time. `null` means draw NOTHING (zero lines),
34
+ * which is how run grouping hides a row. Resolved on every render, so a config
35
+ * toggle repaints existing transcript rows with no core rebuild.
36
+ *
37
+ * Receives the real terminal width, because detail truncation budgets depend on
38
+ * it and TUI only reveals the width during render(). Passing a fixed character
39
+ * count from the caller cannot adapt to the actual viewport.
40
+ */
41
+ export type ResolveRow = (width: number) => Row | null;
42
+
43
+ /**
44
+ * One physical terminal line with width-aware truncation and full-width color.
45
+ *
46
+ * 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
48
+ * only supplies the real terminal width during render(), so truncating with a
49
+ * fixed character count in the caller cannot solve this reliably.
50
+ */
51
+ export class CompactLine implements Component {
52
+ private resolve: ResolveRow = () => null;
53
+ private ticker: unknown;
54
+ private timers: Timers;
55
+
56
+ constructor(timers: Timers = realTimers) {
57
+ this.timers = timers;
58
+ }
59
+
60
+ /** Install the row resolver. Called on every updateDisplay with fresh state. */
61
+ setRow(resolve: ResolveRow): void {
62
+ this.resolve = resolve;
63
+ }
64
+
65
+ /**
66
+ * Repaint once per second while a tool runs, so the elapsed timer ticks even
67
+ * for silent commands that produce no streaming output (the only other
68
+ * repaint trigger). stopTicker() clears it at a final state.
69
+ */
70
+ startTicker(requestRender: () => void): void {
71
+ if (this.ticker !== undefined) return; // already ticking
72
+ this.ticker = this.timers.setInterval(requestRender, 1000);
73
+ }
74
+
75
+ stopTicker(): void {
76
+ if (this.ticker !== undefined) {
77
+ this.timers.clearInterval(this.ticker);
78
+ this.ticker = undefined;
79
+ }
80
+ }
81
+
82
+ /** Test/inspection helper: is a repaint interval currently registered? */
83
+ isTicking(): boolean {
84
+ return this.ticker !== undefined;
85
+ }
86
+
87
+ render(width: number): string[] {
88
+ const row = this.resolve(width);
89
+ if (row === null) return [];
90
+
91
+ // TUI pads each line to terminal width, so no manual trailing padding is
92
+ // needed. truncateToWidth understands ANSI codes and wide Unicode glyphs,
93
+ // so colored text truncates at VISIBLE columns. The gutter is a fixed-width
94
+ // prefix, so the content gets the remaining columns. Width comes from the
95
+ // row's OWN gutter text, so a disabled gutter reclaims those columns.
96
+ const line = truncateToWidth(row.text, Math.max(1, width - gutterWidth(row.gutter)), "…");
97
+ return [row.gutter + (row.highlight ? row.highlight(line) : line)];
98
+ }
99
+
100
+ // Component contract allows cached components to be invalidated. This class
101
+ // computes one cheap line every render, so no cache needs clearing.
102
+ invalidate(): void {}
103
+ }
104
+
105
+ /**
106
+ * Collapsed results must render ZERO lines, not one blank line.
107
+ *
108
+ * Pi's Text component returns [""] for empty strings (one blank row), which
109
+ * doubled the height of every collapsed tool call. This is the only safe way to
110
+ * say "no content at all" while still returning a Component, as the
111
+ * renderResult slot contract requires.
112
+ */
113
+ export class FoldableProse implements Component {
114
+ private inner: Component;
115
+ private resolve: (width: number) => Row | null | undefined;
116
+
117
+ constructor(inner: Component, resolve: (width: number) => Row | null | undefined) {
118
+ this.inner = inner;
119
+ this.resolve = resolve;
120
+ }
121
+
122
+ render(width: number): string[] {
123
+ const row = this.resolve(width);
124
+ if (row === undefined) return this.inner.render(width);
125
+ const line = new CompactLine();
126
+ line.setRow(() => row);
127
+ return line.render(width);
128
+ }
129
+
130
+ invalidate(): void {
131
+ this.inner.invalidate?.();
132
+ }
133
+ }
134
+
135
+ export class EmptyComponent implements Component {
136
+ render(): string[] {
137
+ return [];
138
+ }
139
+
140
+ invalidate(): void {}
141
+ }
142
+
143
+ /**
144
+ * Wraps another component and prefixes EVERY line it renders with a gutter, so
145
+ * expanded output stays visually attached to the call row above it instead of
146
+ * blending into model prose.
147
+ *
148
+ * The inner component renders at a reduced width (the gutter occupies real
149
+ * columns), otherwise its own wrapping/truncation would overflow the row.
150
+ */
151
+ export class GutteredComponent implements Component {
152
+ private inner: Component;
153
+ private gutterText: string;
154
+
155
+ constructor(inner: Component, gutterText: string) {
156
+ this.inner = inner;
157
+ this.gutterText = gutterText;
158
+ }
159
+
160
+ /** Swap in the newest inner component while keeping this wrapper stable. */
161
+ setInner(inner: Component): void {
162
+ this.inner = inner;
163
+ }
164
+
165
+ render(width: number): string[] {
166
+ const lines = this.inner.render(Math.max(1, width - gutterWidth(this.gutterText)));
167
+ return lines.map((line) => this.gutterText + line);
168
+ }
169
+
170
+ invalidate(): void {
171
+ this.inner.invalidate?.();
172
+ }
173
+ }