@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.
- package/AGENTS.md +315 -97
- package/CHANGELOG.md +99 -9
- package/README.md +33 -33
- package/bash-mode/editor.ts +82 -10
- package/bash-mode/pty-session.ts +64 -16
- package/bash-mode/transcript.ts +67 -2
- package/docs/architecture/codebase-graph.md +660 -0
- package/docs/bash-mode.md +4 -0
- package/docs/commands.md +1 -1
- package/docs/segments.md +2 -2
- package/package.json +2 -2
- package/src/config/presets.ts +59 -1
- package/src/config/segment-options.ts +98 -83
- package/src/config/structural-preset-data.ts +69 -0
- package/src/config/structural-preset-table.ts +445 -0
- package/src/config/structural-presets.ts +9 -499
- package/src/config/types.ts +8 -1
- package/src/extension/skills/global-registry.ts +63 -0
- package/src/extension/skills/skill-registry.ts +56 -4
- package/src/extension/ui/custom-editor.ts +22 -1
- package/src/extension/ui/deck/component.ts +17 -5
- package/src/extension/ui/deck/render.ts +26 -7
- package/src/extension/ui/status-line-renderers.ts +6 -2
- package/src/motion/catalog-extra.ts +24 -0
- package/src/motion/catalog.ts +4 -0
- package/src/motion/index.ts +1 -0
- package/src/motion/policy.ts +28 -1
- package/src/motion/scheduler.ts +7 -0
- package/src/render/layout.ts +44 -2
- package/src/render/motion-candidates.ts +52 -0
- package/src/render/motion-rail.ts +80 -50
- package/src/render/paint.ts +32 -8
- package/src/render/v2-adapter.ts +2 -0
- package/src/render/v2-entry.ts +1 -1
- package/src/segments/system.ts +13 -6
- package/src/signal/controller.ts +45 -29
- package/src/studio/component.ts +213 -37
- package/src/studio/deepwiki/cache.ts +80 -6
- package/src/studio/list.ts +11 -1
- package/src/studio/open.ts +207 -15
- package/src/theme/colors.ts +2 -6
- package/src/theme/icons.ts +21 -0
- package/src/theme/separators.ts +20 -0
- package/tsconfig.test.json +7 -0
- package/src/tools/patch.ts +0 -179
- 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="
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
18
|
+
## Start in 10 seconds
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
```bash
|
|
21
|
+
pi install npm:@groeponline/pi-wishcraft
|
|
22
|
+
```
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
Reload Pi, then press `alt+p` to open the Deck.
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
Try the two flows that make Wishcraft click fastest:
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
```text
|
|
29
|
+
# remember to benchmark the new provider path
|
|
30
|
+
/ideas
|
|
31
|
+
```
|
|
26
32
|
|
|
27
|
-
|
|
33
|
+
The `#` line is captured as an idea instead of being sent as a prompt. Your active run keeps going.
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
Then open the skill picker:
|
|
30
36
|
|
|
31
|
-
```
|
|
32
|
-
|
|
37
|
+
```text
|
|
38
|
+
/skills
|
|
33
39
|
```
|
|
34
40
|
|
|
35
|
-
|
|
41
|
+
Search, inspect and insert a skill without leaving the session.
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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)
|
package/bash-mode/editor.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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?.();
|
package/bash-mode/pty-session.ts
CHANGED
|
@@ -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,
|
|
228
|
-
//
|
|
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.
|
|
274
|
+
running.pendingResult = { exitCode: 1, cwd: this.state.cwd };
|
|
238
275
|
});
|
|
239
276
|
child.on("close", (code, signal) => {
|
|
240
|
-
this.child
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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.
|
|
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;
|
package/bash-mode/transcript.ts
CHANGED
|
@@ -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.
|
|
123
|
-
|
|
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
|
}
|