@xiliumz/pi-minimal 1.0.0 → 1.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/README.md CHANGED
@@ -32,6 +32,16 @@ Restart pi or run `/reload`.
32
32
  | Spinner | Shown in the prompt while the agent runs |
33
33
  | Header / footer | Removed |
34
34
  | `/info` / `F8` | Model, thinking level, context %, tokens, cost, session file/id |
35
+ | Tool rows / `Ctrl+O` | Cycles **grouped** → **title** → **collapsed** → **full**. Starts grouped |
36
+
37
+ Tool views:
38
+
39
+ - **grouped** — back-to-back tool calls collapse into one `N tools` line; a lone call shows its name
40
+ - **title** — one line per call, tool name only (red when the call failed)
41
+ - **collapsed** — pi's default truncated preview
42
+ - **full** — pi's normal expanded output
43
+
44
+ Assistant text, thinking, and user messages end a group.
35
45
 
36
46
  Multiline input and slash-command autocomplete start at column 0 (under `$ `).
37
47
  Soft-wrapped continuations use the full terminal width — only the first visual line shares space with `$ `.
@@ -42,6 +52,10 @@ Soft-wrapped continuations use the full terminal width — only the first visual
42
52
  Pi resets extension UI before the old session context goes stale, then re-runs
43
53
  `session_start` so this layout comes back. Extension-side fix is not available.
44
54
 
55
+ `Ctrl+O` follows your `app.tools.expand` keybinding and shows the new view as
56
+ `Tool output: <view>`. `/export` renders tools in
57
+ the current view — switch to **full** first for complete HTML.
58
+
45
59
  ## Develop
46
60
 
47
61
  ```bash
@@ -58,7 +72,9 @@ Layout:
58
72
 
59
73
  ```
60
74
  extensions/index.ts # pi entry (registers hooks + commands)
61
- src/editor.ts # bash prompt CustomEditor
75
+ src/editor.ts # bash prompt CustomEditor (+ Ctrl+O hook)
76
+ src/tool-view.ts # grouped / title / full tool renderers
77
+ src/tool-runs.ts # tracks back-to-back tool calls
62
78
  src/info.ts # /info UI + token stats
63
79
  src/git.ts # branch + dirty
64
80
  src/format.ts # cwd/tokens helpers
@@ -6,6 +6,7 @@
6
6
  * $ |
7
7
  *
8
8
  * Header/footer chrome removed. Session stats via /info.
9
+ * Ctrl+O cycles tool rows: grouped (`N tools`) → title → collapsed → full.
9
10
  */
10
11
 
11
12
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -13,6 +14,8 @@ import type { TUI } from "@earendil-works/pi-tui";
13
14
  import { createBashPromptEditor } from "../src/editor.js";
14
15
  import { fetchGitState, type GitState } from "../src/git.js";
15
16
  import { showInfoUi } from "../src/info.js";
17
+ import { ToolRuns } from "../src/tool-runs.js";
18
+ import { createToolViewRenderer, nextToolView, type ToolView } from "../src/tool-view.js";
16
19
  import { EmptyComponent } from "../src/ui.js";
17
20
 
18
21
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -23,6 +26,28 @@ export default function bashLayoutExtension(pi: ExtensionAPI): void {
23
26
  let isWorking = false;
24
27
  let spinnerIndex = 0;
25
28
  let spinnerTimer: ReturnType<typeof setInterval> | undefined;
29
+ let activeCtx: ExtensionContext | undefined;
30
+
31
+ const toolState = { view: "grouped" as ToolView, runs: new ToolRuns() };
32
+ const toolRenderer = createToolViewRenderer(toolState);
33
+ pi.registerToolRenderer(toolRenderer.resolve);
34
+
35
+ // pi only redraws rows when the expanded flag changes, so flip it twice.
36
+ const repaintTools = (ctx: ExtensionContext) => {
37
+ const expanded = toolState.view === "full";
38
+ ctx.ui.setToolsExpanded(!expanded);
39
+ ctx.ui.setToolsExpanded(expanded);
40
+ };
41
+
42
+ const rebuildToolRuns = (ctx: ExtensionContext) => {
43
+ toolState.runs.clear();
44
+ toolRenderer.clear();
45
+ for (const entry of ctx.sessionManager.getBranch()) {
46
+ if (entry.type === "message") toolState.runs.add(entry.message);
47
+ else toolState.runs.break();
48
+ }
49
+ repaintTools(ctx);
50
+ };
26
51
 
27
52
  const stopSpinner = () => {
28
53
  if (spinnerTimer) {
@@ -49,9 +74,30 @@ export default function bashLayoutExtension(pi: ExtensionAPI): void {
49
74
  onMount(tui: TUI) {
50
75
  activeTui = tui;
51
76
  },
77
+ onToolsExpand() {
78
+ if (!activeCtx) return;
79
+ toolState.view = nextToolView(toolState.view);
80
+ repaintTools(activeCtx);
81
+ // Info notices reuse pi's status line, replacing its `collapsed/expanded` text.
82
+ activeCtx.ui.notify(`Tool output: ${toolState.view}`, "info");
83
+ },
52
84
  };
53
85
 
86
+ pi.on("message_end", (event) => {
87
+ const changed = toolState.runs.add(event.message);
88
+ if (toolState.view === "grouped") toolRenderer.redraw(changed);
89
+ });
90
+
91
+ pi.on("session_tree", (_event, ctx) => {
92
+ if (ctx.mode === "tui") rebuildToolRuns(ctx);
93
+ });
94
+
95
+ pi.on("session_compact", (_event, ctx) => {
96
+ if (ctx.mode === "tui") rebuildToolRuns(ctx);
97
+ });
98
+
54
99
  pi.on("agent_start", () => {
100
+ toolState.runs.break();
55
101
  isWorking = true;
56
102
  stopSpinner();
57
103
  spinnerTimer = setInterval(() => {
@@ -73,6 +119,7 @@ export default function bashLayoutExtension(pi: ExtensionAPI): void {
73
119
  pi.on("session_shutdown", () => {
74
120
  stopSpinner();
75
121
  activeTui = undefined;
122
+ activeCtx = undefined;
76
123
  });
77
124
 
78
125
  pi.on("session_start", (_event, ctx) => {
@@ -81,6 +128,8 @@ export default function bashLayoutExtension(pi: ExtensionAPI): void {
81
128
  ctx.ui.setHeader(() => new EmptyComponent());
82
129
  ctx.ui.setFooter(() => new EmptyComponent());
83
130
  ctx.ui.setWorkingVisible(false);
131
+ activeCtx = ctx;
132
+ rebuildToolRuns(ctx);
84
133
 
85
134
  void refreshGit(ctx.cwd);
86
135
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiliumz/pi-minimal",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Bash-style prompt layout for pi — ~/path (branch*): with /info",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,9 +42,9 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "devDependencies": {
45
- "@earendil-works/pi-ai": "^0.83.0",
46
- "@earendil-works/pi-coding-agent": "^0.83.0",
47
- "@earendil-works/pi-tui": "^0.83.0",
45
+ "@earendil-works/pi-ai": "^1.3.0",
46
+ "@earendil-works/pi-coding-agent": "^1.3.0",
47
+ "@earendil-works/pi-tui": "^1.3.0",
48
48
  "@types/node": "^26.1.2",
49
49
  "typescript": "^7.0.2"
50
50
  }
package/src/editor.ts CHANGED
@@ -21,6 +21,8 @@ export interface BashPromptState {
21
21
  spinnerFrame: string;
22
22
  /** Called when the editor mounts so the extension can request re-renders. */
23
23
  onMount: (tui: TUI) => void;
24
+ /** Replaces pi's `app.tools.expand` (Ctrl+O) toggle. Extensions cannot bind that key directly. */
25
+ onToolsExpand: () => void;
24
26
  }
25
27
 
26
28
  /** Subset of Editor internals used for dual-width soft-wrap. */
@@ -52,9 +54,12 @@ export function createBashPromptEditor(
52
54
  return class BashPromptEditor extends CustomEditor {
53
55
  /** Columns reserved on the first visual line for `$ `. */
54
56
  private promptInset = 0;
57
+ /** CustomEditor keeps its own copy private. */
58
+ private readonly keys: KeybindingsManager;
55
59
 
56
60
  constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) {
57
61
  super(tui, theme, keybindings, { paddingX: 0 });
62
+ this.keys = keybindings;
58
63
  // pi-tui's Editor declares `private layoutText`; TS forbids a subclass
59
64
  // member with that name (TS2415/TS2322), but JS ignores `private` at
60
65
  // runtime — so install the override as an instance property instead.
@@ -63,6 +68,14 @@ export function createBashPromptEditor(
63
68
  state.onMount(tui);
64
69
  }
65
70
 
71
+ override handleInput(data: string): void {
72
+ if (this.keys.matches(data, "app.tools.expand")) {
73
+ state.onToolsExpand();
74
+ return;
75
+ }
76
+ super.handleInput(data);
77
+ }
78
+
66
79
  /** Host may push settings padding — keep 0 so prompt math stays correct. */
67
80
  override setPaddingX(_paddingX: number): void {
68
81
  super.setPaddingX(0);
@@ -0,0 +1,66 @@
1
+ import type { MessageEndEvent } from "@earendil-works/pi-coding-agent";
2
+
3
+ type TranscriptMessage = MessageEndEvent["message"];
4
+
5
+ export interface ToolRunPosition {
6
+ /** First call of the run — the one that draws `N tools`. */
7
+ isHead: boolean;
8
+ /** Number of calls in the run so far. */
9
+ count: number;
10
+ }
11
+
12
+ /**
13
+ * Groups tool calls that appear back to back in the transcript. A run ends at
14
+ * any visible non-tool content: assistant text, thinking, user or custom
15
+ * messages. Tool results never end a run.
16
+ */
17
+ export class ToolRuns {
18
+ private current: string[] = [];
19
+ private readonly runById = new Map<string, string[]>();
20
+
21
+ clear(): void {
22
+ this.current = [];
23
+ this.runById.clear();
24
+ }
25
+
26
+ /** Feed one finished message. Returns tool call ids whose run changed. */
27
+ add(message: TranscriptMessage): string[] {
28
+ if (message.role === "toolResult") return [];
29
+ if (message.role !== "assistant") {
30
+ this.current = [];
31
+ return [];
32
+ }
33
+
34
+ const changed: string[] = [];
35
+ for (const block of message.content) {
36
+ if (block.type !== "toolCall") {
37
+ if (isVisible(block)) this.current = [];
38
+ continue;
39
+ }
40
+ if (this.runById.has(block.id)) continue;
41
+ const head = this.current[0];
42
+ if (head) changed.push(head);
43
+ this.current.push(block.id);
44
+ this.runById.set(block.id, this.current);
45
+ changed.push(block.id);
46
+ }
47
+ return changed;
48
+ }
49
+
50
+ /** Ends the open run, e.g. at a compaction or other non-message entry. */
51
+ break(): void {
52
+ this.current = [];
53
+ }
54
+
55
+ position(toolCallId: string): ToolRunPosition | undefined {
56
+ const run = this.runById.get(toolCallId);
57
+ if (!run) return undefined;
58
+ return { isHead: run[0] === toolCallId, count: run.length };
59
+ }
60
+ }
61
+
62
+ function isVisible(block: { type: string; text?: string; thinking?: string }): boolean {
63
+ if (block.type === "text") return Boolean(block.text?.trim());
64
+ if (block.type === "thinking") return Boolean(block.thinking?.trim());
65
+ return true;
66
+ }
@@ -0,0 +1,192 @@
1
+ import {
2
+ keyHint,
3
+ type AgentToolResult,
4
+ type Theme,
5
+ type ToolRendererResolver,
6
+ type ToolRenderers,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { Box, Text, type Component } from "@earendil-works/pi-tui";
9
+ import type { ToolRuns } from "./tool-runs.js";
10
+ import { EmptyComponent } from "./ui.js";
11
+
12
+ /** pi does not export `ToolRenderContext` from its entry point. */
13
+ type ToolRenderContext = Parameters<NonNullable<ToolRenderers["renderCall"]>>[2];
14
+
15
+ /**
16
+ * How tool rows are drawn; Ctrl+O cycles them in this order.
17
+ * - grouped: back-to-back calls collapse into one `N tools` line
18
+ * - title: one line per call with just the tool name
19
+ * - collapsed: pi's default truncated preview
20
+ * - full: pi's normal expanded output
21
+ */
22
+ export type ToolView = "grouped" | "title" | "collapsed" | "full";
23
+
24
+ const NEXT_VIEW: Record<ToolView, ToolView> = {
25
+ grouped: "title",
26
+ title: "collapsed",
27
+ collapsed: "full",
28
+ full: "grouped",
29
+ };
30
+
31
+ export function nextToolView(view: ToolView): ToolView {
32
+ return NEXT_VIEW[view];
33
+ }
34
+
35
+ /** Views drawn by pi-minimal itself rather than the tool's own renderers. */
36
+ function isSummaryView(view: ToolView): boolean {
37
+ return view === "grouped" || view === "title";
38
+ }
39
+
40
+ /** Same limits pi uses for tools without renderers. */
41
+ const PREVIEW_LINES = 10;
42
+ const PREVIEW_ARGS_CHARS = 100;
43
+
44
+ export interface ToolViewState {
45
+ readonly view: ToolView;
46
+ readonly runs: ToolRuns;
47
+ }
48
+
49
+ /** Per-row bookkeeping: the row's redraw hook, plus the base renderer's components and our frame for collapsed/full view. */
50
+ interface RowCache {
51
+ redraw?: () => void;
52
+ call?: Component;
53
+ result?: Component;
54
+ frame?: Box;
55
+ }
56
+
57
+ export interface ToolViewRenderer {
58
+ resolve: ToolRendererResolver;
59
+ /** Redraw rows already on screen, e.g. after their run grew. */
60
+ redraw(toolCallIds: string[]): void;
61
+ /** Drop per-row caches, e.g. when the transcript is rebuilt. */
62
+ clear(): void;
63
+ }
64
+
65
+ /**
66
+ * Renderer resolver that lets pi-minimal decide what every tool row shows,
67
+ * including MCP and other extensions' tools.
68
+ */
69
+ export function createToolViewRenderer(state: ToolViewState): ToolViewRenderer {
70
+ const rows = new Map<string, RowCache>();
71
+ return {
72
+ resolve: (toolName, next) => wrapRenderers(toolName, next(), state, rows),
73
+ redraw: (toolCallIds) => {
74
+ for (const id of toolCallIds) rows.get(id)?.redraw?.();
75
+ },
76
+ clear: () => rows.clear(),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Rows always use `renderShell: "self"` — pi fixes the shell when a row is
82
+ * created, and only "self" lets a hidden row take zero lines. Full view
83
+ * redraws pi's default box itself.
84
+ */
85
+ function wrapRenderers(
86
+ toolName: string,
87
+ base: ToolRenderers | undefined,
88
+ state: ToolViewState,
89
+ rows: Map<string, RowCache>,
90
+ ): ToolRenderers {
91
+ const row = (id: string) => {
92
+ let cache = rows.get(id);
93
+ if (!cache) rows.set(id, (cache = {}));
94
+ return cache;
95
+ };
96
+
97
+ return {
98
+ renderShell: "self",
99
+
100
+ renderCall(args, theme, context) {
101
+ const cache = row(context.toolCallId);
102
+ cache.redraw = context.invalidate;
103
+ if (isSummaryView(state.view)) return summaryLine(toolName, theme, context, state);
104
+
105
+ const baseContext = { ...context, lastComponent: cache.call };
106
+ cache.call = base?.renderCall
107
+ ? base.renderCall(args, theme, baseContext)
108
+ : new Text(formatArgs(toolName, args, theme, context.expanded), 0, 0);
109
+ if (base?.renderShell === "self") return cache.call;
110
+
111
+ cache.frame = new Box(context.outputPad, 1, rowBackground(theme, context));
112
+ cache.frame.addChild(cache.call);
113
+ return cache.frame;
114
+ },
115
+
116
+ renderResult(result, options, theme, context) {
117
+ if (isSummaryView(state.view)) return new EmptyComponent();
118
+
119
+ const cache = row(context.toolCallId);
120
+ const baseContext = { ...context, lastComponent: cache.result };
121
+ cache.result = base?.renderResult
122
+ ? base.renderResult(result, options, theme, baseContext)
123
+ : fallbackResult(result, theme, options.expanded);
124
+ if (base?.renderShell === "self" || !cache.frame) return cache.result;
125
+
126
+ // Default-shell tools draw call + result inside one box, like pi does.
127
+ cache.frame.addChild(cache.result);
128
+ return new EmptyComponent();
129
+ },
130
+ };
131
+ }
132
+
133
+ function summaryLine(
134
+ toolName: string,
135
+ theme: Theme,
136
+ context: ToolRenderContext,
137
+ state: ToolViewState,
138
+ ): Component {
139
+ const background = rowBackground(theme, context);
140
+ const position = state.view === "grouped" ? state.runs.position(context.toolCallId) : undefined;
141
+ if (position && position.count > 1) {
142
+ if (!position.isHead) return new EmptyComponent();
143
+ const label = theme.fg("toolTitle", theme.bold(`${position.count} tools`));
144
+ return new Text(label, context.outputPad, 1, background);
145
+ }
146
+ const color = context.isError ? "error" : "toolTitle";
147
+ return new Text(theme.fg(color, theme.bold(toolName)), context.outputPad, 1, background);
148
+ }
149
+
150
+ function rowBackground(theme: Theme, context: ToolRenderContext): (text: string) => string {
151
+ if (context.isPartial) return (text) => theme.bg("toolPendingBg", text);
152
+ if (context.isError) return (text) => theme.bg("toolErrorBg", text);
153
+ return (text) => theme.bg("toolSuccessBg", text);
154
+ }
155
+
156
+ /**
157
+ * Tools without their own renderer: name plus one `key: value` line per
158
+ * argument, or a one-line `key=value` preview when collapsed.
159
+ */
160
+ function formatArgs(toolName: string, args: unknown, theme: Theme, expanded: boolean): string {
161
+ const header = theme.fg("toolTitle", theme.bold(toolName));
162
+ if (!args || typeof args !== "object") return header;
163
+ if (!expanded) {
164
+ const pairs = Object.entries(args)
165
+ .map(([key, value]) => `${key}=${JSON.stringify(value) ?? String(value)}`)
166
+ .join(" ");
167
+ if (!pairs) return header;
168
+ const preview = pairs.length > PREVIEW_ARGS_CHARS ? `${pairs.slice(0, PREVIEW_ARGS_CHARS - 3)}...` : pairs;
169
+ return `${header} ${theme.fg("muted", preview)}`;
170
+ }
171
+ const lines = Object.entries(args).map(([key, value]) => {
172
+ const text = typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value));
173
+ return ` ${key}: ${text.replace(/\r/g, "").split("\n").join("\n ")}`;
174
+ });
175
+ return lines.length > 0 ? `${header}\n${theme.fg("muted", lines.join("\n"))}` : header;
176
+ }
177
+
178
+ function fallbackResult(result: AgentToolResult<unknown>, theme: Theme, expanded: boolean): Component {
179
+ const output = result.content
180
+ .flatMap((block) => (block.type === "text" ? [block.text] : []))
181
+ .join("\n")
182
+ .trim();
183
+ if (!output) return new EmptyComponent();
184
+ const lines = output.split("\n");
185
+ const shown = expanded ? lines : lines.slice(0, PREVIEW_LINES);
186
+ let text = shown.map((line) => theme.fg("toolOutput", line)).join("\n");
187
+ const remaining = lines.length - shown.length;
188
+ if (remaining > 0) {
189
+ text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
190
+ }
191
+ return new Text(text, 0, 0);
192
+ }