@groeponline/pi-wishcraft 1.6.0 → 1.9.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.
Files changed (46) hide show
  1. package/AGENTS.md +315 -97
  2. package/CHANGELOG.md +99 -9
  3. package/README.md +33 -33
  4. package/bash-mode/editor.ts +82 -10
  5. package/bash-mode/pty-session.ts +64 -16
  6. package/bash-mode/transcript.ts +67 -2
  7. package/docs/architecture/codebase-graph.md +660 -0
  8. package/docs/bash-mode.md +4 -0
  9. package/docs/commands.md +1 -1
  10. package/docs/segments.md +2 -2
  11. package/package.json +2 -2
  12. package/src/config/presets.ts +59 -1
  13. package/src/config/segment-options.ts +98 -83
  14. package/src/config/structural-preset-data.ts +69 -0
  15. package/src/config/structural-preset-table.ts +445 -0
  16. package/src/config/structural-presets.ts +9 -499
  17. package/src/config/types.ts +8 -1
  18. package/src/extension/skills/global-registry.ts +63 -0
  19. package/src/extension/skills/skill-registry.ts +56 -4
  20. package/src/extension/ui/custom-editor.ts +22 -1
  21. package/src/extension/ui/deck/component.ts +17 -5
  22. package/src/extension/ui/deck/render.ts +26 -7
  23. package/src/extension/ui/status-line-renderers.ts +6 -2
  24. package/src/motion/catalog-extra.ts +24 -0
  25. package/src/motion/catalog.ts +4 -0
  26. package/src/motion/index.ts +1 -0
  27. package/src/motion/policy.ts +28 -1
  28. package/src/motion/scheduler.ts +7 -0
  29. package/src/render/layout.ts +44 -2
  30. package/src/render/motion-candidates.ts +52 -0
  31. package/src/render/motion-rail.ts +80 -50
  32. package/src/render/paint.ts +32 -8
  33. package/src/render/v2-adapter.ts +2 -0
  34. package/src/render/v2-entry.ts +1 -1
  35. package/src/segments/system.ts +13 -6
  36. package/src/signal/controller.ts +45 -29
  37. package/src/studio/component.ts +213 -37
  38. package/src/studio/deepwiki/cache.ts +80 -6
  39. package/src/studio/list.ts +11 -1
  40. package/src/studio/open.ts +207 -15
  41. package/src/theme/colors.ts +2 -6
  42. package/src/theme/icons.ts +21 -0
  43. package/src/theme/separators.ts +20 -0
  44. package/tsconfig.test.json +7 -0
  45. package/src/tools/patch.ts +0 -179
  46. package/src/tools/ripgrep.ts +0 -104
package/README.md CHANGED
@@ -1,44 +1,54 @@
1
- <p>
2
- <img src="https://raw.githubusercontent.com/GroepOnline/pi-wishcraft/main/banner.png" alt="pi-wishcraft" width="1100">
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/GroepOnline/pi-wishcraft/main/banner.png" alt="Pi Wishcraft" width="100%">
3
3
  </p>
4
4
 
5
- <p>
5
+ <h1 align="center">Pi Wishcraft</h1>
6
+
7
+ <p align="center"><strong>Your operator cockpit for Pi.</strong><br>See what the session is doing, park ideas without interrupting it, search skills, switch into Bash, and keep the important controls one keypress away.</p>
8
+
9
+ <p align="center">
6
10
  <a href="https://www.npmjs.com/package/@groeponline/pi-wishcraft"><img src="https://img.shields.io/npm/v/@groeponline/pi-wishcraft.svg" alt="npm version"></a>
7
11
  <a href="https://www.npmjs.com/package/@groeponline/pi-wishcraft"><img src="https://img.shields.io/npm/dm/@groeponline/pi-wishcraft.svg?label=downloads" alt="npm downloads"></a>
8
12
  <a href="https://pi.dev/packages/@groeponline/pi-wishcraft"><img src="https://img.shields.io/badge/Pi-package-9b59b6.svg" alt="Pi package"></a>
9
13
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-555.svg" alt="MIT license"></a>
10
14
  </p>
11
15
 
12
- # pi-wishcraft
13
-
14
- Cockpit and harness for the [pi](https://github.com/badlogic/pi-mono) coding agent: a live status bar, overlay menus, skills, an idea inbox, sticky bash, hooks, and tool-input repairs. Stock pi stays the engine. This package is the operator layer.
15
- Kongming lanterns started as battlefield signals and later carried wishes. Wishcraft is that split in a coding session: telemetry on the bar, thoughts you can park without interrupting the run.
16
+ <p align="center"><img src="https://raw.githubusercontent.com/GroepOnline/pi-wishcraft/main/docs/images/wishcraft-cockpit-map.svg" alt="Wishcraft cockpit map showing Signal, Deck, idea capture, skills, managed shell and local guardrails around Pi" width="100%"><br><sub>A map of the shipped operator surfaces. Pi remains the agent runtime.</sub></p>
16
17
 
17
- Install `@groeponline/pi-wishcraft`. It is listed on the [Pi package catalog](https://pi.dev/packages/@groeponline/pi-wishcraft). Grew out of [`nicobailon/pi-powerline-footer`](https://github.com/nicobailon/pi-powerline-footer). Maintained by [GroepOnline](https://github.com/GroepOnline).
18
+ ## Start in 10 seconds
18
19
 
19
- Guides live in [`docs/`](docs/index.md). This is the Pi extension contract: what ships, how to install it, and what can fail.
20
+ ```bash
21
+ pi install npm:@groeponline/pi-wishcraft
22
+ ```
20
23
 
21
- ## Quick start tutorial
24
+ Reload Pi, then press `alt+p` to open the Deck.
22
25
 
23
- Install, restart Pi (or run `/reload`), then press `alt+p` for the Deck. Try `/signal menu`, `/skills`, `/ideas`, and `ctrl+shift+b`. Full setup: [docs/configuration.md](docs/configuration.md).
26
+ Try the two flows that make Wishcraft click fastest:
24
27
 
25
- ## v2 Platform (in this release)
28
+ ```text
29
+ # remember to benchmark the new provider path
30
+ /ideas
31
+ ```
26
32
 
27
- ## Install
33
+ The `#` line is captured as an idea instead of being sent as a prompt. Your active run keeps going.
28
34
 
29
- Pi package manager (usual path):
35
+ Then open the skill picker:
30
36
 
31
- ```bash
32
- pi install npm:@groeponline/pi-wishcraft
37
+ ```text
38
+ /skills
33
39
  ```
34
40
 
35
- For a local checkout or CI environment, run the repository bootstrap script with Bash:
41
+ Search, inspect and insert a skill without leaving the session.
36
42
 
37
- ```bash
38
- bash ./scripts/install.sh
39
- ```
43
+ ## Why Wishcraft
44
+
45
+ Pi stays the engine. Wishcraft adds the operator layer around it: live status, overlays, idea capture, skill search, sticky Bash, hooks, policy controls and session UX.
46
+
47
+ Kongming lanterns started as battlefield signals and later carried wishes. Wishcraft keeps both ideas in one place: telemetry while the work is moving, and thoughts you can park until you are ready for them.
48
+
49
+ **Portfolio boundary:** Wishcraft owns the operator cockpit and lightweight idea capture. Promote durable work to [`pi-missions`](https://github.com/GroepOnline/pi-missions), then use [`pi-agent-orchestrator`](https://github.com/GroepOnline/pi-agent-orchestrator) when parallel or isolated execution adds value: `idea -> mission -> orchestration run`.
40
50
 
41
- Then restart Pi or `/reload`. Pi host packages are declared as `peerDependencies: "*"`, matching the Pi package contract.
51
+ Guides live in [`docs/`](docs/index.md).
42
52
 
43
53
  ## What you get
44
54
 
@@ -81,12 +91,6 @@ Activates on load. `/signal` toggles it. `/signal <preset>` switches the informa
81
91
  /vibe star trek themed working messages
82
92
  ```
83
93
 
84
- Queue:
85
-
86
- - `# <text>` current project; `# @global`, `# @current`, `# @alias`
87
- - `/idea`, `/ideas`, `/queue` for capture, send, retry, clear, archive
88
- - `/ideas` overlay: `reviewStatus` (`idea` / `in-progress` / `done`), tags, Run with skill X
89
-
90
94
  Keybinds (`powerlineShortcuts`, applied after `/reload`; `null` disables):
91
95
 
92
96
  ```json
@@ -115,10 +119,6 @@ Keybinds (`powerlineShortcuts`, applied after `/reload`; `null` disables):
115
119
 
116
120
  `chef` is muted colors, slash separators, live TPS in/out, and TCP port count. Built-in presets: `default`, `minimal`, `compact`, `full`, `nerd`, `ascii`, `chef`. Custom segments, labels, layout, and presets are documented in [docs/configuration.md](docs/configuration.md). For every setting at its default, see [`examples/settings.example.json`](examples/settings.example.json).
117
121
 
118
- Nerd Fonts auto-detect for iTerm, WezTerm, Kitty, Ghostty, and Alacritty; ASCII otherwise. `POWERLINE_NERD_FONTS=0` forces ASCII.
119
-
120
- Context turns warning above 70% and error above 90%. TPS is tokens in the last ~1s, not a session average. `/tps` reads that ring; it does not start a second sampler.
121
-
122
122
  Daily token budget (never blocks a turn):
123
123
 
124
124
  ```json
@@ -222,18 +222,18 @@ No shell commands — pure in-process regex. Evaluated before command hooks. `wi
222
222
  ```
223
223
 
224
224
 
225
+ Privacy/network boundary: ideas, settings, usage ledgers, and normal cockpit state stay local; there is no package-owned telemetry backend. Optional exchange-rate/DeepWiki features and operator-defined hooks cross the network/process boundary only when used.
225
226
  ## Limits
226
227
 
227
228
  - No mouse on the live footer. Pi core owns that surface.
228
229
  - No second `alt+i` product. Ports stay on `alt+i`; other detail is `→` in the navigator.
229
- - ChefGroep status keys (`powerline.preset`, `powerline.tps`, `powerline.ports`) exist for other extensions. They are not the public pitch.
230
+ - Compatibility status keys (`powerline.preset`, `powerline.tps`, `powerline.ports`) are available to peer extensions; normal Wishcraft use does not require them.
230
231
  - The legacy `@groeponline/pi-powerline-footer` package is deprecated in favor of `@groeponline/pi-wishcraft`.
231
232
  - Tags are not rewritten. 0.19.x through current stay on the timeline.
232
233
 
233
234
  ## vNext Direction
234
235
 
235
236
  Wishcraft is Pi's animated operator layer. See the [release plan](docs/design/vnext-release-plan.md) and [design corpus](docs/index.md#design-system--vnext-specifications).
236
-
237
237
  ## Docs
238
238
 
239
239
  - [Commands](docs/commands.md)
@@ -31,6 +31,24 @@ import type {
31
31
  GhostSuggestion,
32
32
  } from "./types.ts";
33
33
 
34
+ const BRACKETED_PASTE_START = "\x1b[200~";
35
+ const BRACKETED_PASTE_END = "\x1b[201~";
36
+
37
+ function splitTrailingPasteMarkerPrefix(value: string): [string, string] {
38
+ let tail = "";
39
+ for (const marker of [BRACKETED_PASTE_START, BRACKETED_PASTE_END]) {
40
+ const max = Math.min(marker.length - 1, value.length);
41
+ for (let length = max; length > tail.length; length -= 1) {
42
+ const candidate = value.slice(-length);
43
+ if (marker.startsWith(candidate)) {
44
+ tail = candidate;
45
+ break;
46
+ }
47
+ }
48
+ }
49
+ return tail ? [value.slice(0, -tail.length), tail] : [value, ""];
50
+ }
51
+
34
52
  export class BashModeEditor extends CustomEditor {
35
53
  private readonly keybindingsRef: KeybindingsManager;
36
54
  private readonly optionsRef: BashModeEditorOptions;
@@ -43,6 +61,10 @@ export class BashModeEditor extends CustomEditor {
43
61
  private ghostAbort: AbortController | null = null;
44
62
  private ghostToken = 0;
45
63
  private ghostSchedule: ReturnType<typeof setTimeout> | null = null;
64
+ /** A bracketed paste was split across input chunks while forwarding. */
65
+ private forwardPasteOpen = false;
66
+ /** Trailing prefix of a bracketed-paste delimiter awaiting the next input chunk. */
67
+ private forwardPasteMarkerTail = "";
46
68
 
47
69
  constructor(
48
70
  tui: any,
@@ -112,6 +134,25 @@ export class BashModeEditor extends CustomEditor {
112
134
  }
113
135
 
114
136
  handleInput(data: string): void {
137
+ // v2 forward-mode gate, shared by the paste branch (issue #72) and the
138
+ // per-keystroke forward further down: while a command runs, input
139
+ // belongs to the child stdin, never to the editor buffer behind it.
140
+ const forwardActive =
141
+ this.optionsRef.isBashModeActive() &&
142
+ this.optionsRef.isShellRunning() &&
143
+ (this.optionsRef.forwardWhileRunning?.() ?? false) &&
144
+ this.optionsRef.onForwardInput != null;
145
+ if (!forwardActive) {
146
+ this.forwardPasteOpen = false;
147
+ this.forwardPasteMarkerTail = "";
148
+ } else {
149
+ data = this.forwardPasteMarkerTail + data;
150
+ const [complete, tail] = splitTrailingPasteMarkerPrefix(data);
151
+ data = complete;
152
+ this.forwardPasteMarkerTail = tail;
153
+ if (!data) return;
154
+ }
155
+
115
156
  const droppedPathText = droppedPathTextFromInput(data);
116
157
  if (droppedPathText !== null) {
117
158
  this.insertTextAtCursor(droppedPathText);
@@ -125,8 +166,23 @@ export class BashModeEditor extends CustomEditor {
125
166
  }
126
167
 
127
168
  const pasteInProgress =
128
- data.includes("\x1b[200~") || Reflect.get(this, "isInPaste") === true;
169
+ data.includes(BRACKETED_PASTE_START) ||
170
+ data.includes(BRACKETED_PASTE_END) ||
171
+ Reflect.get(this, "isInPaste") === true ||
172
+ (forwardActive && this.forwardPasteOpen);
129
173
  if (pasteInProgress) {
174
+ if (forwardActive) {
175
+ const startIndex = data.lastIndexOf(BRACKETED_PASTE_START);
176
+ const endIndex = data.lastIndexOf(BRACKETED_PASTE_END);
177
+ if (startIndex !== -1 || endIndex !== -1) {
178
+ this.forwardPasteOpen = startIndex > endIndex;
179
+ }
180
+ const payload = data
181
+ .replaceAll(BRACKETED_PASTE_START, "")
182
+ .replaceAll(BRACKETED_PASTE_END, "");
183
+ if (payload) this.optionsRef.onForwardInput?.(payload);
184
+ return;
185
+ }
130
186
  super.handleInput(data);
131
187
  if (Reflect.get(this, "isInPaste") === true) {
132
188
  return;
@@ -169,15 +225,14 @@ export class BashModeEditor extends CustomEditor {
169
225
  // (\x04) must also forward — line-oriented stdin programs (read,
170
226
  // sudo, git rebase -i) cannot proceed without a line terminator.
171
227
  // Opt-in via forwardWhileRunning so v1 run-blocked behavior unchanged.
228
+ // (forwardActive is computed above; bracketed pastes take the paste
229
+ // branch, which forwards the stripped payload too.)
172
230
  if (
173
- bashMode &&
174
- this.optionsRef.isShellRunning() &&
175
- (this.optionsRef.forwardWhileRunning?.() ?? false) &&
176
- this.optionsRef.onForwardInput != null &&
231
+ forwardActive &&
177
232
  !isKeyRelease(data) &&
178
233
  (isPrintableInput(data) || data === "\r" || data === "\n" || data === "\x04")
179
234
  ) {
180
- this.optionsRef.onForwardInput(data);
235
+ this.optionsRef.onForwardInput?.(data);
181
236
  return;
182
237
  }
183
238
 
@@ -279,17 +334,34 @@ export class BashModeEditor extends CustomEditor {
279
334
  }
280
335
 
281
336
  if (
282
- bashMode &&
337
+ (bashMode || oneOffBashCommand) &&
283
338
  this.keybindingsRef.matches(data, "tui.input.submit") &&
284
339
  !this.keybindingsRef.matches(data, "tui.input.newLine")
285
340
  ) {
341
+ // One-off bang prompts run the text after the prefix (mirrors pi's
342
+ // own !/!! handling in interactive-mode.js); anything else submits
343
+ // verbatim. Without the strip the bang would hit bash history
344
+ // expansion (`!cmd` = replay a previous command) instead of `cmd`.
345
+ const raw = this.getExpandedText().trim();
346
+ const oneOff = getOneOffBashCommandContext(raw);
347
+ const command = oneOff ? oneOff.command.trim() : raw;
348
+ if (!command) {
349
+ // Bash-off bare `!`: fall back to pi's own submit handling, whose
350
+ // `if (command)` guard turns an empty command into a normal prompt
351
+ // (pre-interception behavior). Full bash mode keeps the return.
352
+ // This must run before the shell-running guard: a bare `!` while a
353
+ // managed shell job runs should still delegate to pi, not be
354
+ // swallowed by the "already running" warning.
355
+ if (!bashMode) {
356
+ super.handleInput(data);
357
+ }
358
+ return;
359
+ }
360
+
286
361
  if (this.optionsRef.isShellRunning()) {
287
362
  this.optionsRef.onNotify("Shell command already running", "warning");
288
363
  return;
289
364
  }
290
-
291
- const command = this.getExpandedText().trim();
292
- if (!command) return;
293
365
  this.clearGhostSuggestion();
294
366
  resetShellHistoryBrowse(this);
295
367
  this.optionsRef.onEditorSubmit?.();
@@ -22,6 +22,38 @@ import { basename, join } from "node:path";
22
22
  import { randomBytes } from "node:crypto";
23
23
  import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
24
24
 
25
+ const MAX_PENDING_LINE_BYTES = 64 * 1024;
26
+ const MAX_ESCAPE_TAIL_CHARS = 4096;
27
+ const TRUNCATED_LINE_NOTICE = "[wishcraft] output line truncated; keeping tail";
28
+
29
+ /**
30
+ * Restricts a string to its trailing UTF-8 bytes without splitting a multibyte character.
31
+ *
32
+ * @param value - The string to restrict
33
+ * @param maxBytes - The maximum number of UTF-8 bytes to retain
34
+ * @returns The UTF-8-safe trailing portion of `value`
35
+ */
36
+ function utf8Tail(value: string, maxBytes: number): string {
37
+ if (maxBytes <= 0) return "";
38
+ const bytes = Buffer.from(value, "utf8");
39
+ if (bytes.length <= maxBytes) return value;
40
+ let start = bytes.length - maxBytes;
41
+ while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1;
42
+ return bytes.subarray(start).toString("utf8");
43
+ }
44
+
45
+ /**
46
+ * Limits a partial terminal escape sequence to the configured character bound while preserving its introducer.
47
+ *
48
+ * @param value - The partial escape sequence to limit
49
+ * @returns The original value or a bounded value with its escape introducer preserved
50
+ */
51
+ function boundedEscapeTail(value: string): string {
52
+ if (value.length <= MAX_ESCAPE_TAIL_CHARS) return value;
53
+ const prefixLength = value.startsWith("\x1b") && value.length > 1 ? 2 : 1;
54
+ return value.slice(0, prefixLength) + value.slice(-(MAX_ESCAPE_TAIL_CHARS - prefixLength));
55
+ }
56
+
25
57
  /**
26
58
  * Fresh per-command completion delimiter. The wrapper prints it after the
27
59
  * sourced script ends; because it is unpredictable, a command cannot forge
@@ -119,6 +151,10 @@ interface RunningCommand {
119
151
  buffer: string;
120
152
  /** Trailing partial escape sequence carried across chunks. */
121
153
  escapeTail: string;
154
+ /** Wrapper result observed on stdout; publication waits for child close. */
155
+ pendingResult: PtyRunResult | null;
156
+ /** Emit at most one notice when a single output line exceeds the pending cap. */
157
+ lineTruncated: boolean;
122
158
  resolve: (result: PtyRunResult) => void;
123
159
  settled: boolean;
124
160
  }
@@ -179,6 +215,8 @@ export class PtyShellSession {
179
215
  done,
180
216
  buffer: "",
181
217
  escapeTail: "",
218
+ pendingResult: null,
219
+ lineTruncated: false,
182
220
  resolve: (result) => {
183
221
  if (running.settled) return;
184
222
  running.settled = true;
@@ -224,9 +262,8 @@ export class PtyShellSession {
224
262
  child.stderr.on("data", (chunk: string) => this.handleChunk(String(chunk)));
225
263
  child.on("error", (error) => {
226
264
  // spawn itself failed (script removed between probe and spawn, dead
227
- // SHELL path): surface the reason, then settle as a normal failure.
228
- // The first script-path failure also flips the probe cache so the
229
- // next command degrades to pipes instead of silently failing again.
265
+ // SHELL path): surface the reason, but do not publish completion until
266
+ // the matching child emits close. That preserves strict child ownership.
230
267
  console.warn(
231
268
  "[wishcraft] spawn failed:",
232
269
  error instanceof Error ? error.message : String(error),
@@ -234,20 +271,24 @@ export class PtyShellSession {
234
271
  if (usePty) {
235
272
  _resetScriptAvailableForTests();
236
273
  }
237
- running.resolve({ exitCode: 1, cwd: this.state.cwd });
274
+ running.pendingResult = { exitCode: 1, cwd: this.state.cwd };
238
275
  });
239
276
  child.on("close", (code, signal) => {
240
- this.child = null;
241
- // No sentinel observed. An operator interrupt never lets the command
242
- // complete, so it maps to 130 regardless of how `script` exits.
277
+ if (this.child === child) {
278
+ this.child = null;
279
+ }
280
+ // Completion is published only after this exact child closes, so a
281
+ // later run cannot have its child reference cleared by this callback.
243
282
  if (this.interrupted) {
244
283
  running.resolve({ exitCode: 130, cwd: this.state.cwd });
245
284
  return;
246
285
  }
247
- running.resolve({
248
- exitCode: getCloseExitCode(code, signal),
249
- cwd: this.state.cwd,
250
- });
286
+ running.resolve(
287
+ running.pendingResult ?? {
288
+ exitCode: getCloseExitCode(code, signal),
289
+ cwd: this.state.cwd,
290
+ },
291
+ );
251
292
  });
252
293
  });
253
294
  }
@@ -302,8 +343,9 @@ export class PtyShellSession {
302
343
  let work = merged;
303
344
  const partial = /(?:\x1b(?:\[[0-9;?]*[ -/]*|\][^\x07\x1b]*|[P^_][^\x07\x1b]*))$|\x1b$/.exec(work);
304
345
  if (partial) {
305
- running.escapeTail = partial[0];
306
- work = work.slice(0, work.length - running.escapeTail.length);
346
+ const rawTail = partial[0];
347
+ running.escapeTail = boundedEscapeTail(rawTail);
348
+ work = work.slice(0, work.length - rawTail.length);
307
349
  }
308
350
 
309
351
  const color = this.color && this.state.mode === "pty";
@@ -311,7 +353,13 @@ export class PtyShellSession {
311
353
 
312
354
  running.buffer += filtered;
313
355
  const parts = running.buffer.split("\n");
314
- running.buffer = parts.pop() ?? "";
356
+ const remainder = parts.pop() ?? "";
357
+ const boundedRemainder = utf8Tail(remainder, MAX_PENDING_LINE_BYTES);
358
+ if (boundedRemainder !== remainder && !running.lineTruncated) {
359
+ running.lineTruncated = true;
360
+ this.onOutput(TRUNCATED_LINE_NOTICE);
361
+ }
362
+ running.buffer = boundedRemainder;
315
363
 
316
364
  for (const rawLine of parts) {
317
365
  const line = rawLine.replace(/\r$/, "");
@@ -322,10 +370,10 @@ export class PtyShellSession {
322
370
  if (firstColon !== -1) {
323
371
  const exitCode = Number.parseInt(rest.slice(0, firstColon), 10);
324
372
  const cwd = rest.slice(firstColon + 1);
325
- running.resolve({
373
+ running.pendingResult = {
326
374
  exitCode: Number.isFinite(exitCode) ? exitCode : 1,
327
375
  cwd: cwd || this.state.cwd,
328
- });
376
+ };
329
377
  return;
330
378
  }
331
379
  continue;
@@ -4,6 +4,29 @@ function byteLength(value: string): number {
4
4
  return Buffer.byteLength(value, "utf8");
5
5
  }
6
6
 
7
+ /**
8
+ * Extracts the end of a string within a UTF-8 byte limit.
9
+ *
10
+ * @param value - The source string
11
+ * @param maxBytes - The maximum number of UTF-8 bytes to include
12
+ * @returns The UTF-8-safe tail of `value`
13
+ */
14
+ function utf8Tail(value: string, maxBytes: number): string {
15
+ if (maxBytes <= 0) return "";
16
+ const bytes = Buffer.from(value, "utf8");
17
+ if (bytes.length <= maxBytes) return value;
18
+
19
+ let start = bytes.length - maxBytes;
20
+ while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1;
21
+ return bytes.subarray(start).toString("utf8");
22
+ }
23
+
24
+ /**
25
+ * Normalizes lines by removing carriage returns and splitting embedded newline characters.
26
+ *
27
+ * @param lines - The lines to normalize
28
+ * @returns The normalized lines
29
+ */
7
30
  function compactLines(lines: string[]): string[] {
8
31
  const normalized: string[] = [];
9
32
  for (const line of lines) {
@@ -119,8 +142,50 @@ export class BashTranscriptStore {
119
142
  this.commandIndex.delete(removed.id);
120
143
  this.totalLines = Math.max(0, this.totalLines - removed.output.length);
121
144
  this.totalBytes = Math.max(0, this.totalBytes - removed.outputBytes);
122
- this.truncatedCommands += 1;
123
- removed.truncated = true;
145
+ this.markTruncated(removed);
146
+ }
147
+
148
+ // A single active command used to bypass both global limits because there
149
+ // was no older command to evict. Trim that command from the head instead:
150
+ // bash mode is a tail-oriented live view, so the newest output is the
151
+ // useful part and memory remains bounded even for multi-megabyte logs.
152
+ const oldest = this.commands[0];
153
+ if (!oldest) return;
154
+
155
+ while (oldest.output.length > 0 && this.totalLines > this.settings.transcriptMaxLines) {
156
+ this.dropOldestLine(oldest);
157
+ }
158
+ while (oldest.output.length > 1 && this.totalBytes > this.settings.transcriptMaxBytes) {
159
+ this.dropOldestLine(oldest);
124
160
  }
161
+
162
+ if (oldest.output.length === 1 && this.totalBytes > this.settings.transcriptMaxBytes) {
163
+ const previous = oldest.output[0]!;
164
+ const previousBytes = byteLength(previous) + 1;
165
+ const bytesOutsideLine = Math.max(0, this.totalBytes - previousBytes);
166
+ const lineBudget = Math.max(0, this.settings.transcriptMaxBytes - bytesOutsideLine - 1);
167
+ const tail = utf8Tail(previous, lineBudget);
168
+ const tailBytes = byteLength(tail) + 1;
169
+ oldest.output[0] = tail;
170
+ oldest.outputBytes = Math.max(0, oldest.outputBytes - previousBytes + tailBytes);
171
+ this.totalBytes = Math.max(0, this.totalBytes - previousBytes + tailBytes);
172
+ this.markTruncated(oldest);
173
+ }
174
+ }
175
+
176
+ private dropOldestLine(command: BashCommandRecord): void {
177
+ const removed = command.output.shift();
178
+ if (removed === undefined) return;
179
+ const removedBytes = byteLength(removed) + 1;
180
+ command.outputBytes = Math.max(0, command.outputBytes - removedBytes);
181
+ this.totalLines = Math.max(0, this.totalLines - 1);
182
+ this.totalBytes = Math.max(0, this.totalBytes - removedBytes);
183
+ this.markTruncated(command);
184
+ }
185
+
186
+ private markTruncated(command: BashCommandRecord): void {
187
+ if (command.truncated) return;
188
+ command.truncated = true;
189
+ this.truncatedCommands += 1;
125
190
  }
126
191
  }