pi-quiet-activity 1.3.1

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 Kevin Vargas
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,84 @@
1
+ # Quiet Activity
2
+
3
+ A global Pi extension that reduces an agent run to one activity indicator and the final answer.
4
+
5
+ ![Quiet Activity showing live working status in Pi](https://raw.githubusercontent.com/kevinvargasl/pi-quiet-activity/main/assets/pi-quiet-activity-demo.gif)
6
+
7
+ ## Quiet mode
8
+
9
+ While the agent works, the activity line follows the current operation:
10
+
11
+ ```text
12
+ User: Do this.
13
+ Working
14
+ Reading abc.ts...
15
+ Writing 1234.csv...
16
+ Calling MCP context7/resolve-library-id...
17
+ ```
18
+
19
+ Built-in file, shell, search, web, MCP, task, and question tools receive concise labels. MCP proxy calls include the server and tool when those fields are available. Unknown tools display `Using <tool-name>...`. Long details are normalized to one line and truncated. Terminal control sequences are removed, and common credentials in commands or URLs are redacted.
20
+
21
+ The TUI hides:
22
+
23
+ - thinking/reasoning blocks
24
+ - intermediate assistant narration attached to tool-using turns
25
+ - all built-in and extension tool calls/results
26
+ - the final answer while it is still streaming
27
+
28
+ When the agent settles, the working line disappears and a compact elapsed time appears in dimmed text before the finalized answer, such as `Worked for 45s`, `Worked for 1m 23s`, or `Worked for 2h 12m`. The extension does not add anything to the footer.
29
+
30
+ ![Elapsed time displayed before the final answer](https://raw.githubusercontent.com/kevinvargasl/pi-quiet-activity/main/assets/elapsed-time-message.png)
31
+
32
+ Some models, including Claude Opus 5 through GitHub Copilot, can put useful details in a tool-calling turn and finish with only a short phrase. Quiet mode now asks the model to repeat those details in a self-contained final response. Rendering stays display-only. Tool execution and saved session data are unchanged, but the extra instruction is part of the model's system prompt while quiet mode is enabled in the TUI.
33
+
34
+ ## Toggle
35
+
36
+ Press:
37
+
38
+ ```text
39
+ F9
40
+ ```
41
+
42
+ This shortcut is not assigned by Pi's default keybindings and works in Windows and macOS terminals. On a Mac keyboard configured to use the top row for media controls, press `Fn+F9`. It toggles between quiet mode and Pi's normal transcript. A brief notification reports only whether quiet activity is enabled or disabled; no permanent footer indicator is added. The setting persists across restarts in:
43
+
44
+ ```text
45
+ ~/.pi/agent/extension-data/quiet-activity/config.json
46
+ ```
47
+
48
+ You can also use:
49
+
50
+ ```text
51
+ /quiet-activity
52
+ /quiet-activity on
53
+ /quiet-activity off
54
+ /quiet-activity toggle
55
+ /quiet-activity status
56
+ ```
57
+
58
+ ## Installation
59
+
60
+ Install the latest release from npm:
61
+
62
+ ```bash
63
+ pi install npm:pi-quiet-activity
64
+ ```
65
+
66
+ To pin this release:
67
+
68
+ ```bash
69
+ pi install npm:pi-quiet-activity@1.3.1
70
+ ```
71
+
72
+ To try the current main branch without installing it:
73
+
74
+ ```bash
75
+ pi -e git:github.com/kevinvargasl/pi-quiet-activity
76
+ ```
77
+
78
+ Run `/reload` in an existing Pi session after installation, or restart Pi.
79
+
80
+ ## Compatibility
81
+
82
+ The extension patches Pi's exported `AssistantMessageComponent` and `ToolExecutionComponent` render methods for the current TUI session. It restores the original methods during session shutdown/reload.
83
+
84
+ Renderer extensions that patch the same component prototypes may conflict. Turning quiet mode off restores normal rendering through whatever renderer was active when this extension loaded.
@@ -0,0 +1,57 @@
1
+ import type {
2
+ ExtensionContext,
3
+ ToolExecutionEndEvent,
4
+ ToolExecutionStartEvent,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { toolActivityLabel } from "./labels.ts";
7
+
8
+ export interface ActivityDisplay {
9
+ reset(ctx: ExtensionContext): void;
10
+ clear(): void;
11
+ start(event: ToolExecutionStartEvent, ctx: ExtensionContext): void;
12
+ end(event: ToolExecutionEndEvent, ctx: ExtensionContext): void;
13
+ refresh(ctx: ExtensionContext): void;
14
+ restore(ctx: ExtensionContext): void;
15
+ }
16
+
17
+ export function createActivityDisplay(
18
+ isEnabled: () => boolean,
19
+ ): ActivityDisplay {
20
+ const active = new Map<string, string>();
21
+
22
+ function text(): string {
23
+ const activity = Array.from(active.values()).at(-1);
24
+ if (!activity) return "Working";
25
+ return `${activity}${activity.endsWith("...") ? "" : "..."}`;
26
+ }
27
+
28
+ function refresh(ctx: ExtensionContext): void {
29
+ if (ctx.mode !== "tui") return;
30
+ ctx.ui.setWorkingMessage(isEnabled() ? text() : undefined);
31
+ ctx.ui.setWorkingVisible(true);
32
+ }
33
+
34
+ return {
35
+ reset(ctx) {
36
+ active.clear();
37
+ refresh(ctx);
38
+ },
39
+ clear: () => active.clear(),
40
+ start(event, ctx) {
41
+ active.delete(event.toolCallId);
42
+ active.set(event.toolCallId, toolActivityLabel(event.toolName, event.args));
43
+ refresh(ctx);
44
+ },
45
+ end(event, ctx) {
46
+ active.delete(event.toolCallId);
47
+ refresh(ctx);
48
+ },
49
+ refresh,
50
+ restore(ctx) {
51
+ active.clear();
52
+ if (ctx.mode !== "tui") return;
53
+ ctx.ui.setWorkingMessage();
54
+ ctx.ui.setWorkingVisible(true);
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,154 @@
1
+ const MAX_DETAIL_LENGTH = 72;
2
+ const ANSI_SEQUENCE =
3
+ /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/g;
4
+ const CONTROL_CHARACTERS =
5
+ /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u202A-\u202E\u2066-\u2069]/g;
6
+ const BEARER_TOKEN = /\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi;
7
+ const SENSITIVE_ASSIGNMENT =
8
+ /\b(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|passwd|pwd)(\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s&]+)/gi;
9
+ const SENSITIVE_FLAG =
10
+ /(--?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|passwd|pwd)\b(?:=|\s)+)(?:"[^"]*"|'[^']*'|[^\s]+)/gi;
11
+
12
+ type ToolArguments = Record<string, unknown>;
13
+
14
+ interface ActivitySpec {
15
+ action: string;
16
+ fallback: string;
17
+ keys: readonly string[];
18
+ }
19
+
20
+ const ACTIVITY_SPECS: Record<string, ActivitySpec> = {
21
+ read: {
22
+ action: "Reading",
23
+ fallback: "Reading a file",
24
+ keys: ["path", "file"],
25
+ },
26
+ write: {
27
+ action: "Writing",
28
+ fallback: "Writing a file",
29
+ keys: ["path", "file"],
30
+ },
31
+ edit: {
32
+ action: "Editing",
33
+ fallback: "Editing a file",
34
+ keys: ["path", "file"],
35
+ },
36
+ bash: { action: "Running", fallback: "Running a command", keys: ["command"] },
37
+ grep: {
38
+ action: "Searching files for",
39
+ fallback: "Searching files",
40
+ keys: ["query", "pattern"],
41
+ },
42
+ search: {
43
+ action: "Searching files for",
44
+ fallback: "Searching files",
45
+ keys: ["query", "pattern"],
46
+ },
47
+ find: {
48
+ action: "Finding",
49
+ fallback: "Finding files",
50
+ keys: ["pattern", "query", "path"],
51
+ },
52
+ web_search: {
53
+ action: "Searching the web for",
54
+ fallback: "Searching the web",
55
+ keys: ["query", "queries"],
56
+ },
57
+ source_check: {
58
+ action: "Checking",
59
+ fallback: "Checking sources",
60
+ keys: ["claim"],
61
+ },
62
+ fetch_content: {
63
+ action: "Fetching",
64
+ fallback: "Fetching content",
65
+ keys: ["url", "urls"],
66
+ },
67
+ mcp_call: {
68
+ action: "Calling",
69
+ fallback: "Calling an MCP tool",
70
+ keys: ["tool", "toolName", "name", "server"],
71
+ },
72
+ };
73
+
74
+ const STATIC_LABELS: Record<string, string> = {
75
+ get_search_content: "Reading search results",
76
+ todo: "Updating tasks",
77
+ ask_user_question: "Preparing a question",
78
+ };
79
+
80
+ function asArguments(value: unknown): ToolArguments {
81
+ return typeof value === "object" && value !== null
82
+ ? (value as ToolArguments)
83
+ : {};
84
+ }
85
+
86
+ function shorten(value: string): string {
87
+ const oneLine = value
88
+ .replace(ANSI_SEQUENCE, "")
89
+ .replace(CONTROL_CHARACTERS, "")
90
+ .replace(/\s+/g, " ")
91
+ .replace(BEARER_TOKEN, "$1 [redacted]")
92
+ .replace(SENSITIVE_ASSIGNMENT, "$1$2[redacted]")
93
+ .replace(SENSITIVE_FLAG, "$1[redacted]")
94
+ .trim();
95
+ return oneLine.length <= MAX_DETAIL_LENGTH
96
+ ? oneLine
97
+ : `${oneLine.slice(0, MAX_DETAIL_LENGTH - 3)}...`;
98
+ }
99
+
100
+ function firstString(value: unknown): string | undefined {
101
+ if (typeof value === "string" && value.trim()) return shorten(value);
102
+ if (!Array.isArray(value)) return undefined;
103
+ const match = value.find(
104
+ (item): item is string => typeof item === "string" && Boolean(item.trim()),
105
+ );
106
+ return match ? shorten(match) : undefined;
107
+ }
108
+
109
+ function findDetail(
110
+ args: ToolArguments,
111
+ keys: readonly string[],
112
+ ): string | undefined {
113
+ for (const key of keys) {
114
+ const value = firstString(args[key]);
115
+ if (value) return value;
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ function formatSpec(spec: ActivitySpec, args: ToolArguments): string {
121
+ const value = findDetail(args, spec.keys);
122
+ return value ? `${spec.action} ${value}` : spec.fallback;
123
+ }
124
+
125
+ function mcpActivityLabel(args: ToolArguments): string {
126
+ const server = findDetail(args, ["server", "serverName"]);
127
+ const tool = findDetail(args, ["tool", "toolName"]);
128
+ if (tool) return `Calling MCP ${server ? `${server}/${tool}` : tool}`;
129
+ if (server) return `Using MCP ${server}`;
130
+
131
+ const search = findDetail(args, ["search"]);
132
+ if (search) return `Searching MCP for ${search}`;
133
+
134
+ const target = findDetail(args, ["describe", "instructions", "name"]);
135
+ return target ? `Inspecting MCP ${target}` : "Using MCP";
136
+ }
137
+
138
+ export function toolActivityLabel(toolName: string, rawArgs: unknown): string {
139
+ const name = toolName.toLowerCase();
140
+ const staticLabel = STATIC_LABELS[name];
141
+ if (staticLabel) return staticLabel;
142
+
143
+ const args = asArguments(rawArgs);
144
+ if (name === "mcp") return mcpActivityLabel(args);
145
+
146
+ const spec = ACTIVITY_SPECS[name];
147
+ if (spec) return formatSpec(spec, args);
148
+
149
+ if (name.startsWith("mcp_")) {
150
+ const target = findDetail(args, ["tool", "toolName", "name"]);
151
+ return target ? `Calling ${target}` : `Calling ${shorten(toolName)}`;
152
+ }
153
+ return `Using ${shorten(toolName)}`;
154
+ }
@@ -0,0 +1,14 @@
1
+ export function formatElapsedTime(elapsedMs: number): string {
2
+ const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
3
+ if (totalSeconds < 60) return `${totalSeconds}s`;
4
+
5
+ const totalMinutes = Math.floor(totalSeconds / 60);
6
+ if (totalMinutes < 60) {
7
+ const seconds = totalSeconds % 60;
8
+ return seconds === 0 ? `${totalMinutes}m` : `${totalMinutes}m ${seconds}s`;
9
+ }
10
+
11
+ const hours = Math.floor(totalMinutes / 60);
12
+ const minutes = totalMinutes % 60;
13
+ return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
14
+ }
package/index.ts ADDED
@@ -0,0 +1,184 @@
1
+ import type {
2
+ AgentStartEvent,
3
+ BeforeAgentStartEvent,
4
+ ExtensionAPI,
5
+ ExtensionCommandContext,
6
+ ExtensionContext,
7
+ SessionShutdownEvent,
8
+ SessionStartEvent,
9
+ ToolExecutionEndEvent,
10
+ ToolExecutionStartEvent,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import {
16
+ createActivityDisplay,
17
+ type ActivityDisplay,
18
+ } from "./activity/display.ts";
19
+ import { formatElapsedTime } from "./activity/timer.ts";
20
+ import {
21
+ createQuietRenderPatcher,
22
+ type QuietRenderPatcher,
23
+ } from "./render/index.ts";
24
+
25
+ const SHORTCUT = "f9";
26
+ const FINAL_RESPONSE_INSTRUCTION =
27
+ "Quiet activity hides text from assistant turns that call tools. After tool use finishes, provide a self-contained final response. Repeat any result or explanation the user needs from earlier tool-calling turns.";
28
+ const CONFIG_PATH = join(
29
+ process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"),
30
+ "extension-data",
31
+ "quiet-activity",
32
+ "config.json",
33
+ );
34
+
35
+ type QuietMode = "enabled" | "disabled";
36
+
37
+ interface QuietState {
38
+ mode: { current: QuietMode };
39
+ activity: ActivityDisplay;
40
+ renderer: QuietRenderPatcher;
41
+ startedAt?: number;
42
+ }
43
+
44
+ function loadMode(): QuietMode {
45
+ try {
46
+ const config = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as {
47
+ enabled?: unknown;
48
+ };
49
+ return config.enabled === false ? "disabled" : "enabled";
50
+ } catch {
51
+ return "enabled";
52
+ }
53
+ }
54
+
55
+ function saveMode(mode: QuietMode): boolean {
56
+ try {
57
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
58
+ writeFileSync(
59
+ CONFIG_PATH,
60
+ `${JSON.stringify({ enabled: mode === "enabled" }, null, 2)}\n`,
61
+ "utf8",
62
+ );
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ function createState(): QuietState {
70
+ const mode = { current: loadMode() };
71
+ const isEnabled = () => mode.current === "enabled";
72
+ return {
73
+ mode,
74
+ activity: createActivityDisplay(isEnabled),
75
+ renderer: createQuietRenderPatcher(isEnabled),
76
+ };
77
+ }
78
+
79
+ function changeMode(
80
+ state: QuietState,
81
+ mode: QuietMode,
82
+ ctx: ExtensionContext,
83
+ ): void {
84
+ state.mode.current = mode;
85
+ state.renderer.refresh();
86
+ state.activity.refresh(ctx);
87
+
88
+ const saved = saveMode(mode);
89
+ ctx.ui.notify(`Quiet activity ${mode}.`, saved ? "info" : "warning");
90
+ if (!saved) ctx.ui.notify(`Could not save ${CONFIG_PATH}`, "warning");
91
+ }
92
+
93
+ function toggle(state: QuietState, ctx: ExtensionContext): void {
94
+ changeMode(
95
+ state,
96
+ state.mode.current === "enabled" ? "disabled" : "enabled",
97
+ ctx,
98
+ );
99
+ }
100
+
101
+ function handleCommand(
102
+ state: QuietState,
103
+ args: string,
104
+ ctx: ExtensionContext,
105
+ ): void {
106
+ switch (args.trim().toLowerCase() || "toggle") {
107
+ case "status":
108
+ ctx.ui.notify(
109
+ `Quiet activity is ${state.mode.current}. Toggle: ${SHORTCUT}`,
110
+ );
111
+ return;
112
+ case "on":
113
+ changeMode(state, "enabled", ctx);
114
+ return;
115
+ case "off":
116
+ changeMode(state, "disabled", ctx);
117
+ return;
118
+ case "toggle":
119
+ toggle(state, ctx);
120
+ return;
121
+ default:
122
+ ctx.ui.notify("Usage: /quiet-activity [on|off|toggle|status]", "warning");
123
+ }
124
+ }
125
+
126
+ function register(pi: ExtensionAPI, state: QuietState): void {
127
+ pi.registerShortcut(SHORTCUT, {
128
+ description: "Toggle quiet activity / normal agent output",
129
+ handler: (ctx: ExtensionContext) => toggle(state, ctx),
130
+ });
131
+ pi.registerCommand("quiet-activity", {
132
+ description:
133
+ "Control final-answer-only agent display: on, off, toggle, or status",
134
+ handler: (args: string, ctx: ExtensionCommandContext): Promise<void> => {
135
+ handleCommand(state, args, ctx);
136
+ return Promise.resolve();
137
+ },
138
+ });
139
+ pi.on("session_start", (_event: SessionStartEvent, ctx: ExtensionContext) => {
140
+ if (ctx.mode === "tui") state.renderer.install();
141
+ state.activity.reset(ctx);
142
+ });
143
+ pi.on(
144
+ "before_agent_start",
145
+ (event: BeforeAgentStartEvent, ctx: ExtensionContext) => {
146
+ if (ctx.mode !== "tui" || state.mode.current !== "enabled") return;
147
+ return {
148
+ systemPrompt: `${event.systemPrompt}\n\n${FINAL_RESPONSE_INSTRUCTION}`,
149
+ };
150
+ },
151
+ );
152
+ pi.on("agent_start", (_event: AgentStartEvent, ctx: ExtensionContext) => {
153
+ state.startedAt = Date.now();
154
+ state.activity.reset(ctx);
155
+ });
156
+ pi.on(
157
+ "tool_execution_start",
158
+ (event: ToolExecutionStartEvent, ctx: ExtensionContext) =>
159
+ state.activity.start(event, ctx),
160
+ );
161
+ pi.on(
162
+ "tool_execution_end",
163
+ (event: ToolExecutionEndEvent, ctx: ExtensionContext) =>
164
+ state.activity.end(event, ctx),
165
+ );
166
+ pi.on("agent_settled", (_event, ctx) => {
167
+ state.activity.clear();
168
+ if (state.startedAt === undefined) return;
169
+ const elapsed = formatElapsedTime(Date.now() - state.startedAt);
170
+ state.renderer.finish(ctx.ui.theme.fg("dim", `Worked for ${elapsed}`));
171
+ state.startedAt = undefined;
172
+ });
173
+ pi.on(
174
+ "session_shutdown",
175
+ (_event: SessionShutdownEvent, ctx: ExtensionContext) => {
176
+ state.activity.restore(ctx);
177
+ state.renderer.uninstall();
178
+ },
179
+ );
180
+ }
181
+
182
+ export default function quietActivityExtension(pi: ExtensionAPI): void {
183
+ register(pi, createState());
184
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "pi-quiet-activity",
3
+ "version": "1.3.1",
4
+ "description": "Final-answer-only interactive mode with live activity labels for Pi.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "keywords": [
10
+ "pi-package",
11
+ "pi-extension",
12
+ "quiet-mode"
13
+ ],
14
+ "author": "Kevin Vargas",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/kevinvargasl/pi-quiet-activity.git"
19
+ },
20
+ "homepage": "https://github.com/kevinvargasl/pi-quiet-activity#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/kevinvargasl/pi-quiet-activity/issues"
23
+ },
24
+ "files": [
25
+ "index.ts",
26
+ "activity",
27
+ "render",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "pi": {
32
+ "extensions": [
33
+ "./index.ts"
34
+ ],
35
+ "image": "https://raw.githubusercontent.com/kevinvargasl/pi-quiet-activity/main/assets/pi-quiet-activity-demo.gif"
36
+ },
37
+ "scripts": {
38
+ "test": "node --experimental-strip-types tests/run-smoke.mjs",
39
+ "typecheck": "tsc --noEmit",
40
+ "prepublishOnly": "npm test && npm run typecheck"
41
+ },
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-ai": "*",
44
+ "@earendil-works/pi-coding-agent": "*"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^26.2.0",
48
+ "typescript": "^7.0.2"
49
+ }
50
+ }
@@ -0,0 +1,208 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
3
+
4
+ type AssistantRender = typeof AssistantMessageComponent.prototype.render;
5
+ type AssistantUpdate = typeof AssistantMessageComponent.prototype.updateContent;
6
+ type EnabledCheck = () => boolean;
7
+
8
+ const ELAPSED_METADATA = Symbol.for("pi-quiet-activity.elapsed");
9
+
10
+ interface RenderState {
11
+ originalMessage: AssistantMessage;
12
+ renderedMessage: AssistantMessage;
13
+ isStreaming: boolean;
14
+ elapsed?: string;
15
+ }
16
+
17
+ interface RuntimeState {
18
+ hasToolCalls: boolean;
19
+ isStreaming: boolean;
20
+ lastMessage?: AssistantMessage;
21
+ [ELAPSED_METADATA]?: string;
22
+ }
23
+
24
+ type ElapsedMessage = AssistantMessage & {
25
+ [ELAPSED_METADATA]?: string;
26
+ };
27
+
28
+ interface RenderMethods {
29
+ render: AssistantRender;
30
+ update: AssistantUpdate;
31
+ }
32
+
33
+ interface PatchState {
34
+ components: Map<AssistantMessageComponent, RenderState>;
35
+ originals?: RenderMethods;
36
+ patches?: RenderMethods;
37
+ }
38
+
39
+ function getRuntimeState(component: AssistantMessageComponent): RuntimeState {
40
+ // SAFETY: Pi's AssistantMessageComponent owns these runtime fields; the symbol
41
+ // metadata is private to this extension and does not overlap Pi's properties.
42
+ return component as unknown as RuntimeState;
43
+ }
44
+
45
+ function getMessageElapsed(message?: AssistantMessage): string | undefined {
46
+ return (message as ElapsedMessage | undefined)?.[ELAPSED_METADATA];
47
+ }
48
+
49
+ function getElapsed(component: AssistantMessageComponent): string | undefined {
50
+ const runtime = getRuntimeState(component);
51
+ return runtime[ELAPSED_METADATA] ?? getMessageElapsed(runtime.lastMessage);
52
+ }
53
+
54
+ function setElapsed(
55
+ component: AssistantMessageComponent,
56
+ message: AssistantMessage,
57
+ elapsed: string,
58
+ ): void {
59
+ getRuntimeState(component)[ELAPSED_METADATA] = elapsed;
60
+ (message as ElapsedMessage)[ELAPSED_METADATA] = elapsed;
61
+ }
62
+
63
+ export interface AssistantRenderPatch {
64
+ install(): void;
65
+ refresh(): void;
66
+ finish(elapsed: string): void;
67
+ uninstall(): void;
68
+ }
69
+
70
+ function removeThinking(message: AssistantMessage): AssistantMessage {
71
+ if (!message.content.some((part) => part.type === "thinking")) return message;
72
+ return {
73
+ ...message,
74
+ content: message.content.filter((part) => part.type !== "thinking"),
75
+ };
76
+ }
77
+
78
+ function shouldHide(
79
+ state: PatchState,
80
+ isEnabled: EnabledCheck,
81
+ component: AssistantMessageComponent,
82
+ ): boolean {
83
+ if (!isEnabled()) return false;
84
+ const runtime = getRuntimeState(component);
85
+ const isStreaming =
86
+ state.components.get(component)?.isStreaming ?? runtime.isStreaming;
87
+ return isStreaming || runtime.hasToolCalls;
88
+ }
89
+
90
+ function createPatches(
91
+ state: PatchState,
92
+ isEnabled: EnabledCheck,
93
+ originals: RenderMethods,
94
+ ): RenderMethods {
95
+ return {
96
+ update: function updateContent(
97
+ this: AssistantMessageComponent,
98
+ ...args: Parameters<AssistantUpdate>
99
+ ): void {
100
+ const [message, requestedStreaming] = args;
101
+ const runtime = getRuntimeState(this);
102
+ const previous = state.components.get(this);
103
+ const originalMessage =
104
+ message === previous?.renderedMessage ? previous.originalMessage : message;
105
+ const isStreaming = requestedStreaming ?? runtime.isStreaming;
106
+ const renderedMessage = isEnabled()
107
+ ? removeThinking(originalMessage)
108
+ : originalMessage;
109
+
110
+ state.components.set(this, {
111
+ originalMessage,
112
+ renderedMessage,
113
+ isStreaming,
114
+ elapsed: previous?.elapsed ?? getElapsed(this),
115
+ });
116
+ originals.update.call(this, renderedMessage, isStreaming);
117
+ },
118
+ render: function render(
119
+ this: AssistantMessageComponent,
120
+ ...args: Parameters<AssistantRender>
121
+ ): string[] {
122
+ if (shouldHide(state, isEnabled, this)) return [];
123
+ const lines = originals.render.call(this, ...args);
124
+ const elapsed = state.components.get(this)?.elapsed ?? getElapsed(this);
125
+ return isEnabled() && elapsed ? [elapsed, ...lines] : lines;
126
+ },
127
+ };
128
+ }
129
+
130
+ function finish(state: PatchState, elapsed: string): void {
131
+ const entries = Array.from(state.components.entries());
132
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
133
+ const [component, renderState] = entries[index];
134
+ const runtime = getRuntimeState(component);
135
+ if (renderState.isStreaming || runtime.hasToolCalls) continue;
136
+ renderState.elapsed = elapsed;
137
+ setElapsed(component, renderState.originalMessage, elapsed);
138
+ state.originals?.update.call(
139
+ component,
140
+ renderState.renderedMessage,
141
+ renderState.isStreaming,
142
+ );
143
+ return;
144
+ }
145
+ }
146
+
147
+ function install(state: PatchState, isEnabled: EnabledCheck): void {
148
+ if (state.originals) return;
149
+
150
+ const prototype = AssistantMessageComponent.prototype;
151
+ const originals = {
152
+ render: prototype.render,
153
+ update: prototype.updateContent,
154
+ };
155
+ const patches = createPatches(state, isEnabled, originals);
156
+ state.originals = originals;
157
+ state.patches = patches;
158
+ prototype.updateContent = patches.update;
159
+ prototype.render = patches.render;
160
+ }
161
+
162
+ function refresh(state: PatchState, isEnabled: EnabledCheck): void {
163
+ const update = state.originals?.update;
164
+ if (!update) return;
165
+
166
+ for (const [component, renderState] of state.components) {
167
+ renderState.renderedMessage = isEnabled()
168
+ ? removeThinking(renderState.originalMessage)
169
+ : renderState.originalMessage;
170
+ update.call(component, renderState.renderedMessage, renderState.isStreaming);
171
+ }
172
+ }
173
+
174
+ function uninstall(state: PatchState): void {
175
+ const { originals, patches } = state;
176
+ if (!originals || !patches) return;
177
+
178
+ try {
179
+ for (const [component, renderState] of state.components) {
180
+ originals.update.call(
181
+ component,
182
+ renderState.originalMessage,
183
+ renderState.isStreaming,
184
+ );
185
+ }
186
+ } finally {
187
+ const prototype = AssistantMessageComponent.prototype;
188
+ if (prototype.updateContent === patches.update)
189
+ prototype.updateContent = originals.update;
190
+ if (prototype.render === patches.render) prototype.render = originals.render;
191
+
192
+ state.components.clear();
193
+ state.originals = undefined;
194
+ state.patches = undefined;
195
+ }
196
+ }
197
+
198
+ export function createAssistantRenderPatch(
199
+ isEnabled: EnabledCheck,
200
+ ): AssistantRenderPatch {
201
+ const state: PatchState = { components: new Map() };
202
+ return {
203
+ install: () => install(state, isEnabled),
204
+ refresh: () => refresh(state, isEnabled),
205
+ finish: (elapsed) => finish(state, elapsed),
206
+ uninstall: () => uninstall(state),
207
+ };
208
+ }
@@ -0,0 +1,29 @@
1
+ import { createAssistantRenderPatch } from "./assistant.ts";
2
+ import { createToolRenderPatch } from "./tool.ts";
3
+
4
+ export interface QuietRenderPatcher {
5
+ install(): void;
6
+ refresh(): void;
7
+ finish(elapsed: string): void;
8
+ uninstall(): void;
9
+ }
10
+
11
+ export function createQuietRenderPatcher(
12
+ isEnabled: () => boolean,
13
+ ): QuietRenderPatcher {
14
+ const assistant = createAssistantRenderPatch(isEnabled);
15
+ const tool = createToolRenderPatch(isEnabled);
16
+
17
+ return {
18
+ install() {
19
+ assistant.install();
20
+ tool.install();
21
+ },
22
+ refresh: () => assistant.refresh(),
23
+ finish: (elapsed) => assistant.finish(elapsed),
24
+ uninstall() {
25
+ assistant.uninstall();
26
+ tool.uninstall();
27
+ },
28
+ };
29
+ }
package/render/tool.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
2
+
3
+ type ToolRender = typeof ToolExecutionComponent.prototype.render;
4
+
5
+ export interface ToolRenderPatch {
6
+ install(): void;
7
+ uninstall(): void;
8
+ }
9
+
10
+ export function createToolRenderPatch(
11
+ isEnabled: () => boolean,
12
+ ): ToolRenderPatch {
13
+ let original: ToolRender | undefined;
14
+ let patch: ToolRender | undefined;
15
+
16
+ return {
17
+ install() {
18
+ if (original) return;
19
+ const prototype = ToolExecutionComponent.prototype;
20
+ original = prototype.render;
21
+ patch = function render(
22
+ this: ToolExecutionComponent,
23
+ ...args: Parameters<ToolRender>
24
+ ): string[] {
25
+ return isEnabled() ? [] : (original?.call(this, ...args) ?? []);
26
+ };
27
+ prototype.render = patch;
28
+ },
29
+ uninstall() {
30
+ if (original && patch && ToolExecutionComponent.prototype.render === patch) {
31
+ ToolExecutionComponent.prototype.render = original;
32
+ }
33
+ original = undefined;
34
+ patch = undefined;
35
+ },
36
+ };
37
+ }