pi-minimal 0.1.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cwd.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import path from "node:path";
3
- import { CHROME_MARGIN } from "./chrome.js";
4
- function formatCwd(cwd) {
3
+ import * as chrome from "./chrome.js";
4
+ import { formatGitState } from "./git-state.js";
5
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
6
+ const margin = () => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1));
7
+ export function formatCwd(cwd) {
5
8
  const home = process.env.HOME || process.env.USERPROFILE || "";
6
9
  if (!home)
7
10
  return cwd;
@@ -12,7 +15,7 @@ function formatCwd(cwd) {
12
15
  }
13
16
  return cwd;
14
17
  }
15
- function formatTokens(n) {
18
+ export function formatTokens(n) {
16
19
  if (n < 1000)
17
20
  return String(n);
18
21
  if (n < 10000)
@@ -23,7 +26,7 @@ function formatTokens(n) {
23
26
  return `${(n / 1e6).toFixed(1)}M`;
24
27
  return `${Math.round(n / 1e6)}M`;
25
28
  }
26
- function modelIcon(id) {
29
+ export function modelIcon(id) {
27
30
  const s = id.toLowerCase();
28
31
  if (s.includes("grok"))
29
32
  return "✦";
@@ -38,7 +41,7 @@ function modelIcon(id) {
38
41
  return "◈";
39
42
  return "◆";
40
43
  }
41
- function shortModel(id) {
44
+ export function shortModel(id) {
42
45
  return (id.split("/").pop() || id).replace(/^claude-/, "");
43
46
  }
44
47
  function thinkingColor(level) {
@@ -48,56 +51,95 @@ function thinkingColor(level) {
48
51
  return "accent";
49
52
  return "dim";
50
53
  }
51
- function meter(percent, fill, empty) {
52
- const n = 8;
53
- const filled = Math.max(0, Math.min(n, Math.round((percent / 100) * n)));
54
- return fill.repeat(filled) + empty.repeat(n - filled);
54
+ /** Context pressure band used for the meter and percentage colour. */
55
+ export function contextBand(pct) {
56
+ if (pct >= 90)
57
+ return "error";
58
+ if (pct >= 70)
59
+ return "warning";
60
+ return "accent";
55
61
  }
56
- function splitRow(left, right, width) {
57
- let L = left;
58
- let R = right;
59
- while (visibleWidth(L) + 1 + visibleWidth(R) > width && visibleWidth(R) > 0) {
60
- R = truncateToWidth(R, Math.max(0, visibleWidth(R) - 1), "");
62
+ export function meter(percent, fill, empty, cells = 8) {
63
+ const filled = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)));
64
+ return fill.repeat(filled) + empty.repeat(cells - filled);
65
+ }
66
+ /**
67
+ * Fit `left` and `right` into `width`. Right-hand segments are dropped from the end first (they are
68
+ * the least important), then the left is truncated.
69
+ */
70
+ export function splitRow(left, rightSegments, width, sep = " ") {
71
+ const segments = [...rightSegments.filter((s) => visibleWidth(s) > 0)];
72
+ let right = segments.join(sep);
73
+ while (segments.length > 0 && visibleWidth(left) + 2 + visibleWidth(right) > width) {
74
+ segments.pop();
75
+ right = segments.join(sep);
61
76
  }
62
- while (visibleWidth(L) + 1 + visibleWidth(R) > width && visibleWidth(L) > 0) {
63
- L = truncateToWidth(L, Math.max(0, visibleWidth(L) - 1), "");
77
+ let L = left;
78
+ if (visibleWidth(L) + (right ? 2 : 0) + visibleWidth(right) > width) {
79
+ L = truncateToWidth(L, Math.max(0, width - (right ? 2 : 0) - visibleWidth(right)), "");
64
80
  }
65
- const gap = Math.max(1, width - visibleWidth(L) - visibleWidth(R));
66
- return L + " ".repeat(gap) + R;
81
+ const gap = right ? Math.max(2, width - visibleWidth(L) - visibleWidth(right)) : 0;
82
+ return L + " ".repeat(gap) + right;
83
+ }
84
+ function siblingColor(status) {
85
+ if (status === "idle" || status === "done")
86
+ return "success";
87
+ if (status === "working")
88
+ return "accent";
89
+ if (status === "blocked")
90
+ return "error";
91
+ return "muted";
67
92
  }
68
- export function installCwd(ctx) {
69
- ctx.ui.setWidget("tw-cwd", (_tui, theme) => ({
70
- invalidate() { },
71
- render(width) {
72
- const inner = Math.max(8, width - CHROME_MARGIN * 2);
73
- const id = ctx.model?.id || "no-model";
74
- const icon = theme.fg("accent", modelIcon(id));
75
- const name = theme.fg("text", shortModel(id));
76
- const usage = ctx.getContextUsage?.();
77
- const window = usage?.contextWindow || ctx.model?.contextWindow;
78
- const used = usage?.tokens;
79
- let tokenStr = theme.fg("dim", "?/?");
80
- let bar = theme.fg("dim", "░░░░░░░░");
81
- let pctStr = theme.fg("dim", "?%");
82
- if (typeof used === "number" && window) {
83
- const pct = Math.max(0, Math.min(100, Math.round((used / window) * 100)));
84
- tokenStr = theme.fg("dim", `${formatTokens(used)}/${formatTokens(window)}`);
85
- const hot = pct >= 85;
86
- bar = meter(pct, theme.fg(hot ? "warning" : "accent", "█"), theme.fg("dim", "░"));
87
- pctStr = theme.fg(hot ? "warning" : "muted", `${pct}%`);
88
- }
89
- else if (used === null && window) {
90
- tokenStr = theme.fg("dim", `?/${formatTokens(window)}`);
91
- }
92
- const thinking = ctx.thinkingLevel || "";
93
- const think = thinking ? theme.fg(thinkingColor(thinking), thinking) : "";
94
- const midot = theme.fg("dim", " · ");
95
- const left = [`${icon} ${name}`, tokenStr, `${bar} ${pctStr}`, think]
96
- .filter(Boolean)
97
- .join(midot);
98
- const right = ctx.cwd ? theme.fg("accent", formatCwd(ctx.cwd)) : "";
99
- const row = splitRow(left, right, inner);
100
- return [`${" ".repeat(CHROME_MARGIN)}${row}`];
101
- },
102
- }), { placement: "aboveEditor" });
93
+ export function installCwd(ctx, sources) {
94
+ ctx.ui.setWidget("tw-cwd", (tui, theme) => {
95
+ const unsub = sources.onGitChange(() => tui.requestRender());
96
+ return {
97
+ dispose: unsub,
98
+ invalidate() { },
99
+ render(width) {
100
+ const inner = Math.max(8, width - margin() * 2);
101
+ const id = ctx.model?.id || "no-model";
102
+ const icon = theme.fg("accent", modelIcon(id));
103
+ const name = theme.fg("text", shortModel(id));
104
+ const thinking = ctx.thinkingLevel || "";
105
+ const think = thinking ? theme.fg(thinkingColor(thinking), thinking) : "";
106
+ const usage = ctx.getContextUsage?.();
107
+ const window = usage?.contextWindow || ctx.model?.contextWindow;
108
+ const used = usage?.tokens;
109
+ let bar = theme.fg("dim", "░░░░░░░░");
110
+ let pctStr = theme.fg("dim", "?%");
111
+ let tokenStr = theme.fg("dim", "?/?");
112
+ if (typeof used === "number" && window) {
113
+ const pct = Math.max(0, Math.min(100, Math.round((used / window) * 100)));
114
+ const band = contextBand(pct);
115
+ bar = meter(pct, theme.fg(band, "▓"), theme.fg("dim", "░"));
116
+ pctStr = theme.fg(band === "accent" ? "muted" : band, `${pct}%`);
117
+ tokenStr = theme.fg("dim", `${formatTokens(used)}/${formatTokens(window)}`);
118
+ }
119
+ else if (used === null && window) {
120
+ tokenStr = theme.fg("dim", `?/${formatTokens(window)}`);
121
+ }
122
+ const midot = theme.fg("dim", " · ");
123
+ const model = [`${icon} ${name}`, think].filter(Boolean).join(midot);
124
+ const context = `${bar} ${pctStr} ${tokenStr}`;
125
+ const left = `${model} ${context}`;
126
+ const right = [];
127
+ if (ctx.cwd)
128
+ right.push(theme.fg("muted", formatCwd(ctx.cwd)));
129
+ const git = formatGitState(sources.gitState());
130
+ if (git.branch) {
131
+ const dirty = git.dirty ? ` ${theme.fg("warning", git.dirty)}` : "";
132
+ right.push(`${theme.fg("dim", "⎇")} ${theme.fg("muted", git.branch)}${dirty}`);
133
+ }
134
+ const siblings = sources.siblings();
135
+ if (siblings && siblings.length > 0) {
136
+ right.push(siblings
137
+ .map((s) => `${theme.fg("text", s.name)} ${theme.fg(siblingColor(s.status), s.status)}`)
138
+ .join(theme.fg("dim", " · ")));
139
+ }
140
+ const row = splitRow(left, right, inner);
141
+ return [`${" ".repeat(margin())}${row}`];
142
+ },
143
+ };
144
+ }, { placement: "aboveEditor" });
103
145
  }
package/dist/footer.js CHANGED
@@ -1,43 +1,44 @@
1
1
  import { keyHint } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
- import { CHROME_MARGIN } from "./chrome.js";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import * as chrome from "./chrome.js";
4
4
  import { formatTrackerLines } from "./tracker.js";
5
- function statusColor(status) {
6
- if (status === "idle" || status === "done")
7
- return "success";
8
- if (status === "working")
9
- return "accent";
10
- if (status === "blocked")
11
- return "error";
12
- return "muted";
5
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
6
+ const margin = () => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1));
7
+ /**
8
+ * Key hints are shown only when nothing else needs the line: idle agent, empty editor. Otherwise
9
+ * the footer collapses to zero lines (tracker lines excepted).
10
+ */
11
+ export function footerHints(sources, hints) {
12
+ if (sources.busy())
13
+ return [];
14
+ if (sources.editorText().trim().length > 0)
15
+ return [];
16
+ const pct = sources.contextPct();
17
+ const out = [...hints];
18
+ if (pct !== undefined && pct >= 70)
19
+ out.push("/compact");
20
+ return out;
13
21
  }
14
- export function installFooter(ctx, getSiblings) {
22
+ export function installFooter(ctx, sources) {
15
23
  ctx.ui.setFooter((tui, theme, footerData) => {
16
24
  const unsub = footerData.onBranchChange(() => tui.requestRender());
17
25
  return {
18
26
  dispose: unsub,
19
27
  invalidate() { },
20
28
  render(width) {
21
- const send = keyHint("tui.input.submit", "send");
22
- const think = keyHint("app.thinking.cycle", "thinking");
23
- const left = [send, think].filter(Boolean).join(" · ");
24
- const siblings = getSiblings();
25
- let right = "";
26
- if (siblings && siblings.length > 0) {
27
- right = siblings
28
- .map((s) => {
29
- const name = theme.fg("text", s.name);
30
- const st = theme.fg(statusColor(s.status), s.status);
31
- return `${name} ${st}`;
32
- })
33
- .join(theme.fg("dim", " · "));
29
+ const inner = Math.max(8, width - margin() * 2);
30
+ const out = [];
31
+ const hints = footerHints(sources, [
32
+ keyHint("tui.input.submit", "send"),
33
+ keyHint("app.thinking.cycle", "thinking"),
34
+ keyHint("app.tools.expand", "expand tools"),
35
+ ].filter(Boolean));
36
+ if (hints.length > 0) {
37
+ const line = theme.fg("dim", hints.join(" · "));
38
+ out.push(`${" ".repeat(margin())}${truncateToWidth(line, inner, "")}`);
34
39
  }
35
- const inner = Math.max(8, width - CHROME_MARGIN * 2);
36
- const pad = Math.max(1, inner - visibleWidth(left) - visibleWidth(right));
37
- const line = truncateToWidth(left + " ".repeat(pad) + right, inner, "");
38
- const out = [`${" ".repeat(CHROME_MARGIN)}${line}`];
39
40
  for (const extra of formatTrackerLines(ctx, theme)) {
40
- out.push(`${" ".repeat(CHROME_MARGIN)}${truncateToWidth(extra, inner, "")}`);
41
+ out.push(`${" ".repeat(margin())}${truncateToWidth(extra, inner, "")}`);
41
42
  }
42
43
  return out;
43
44
  },
@@ -0,0 +1,63 @@
1
+ export function parseBranch(stdout) {
2
+ const line = stdout.trim().split("\n")[0]?.trim();
3
+ if (!line || line === "HEAD")
4
+ return undefined;
5
+ return line;
6
+ }
7
+ export function parsePorcelain(stdout) {
8
+ return stdout.split("\n").filter((line) => line.trim().length > 0).length;
9
+ }
10
+ export function formatGitState(state) {
11
+ if (!state?.branch)
12
+ return { branch: "", dirty: "" };
13
+ const dirty = state.dirty && state.dirty > 0 ? `±${state.dirty}` : "";
14
+ return { branch: state.branch, dirty };
15
+ }
16
+ /**
17
+ * Polls git for the current branch and dirty count. Call `refresh()` after events that may have
18
+ * changed the tree (tool results, turn end); `get()` is always synchronous.
19
+ */
20
+ export function createGitState(pi, cwd, minIntervalMs = 5000) {
21
+ let state;
22
+ let lastRun = 0;
23
+ let inFlight;
24
+ const listeners = new Set();
25
+ const refresh = (force = false) => {
26
+ const now = Date.now();
27
+ if (inFlight)
28
+ return inFlight;
29
+ if (!force && now - lastRun < minIntervalMs)
30
+ return Promise.resolve();
31
+ lastRun = now;
32
+ inFlight = (async () => {
33
+ try {
34
+ const branchRun = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: cwd(), timeout: 2000 });
35
+ if (branchRun.code !== 0) {
36
+ state = undefined;
37
+ return;
38
+ }
39
+ const branch = parseBranch(branchRun.stdout);
40
+ const statusRun = await pi.exec("git", ["status", "--porcelain"], { cwd: cwd(), timeout: 3000 });
41
+ const dirty = statusRun.code === 0 ? parsePorcelain(statusRun.stdout) : undefined;
42
+ state = { branch, dirty };
43
+ }
44
+ catch {
45
+ state = undefined;
46
+ }
47
+ finally {
48
+ inFlight = undefined;
49
+ for (const listener of listeners)
50
+ listener();
51
+ }
52
+ })();
53
+ return inFlight;
54
+ };
55
+ return {
56
+ get: () => state,
57
+ refresh,
58
+ onChange(listener) {
59
+ listeners.add(listener);
60
+ return () => listeners.delete(listener);
61
+ },
62
+ };
63
+ }
package/dist/index.js CHANGED
@@ -5,11 +5,21 @@ import { installFooter } from "./footer.js";
5
5
  import { installCwd } from "./cwd.js";
6
6
  import { listSiblings, parseAgentList, parseTabList } from "./herdr.js";
7
7
  import { hideGauntletWidgets } from "./tracker.js";
8
- import { stopLiveWatch, wrapBuiltinTools } from "./tools.js";
8
+ import { installToolRenderers, stopLiveWatch } from "./tools.js";
9
9
  import { applyReclaim, reclaimPolicy } from "./reclaim.js";
10
10
  import { installWorkingStatus } from "./working-status.js";
11
+ import { installTurnRule } from "./turn-rule.js";
12
+ import { installTranscript } from "./transcript.js";
13
+ import { createGitState } from "./git-state.js";
14
+ import { installCodeBox } from "./code-box.js";
15
+ import { setChromeMargin } from "./chrome.js";
16
+ import { SettingsManager } from "@earendil-works/pi-coding-agent";
11
17
  export default function piMinimalExtension(pi) {
12
18
  const stopWorkingStatus = installWorkingStatus(pi);
19
+ const stopTurnRule = installTurnRule(pi);
20
+ installToolRenderers(pi);
21
+ installTranscript(pi);
22
+ installCodeBox();
13
23
  let fastTimer;
14
24
  let slowTimer;
15
25
  let stealTimer;
@@ -17,6 +27,9 @@ export default function piMinimalExtension(pi) {
17
27
  let siblings;
18
28
  let generation = 0;
19
29
  let hideWidgets = true;
30
+ let busy = false;
31
+ let currentCwd = process.cwd();
32
+ const gitState = createGitState(pi, () => currentCwd);
20
33
  const stopTimers = () => {
21
34
  if (fastTimer)
22
35
  clearInterval(fastTimer);
@@ -103,16 +116,37 @@ export default function piMinimalExtension(pi) {
103
116
  },
104
117
  chrome: () => {
105
118
  installHeader(ctx);
106
- installFooter(ctx, () => siblings);
119
+ installChromeFooter(ctx);
107
120
  },
108
121
  });
109
122
  };
123
+ const contextPct = (ctx) => {
124
+ const usage = ctx.getContextUsage?.();
125
+ const window = usage?.contextWindow || ctx.model?.contextWindow;
126
+ if (typeof usage?.tokens !== "number" || !window)
127
+ return undefined;
128
+ return Math.max(0, Math.min(100, Math.round((usage.tokens / window) * 100)));
129
+ };
130
+ const installChromeFooter = (ctx) => {
131
+ installFooter(ctx, {
132
+ busy: () => busy,
133
+ editorText: () => (typeof ctx.ui.getEditorText === "function" ? ctx.ui.getEditorText() : ""),
134
+ contextPct: () => contextPct(ctx),
135
+ });
136
+ };
137
+ const installStatusLine = (ctx) => {
138
+ installCwd(ctx, {
139
+ gitState: gitState.get,
140
+ siblings: () => siblings,
141
+ onGitChange: gitState.onChange,
142
+ });
143
+ };
110
144
  const pinChrome = (ctx, reason) => {
111
145
  hideForeignChrome(ctx, reason);
112
146
  if (ctx.mode !== "tui" || !reclaimPolicy(reason).reinstallChrome)
113
147
  return;
114
148
  ctx.ui.setWidget("tw-cwd", undefined);
115
- installCwd(ctx);
149
+ installStatusLine(ctx);
116
150
  };
117
151
  pi.on("session_start", async (_event, ctx) => {
118
152
  if (ctx.mode !== "tui")
@@ -123,10 +157,18 @@ export default function piMinimalExtension(pi) {
123
157
  projectTrusted: ctx.isProjectTrusted(),
124
158
  });
125
159
  hideWidgets = config.hideGauntletWidgets;
160
+ currentCwd = ctx.cwd;
161
+ try {
162
+ const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
163
+ setChromeMargin(settings.getOutputPad());
164
+ }
165
+ catch {
166
+ // Keep the default margin when settings are unavailable.
167
+ }
126
168
  installHeader(ctx);
127
- installFooter(ctx, () => siblings);
128
- installCwd(ctx);
129
- wrapBuiltinTools(pi, ctx);
169
+ installChromeFooter(ctx);
170
+ installStatusLine(ctx);
171
+ void gitState.refresh(true);
130
172
  pinChrome(ctx, "session_start");
131
173
  ctx.ui.setHiddenThinkingLabel("◆ Thought");
132
174
  ctx.ui.setWorkingIndicator();
@@ -139,15 +181,26 @@ export default function piMinimalExtension(pi) {
139
181
  await probe(ctx);
140
182
  }
141
183
  });
184
+ pi.on("agent_start", () => {
185
+ busy = true;
186
+ });
187
+ pi.on("agent_end", () => {
188
+ busy = false;
189
+ void gitState.refresh();
190
+ });
142
191
  pi.on("turn_end", (_event, ctx) => {
143
192
  pinChrome(ctx, "turn_end");
193
+ void gitState.refresh();
144
194
  });
145
195
  pi.on("tool_execution_end", (_event, ctx) => {
146
196
  pinChrome(ctx, "tool_execution_end");
197
+ void gitState.refresh();
147
198
  });
148
199
  pi.on("session_shutdown", async () => {
200
+ busy = false;
149
201
  stopTimers();
150
202
  stopWorkingStatus();
203
+ stopTurnRule();
151
204
  stopLiveWatch();
152
205
  generation += 1;
153
206
  siblings = undefined;
package/dist/tool-rows.js CHANGED
@@ -1,7 +1,7 @@
1
- let activityGroup;
2
- export function resetActivityGroup() {
3
- activityGroup = undefined;
4
- }
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ /** Width of the verb column so items align across rows. */
3
+ export const VERB_WIDTH = 5;
4
+ const BASH_STATUS = /^Command (?:exited with code (\d+)|terminated without an exit code)$/;
5
5
  export function shortName(path) {
6
6
  const parts = path.split("/").filter(Boolean);
7
7
  if (parts.length <= 1)
@@ -22,13 +22,144 @@ export function toolItem(kind, args) {
22
22
  "";
23
23
  return path ? shortName(path) : kind;
24
24
  }
25
- export function nextGroupedRow(verb, item, id) {
26
- if (activityGroup && activityGroup.verb === verb) {
27
- activityGroup.seen.add(id);
28
- if (id === activityGroup.leadId)
29
- return { header: verb, item };
30
- return { header: undefined, item };
25
+ export function resultText(result) {
26
+ if (!result?.content)
27
+ return "";
28
+ return result.content
29
+ .filter((part) => part.type === "text" && typeof part.text === "string")
30
+ .map((part) => part.text)
31
+ .join("\n");
32
+ }
33
+ export function rowGlyph(status, frame) {
34
+ if (status === "running")
35
+ return frame === 1 ? "◓" : "◐";
36
+ return status === "error" ? "✗" : "✓";
37
+ }
38
+ export function formatDuration(ms) {
39
+ if (ms === undefined || !Number.isFinite(ms))
40
+ return "";
41
+ if (ms < 1000)
42
+ return `${Math.round(ms / 100) / 10}s`;
43
+ if (ms < 60_000)
44
+ return `${Math.round(ms / 100) / 10}s`;
45
+ const minutes = Math.floor(ms / 60_000);
46
+ const seconds = Math.round((ms % 60_000) / 1000);
47
+ return `${minutes}m ${seconds}s`;
48
+ }
49
+ function plural(n, word, pluralWord = `${word}s`) {
50
+ return `${n} ${n === 1 ? word : pluralWord}`;
51
+ }
52
+ function countLines(text) {
53
+ if (!text)
54
+ return 0;
55
+ return text.split("\n").filter((line) => line.length > 0).length;
56
+ }
57
+ /** Strip pi's bash status trailer and truncation note from output text. */
58
+ export function bashBody(text) {
59
+ const lines = text.split("\n");
60
+ let exitCode;
61
+ while (lines.length > 0) {
62
+ const last = lines[lines.length - 1].trim();
63
+ const match = BASH_STATUS.exec(last);
64
+ if (match) {
65
+ exitCode = match[1] !== undefined ? Number(match[1]) : undefined;
66
+ lines.pop();
67
+ continue;
68
+ }
69
+ if (last.startsWith("[Showing ") && last.endsWith("]")) {
70
+ lines.pop();
71
+ continue;
72
+ }
73
+ if (last === "") {
74
+ lines.pop();
75
+ continue;
76
+ }
77
+ break;
78
+ }
79
+ return { body: lines.join("\n"), exitCode };
80
+ }
81
+ export function diffStats(diff) {
82
+ let added = 0;
83
+ let removed = 0;
84
+ for (const line of diff.split("\n")) {
85
+ if (line.startsWith("+++") || line.startsWith("---"))
86
+ continue;
87
+ if (line.startsWith("+"))
88
+ added += 1;
89
+ else if (line.startsWith("-"))
90
+ removed += 1;
91
+ }
92
+ return { added, removed };
93
+ }
94
+ export function formatOutcome(kind, args, result, isError, durationMs) {
95
+ const text = resultText(result);
96
+ const dur = formatDuration(durationMs);
97
+ const join = (...parts) => parts.filter(Boolean).join(" · ");
98
+ if (kind === "bash") {
99
+ const { body, exitCode } = bashBody(text);
100
+ if (isError || (exitCode !== undefined && exitCode !== 0)) {
101
+ const code = exitCode !== undefined ? `exit ${exitCode}` : "failed";
102
+ return { text: join(code, dur), errorLine: firstLine(body) };
103
+ }
104
+ const lines = countLines(body);
105
+ const summary = lines === 0 || body === "(no output)" ? "ok" : plural(lines, "line");
106
+ return { text: join(summary, dur) };
107
+ }
108
+ if (isError)
109
+ return { text: join("failed", dur), errorLine: firstLine(text) };
110
+ if (kind === "edit") {
111
+ const details = result?.details;
112
+ const diff = typeof details?.diff === "string" ? diffStats(details.diff) : undefined;
113
+ return diff ? { text: dur, diff } : { text: join("edited", dur) };
114
+ }
115
+ if (kind === "write") {
116
+ const bytes = typeof args?.content === "string" ? Buffer.byteLength(args.content, "utf8") : undefined;
117
+ return { text: join(bytes === undefined ? "written" : formatBytes(bytes), dur) };
118
+ }
119
+ if (kind === "read") {
120
+ return { text: join(plural(countLines(text), "line"), dur) };
121
+ }
122
+ if (kind === "grep") {
123
+ if (/^No matches found/m.test(text))
124
+ return { text: join("0 matches", dur) };
125
+ return { text: join(plural(countLines(text), "match"), dur) };
126
+ }
127
+ // find / ls
128
+ return { text: join(plural(countLines(text), "entry", "entries"), dur) };
129
+ }
130
+ export function firstLine(text) {
131
+ for (const line of text.split("\n")) {
132
+ const trimmed = line.trim();
133
+ if (trimmed && trimmed !== "(no output)")
134
+ return trimmed;
135
+ }
136
+ return undefined;
137
+ }
138
+ export function formatBytes(n) {
139
+ if (n < 1024)
140
+ return `${n} B`;
141
+ if (n < 1024 * 1024)
142
+ return `${Math.round(n / 102.4) / 10} KB`;
143
+ return `${Math.round(n / (1024 * 102.4)) / 10} MB`;
144
+ }
145
+ /**
146
+ * Lay out one row: `<glyph> <verb> <item> ... <outcome>`. The item is truncated so the outcome
147
+ * stays visible; the outcome is dropped only when the row is too narrow to show both.
148
+ */
149
+ export function layoutRow(width, verb, item, outcome) {
150
+ const verbCol = verb.padEnd(VERB_WIDTH);
151
+ // glyph + space + verb column + space
152
+ const prefixWidth = 2 + VERB_WIDTH + 1;
153
+ const outcomeWidth = visibleWidth(outcome);
154
+ const minGap = 2;
155
+ let shownOutcome = outcome;
156
+ let itemRoom = width - prefixWidth - (outcomeWidth ? outcomeWidth + minGap : 0);
157
+ if (itemRoom < 12 && outcomeWidth) {
158
+ shownOutcome = "";
159
+ itemRoom = width - prefixWidth;
31
160
  }
32
- activityGroup = { verb, leadId: id, seen: new Set([id]) };
33
- return { header: verb, item };
161
+ const shownItem = truncateToWidth(item, Math.max(0, itemRoom), "…");
162
+ const used = prefixWidth + visibleWidth(shownItem) + visibleWidth(shownOutcome);
163
+ const gap = shownOutcome ? Math.max(minGap, width - used) : 0;
164
+ return { verb: verbCol, item: shownItem, gap, outcome: shownOutcome };
34
165
  }