@groeponline/pi-wishcraft 1.4.1 → 1.4.6
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 +28 -0
- package/CHANGELOG.md +36 -0
- package/README.md +22 -18
- package/ROADMAP.md +3 -3
- package/STRATEGY.md +54 -0
- package/bash-mode/editor-input.ts +3 -1
- package/bash-mode/editor.ts +44 -7
- package/bash-mode/forward.ts +64 -0
- package/bash-mode/pty-session.ts +356 -0
- package/bash-mode/ptyshell-managed.ts +145 -0
- package/bash-mode/session-factory.ts +64 -0
- package/bash-mode/transcript-v2.ts +77 -0
- package/bash-mode/transcript.ts +18 -0
- package/bash-mode/types.ts +27 -0
- package/docs/bash-mode.md +4 -0
- package/docs/configuration.md +45 -1
- package/docs/index.md +4 -1
- package/package.json +1 -1
- package/src/config/effective.ts +122 -0
- package/src/config/settings-registry.ts +2 -0
- package/src/config/structural-presets.ts +1 -1
- package/src/extension/commands/bash-mode-actions.ts +11 -10
- package/src/extension/commands/commands.ts +8 -0
- package/src/extension/core/state.ts +0 -1
- package/src/extension/core/types.ts +2 -2
- package/src/extension/session/session-lifecycle.ts +21 -0
- package/src/extension/skills/skill-templates.ts +12 -3
- package/src/extension/ui/custom-editor.ts +4 -0
- package/src/extension/ui/status-line-renderers.ts +12 -9
- package/src/extension/welcome/welcome-integration.ts +23 -1
- package/src/motion/catalog.ts +4 -0
- package/src/motion/frames.ts +19 -9
- package/src/motion/scheduler.ts +7 -0
- package/src/render/layout.ts +137 -0
- package/src/render/motion-candidates.ts +52 -0
- package/src/render/motion-rail.ts +99 -0
- package/src/render/paint.ts +64 -0
- package/src/render/v2-adapter.ts +70 -0
- package/src/render/v2-entry.ts +132 -0
- package/src/segments/pipeline.ts +164 -0
- package/src/segments/types.ts +36 -0
- package/src/segments/usage.ts +42 -3
- package/src/signal/controller.ts +37 -21
- package/src/studio/actions.ts +105 -0
- package/src/studio/advice-pane.ts +73 -0
- package/src/studio/advise/context.ts +89 -0
- package/src/studio/advise/engine.ts +117 -0
- package/src/studio/advise/prompts.ts +68 -0
- package/src/studio/component.ts +105 -0
- package/src/studio/deepwiki/cache.ts +74 -0
- package/src/studio/deepwiki/client.ts +73 -0
- package/src/studio/deepwiki/extract.ts +66 -0
- package/src/studio/inspect.ts +38 -0
- package/src/studio/list.ts +48 -0
- package/src/studio/open.ts +44 -0
- package/src/studio/state.ts +100 -0
- package/src/studio/types.ts +30 -0
- package/src/welcome/banner.ts +6 -0
- package/src/welcome/index.ts +7 -0
- package/src/welcome/overlay.ts +6 -0
- package/src/welcome/renderer.ts +6 -39
- package/src/welcome/types.ts +6 -0
- package/src/welcome/welcome-art.ts +148 -0
- package/src/working-vibes/frames.ts +25 -0
- package/src/working-vibes/index.ts +2 -0
- package/src/working-vibes/storage.ts +11 -0
- package/bash-mode/shell-session.ts +0 -294
- package/src/signal/render.ts +0 -221
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pty-session.ts
|
|
3
|
+
* ------------------------------------------------------------------------
|
|
4
|
+
* PTY-backed command execution for bash mode v2 (plan U3, KTD2).
|
|
5
|
+
*
|
|
6
|
+
* Wraps `script(1)` (util-linux) to allocate a real PTY per command, so
|
|
7
|
+
* stdin-reading programs work and SGR color survives into the transcript.
|
|
8
|
+
* The ANSI filter keeps only SGR sequences (plus plain text); OSC, DCS,
|
|
9
|
+
* cursor-positioning CSI and C0 control noise are stripped — a transcript
|
|
10
|
+
* must never relay terminal-control escapes back into the host terminal.
|
|
11
|
+
*
|
|
12
|
+
* When `script` is unavailable, the session degrades per command to plain
|
|
13
|
+
* pipe execution (v1 behavior, no color). SIGWINCH/resize forwarding is a
|
|
14
|
+
* known `script(1)` ceiling — swap for node-pty only if that bites
|
|
15
|
+
* (ponytail: revisit on real demand, not speculatively).
|
|
16
|
+
* ------------------------------------------------------------------------
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { basename, join } from "node:path";
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Fresh per-command completion delimiter. The wrapper prints it after the
|
|
27
|
+
* sourced script ends; because it is unpredictable, a command cannot forge
|
|
28
|
+
* it mid-stream and settle the run early (which would let a later
|
|
29
|
+
* runCommand replace `this.child` and orphan the still-running process
|
|
30
|
+
* group).
|
|
31
|
+
*/
|
|
32
|
+
function makeDoneDelimiter(): string {
|
|
33
|
+
return `__PI_DONE__${randomBytes(8).toString("hex")}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PtyFilterOptions {
|
|
37
|
+
/** Keep SGR color sequences; false strips them too (plain-text mode). */
|
|
38
|
+
color: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PtyRunResult {
|
|
42
|
+
exitCode: number;
|
|
43
|
+
cwd: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PtyShellSessionOptions {
|
|
47
|
+
cwd: string;
|
|
48
|
+
/** Emit filtered output lines as they arrive. */
|
|
49
|
+
onOutput: (line: string) => void;
|
|
50
|
+
onStateChange: () => void;
|
|
51
|
+
/** Keep SGR colors (PTY mode default capability). Default true. */
|
|
52
|
+
color?: boolean;
|
|
53
|
+
/** Override for the `script` availability probe (tests). */
|
|
54
|
+
scriptAvailable?: () => boolean;
|
|
55
|
+
/** Override user shell path (tests). */
|
|
56
|
+
shellPath?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Filter raw PTY output. Keeps text, newlines, tabs, and (optionally) SGR
|
|
61
|
+
* escapes. Strips OSC/DCS sequences, non-SGR CSI sequences, and C0 control
|
|
62
|
+
* noise including the CR of CRLF pairs.
|
|
63
|
+
*/
|
|
64
|
+
export function filterPtyOutput(chunk: string, options: PtyFilterOptions): string {
|
|
65
|
+
let out = chunk;
|
|
66
|
+
|
|
67
|
+
// OSC sequences: ESC ] ... (BEL or ST)
|
|
68
|
+
out = out.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
|
|
69
|
+
// DCS/SOS/PM/APC sequences: ESC P ... ST
|
|
70
|
+
out = out.replace(/\x1b[P^_][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
|
|
71
|
+
|
|
72
|
+
if (options.color) {
|
|
73
|
+
// Protect SGR sequences behind Private Use Area sentinels so the C0
|
|
74
|
+
// strip below cannot eat their ESC introducers, strip every other CSI.
|
|
75
|
+
const kept: string[] = [];
|
|
76
|
+
out = out.replace(/\x1b\[[0-9;]*m/g, (m) => {
|
|
77
|
+
kept.push(m);
|
|
78
|
+
return `\uE000${kept.length - 1}\uE001`;
|
|
79
|
+
});
|
|
80
|
+
out = out.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
|
81
|
+
// C0 noise: drop everything except \n and \t; \r is dropped (CRLF -> LF).
|
|
82
|
+
out = out.replace(/[\x00-\x09\x0b-\x1f\x7f]/g, (c) => (c === "\t" ? c : ""));
|
|
83
|
+
out = out.replace(/\uE000(\d+)\uE001/g, (_, i: string) => kept[Number.parseInt(i, 10)] ?? "");
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Strip all CSI including SGR.
|
|
88
|
+
out = out.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
|
89
|
+
// C0 noise: drop everything except \n and \t; \r is dropped (CRLF -> LF).
|
|
90
|
+
out = out.replace(/[\x00-\x09\x0b-\x1f\x7f]/g, (c) => (c === "\t" ? c : ""));
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function quoteShellArg(value: string): string {
|
|
95
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getCloseExitCode(code: number | null, signal: NodeJS.Signals | null): number {
|
|
99
|
+
if (typeof code === "number") return code;
|
|
100
|
+
if (signal === "SIGINT") return 130;
|
|
101
|
+
if (signal === "SIGTERM") return 143;
|
|
102
|
+
if (signal === "SIGKILL") return 137;
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildWrapper(shellName: string, cwd: string, file: string, done: string): string {
|
|
107
|
+
const quotedCwd = quoteShellArg(cwd);
|
|
108
|
+
const quotedFile = quoteShellArg(file);
|
|
109
|
+
if (shellName.includes("fish")) {
|
|
110
|
+
return `cd ${quotedCwd}; and source ${quotedFile}; printf '\\n${done}:%s:%s\\n' $status $PWD`;
|
|
111
|
+
}
|
|
112
|
+
return `cd ${quotedCwd} && . ${quotedFile}; printf '\\n${done}:%s:%s\\n' "$?" "$PWD"`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface RunningCommand {
|
|
116
|
+
id: number;
|
|
117
|
+
/** This command's completion delimiter; only its own wrapper prints it. */
|
|
118
|
+
done: string;
|
|
119
|
+
buffer: string;
|
|
120
|
+
/** Trailing partial escape sequence carried across chunks. */
|
|
121
|
+
escapeTail: string;
|
|
122
|
+
resolve: (result: PtyRunResult) => void;
|
|
123
|
+
settled: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class PtyShellSession {
|
|
127
|
+
private readonly startCwd: string;
|
|
128
|
+
private readonly onOutput: (line: string) => void;
|
|
129
|
+
private readonly onStateChange: () => void;
|
|
130
|
+
private readonly color: boolean;
|
|
131
|
+
private readonly scriptProbe: () => boolean;
|
|
132
|
+
private readonly shellPath: string;
|
|
133
|
+
private readonly shellName: string;
|
|
134
|
+
private readonly tempDir = mkdtempSync(join(tmpdir(), "wishcraft-pty-"));
|
|
135
|
+
private child: ChildProcessWithoutNullStreams | null = null;
|
|
136
|
+
private commandCounter = 0;
|
|
137
|
+
private running: RunningCommand | null = null;
|
|
138
|
+
private disposed = false;
|
|
139
|
+
private interrupted = false;
|
|
140
|
+
|
|
141
|
+
state: { running: boolean; cwd: string; lastExitCode: number | null; mode: "pty" | "pipe" };
|
|
142
|
+
|
|
143
|
+
constructor(options: PtyShellSessionOptions) {
|
|
144
|
+
this.startCwd = options.cwd;
|
|
145
|
+
this.onOutput = options.onOutput;
|
|
146
|
+
this.onStateChange = options.onStateChange;
|
|
147
|
+
this.color = options.color ?? true;
|
|
148
|
+
this.scriptProbe = options.scriptAvailable ?? (() => defaultScriptAvailable());
|
|
149
|
+
this.shellPath = options.shellPath ?? process.env["SHELL"] ?? "/bin/sh";
|
|
150
|
+
this.shellName = basename(this.shellPath).toLowerCase();
|
|
151
|
+
this.state = {
|
|
152
|
+
running: false,
|
|
153
|
+
cwd: options.cwd,
|
|
154
|
+
lastExitCode: null,
|
|
155
|
+
mode: this.scriptProbe() ? "pty" : "pipe",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
childPid(): number | null {
|
|
160
|
+
return this.child?.pid ?? null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async runCommand(command: string): Promise<PtyRunResult> {
|
|
164
|
+
if (this.disposed) throw new Error("Session disposed");
|
|
165
|
+
if (this.state.running) throw new Error("Command already running");
|
|
166
|
+
|
|
167
|
+
const id = ++this.commandCounter;
|
|
168
|
+
const extension = this.shellName.includes("fish") ? "fish" : "sh";
|
|
169
|
+
const file = join(this.tempDir, `cmd-${id}.${extension}`);
|
|
170
|
+
writeFileSync(file, command.endsWith("\n") ? command : `${command}\n`, "utf8");
|
|
171
|
+
|
|
172
|
+
const done = makeDoneDelimiter();
|
|
173
|
+
const wrapper = buildWrapper(this.shellName, this.state.cwd, file, done);
|
|
174
|
+
const usePty = this.scriptProbe();
|
|
175
|
+
|
|
176
|
+
return new Promise<PtyRunResult>((resolve) => {
|
|
177
|
+
const running: RunningCommand = {
|
|
178
|
+
id,
|
|
179
|
+
done,
|
|
180
|
+
buffer: "",
|
|
181
|
+
escapeTail: "",
|
|
182
|
+
resolve: (result) => {
|
|
183
|
+
if (running.settled) return;
|
|
184
|
+
running.settled = true;
|
|
185
|
+
this.state.running = false;
|
|
186
|
+
this.state.lastExitCode = result.exitCode;
|
|
187
|
+
this.state.cwd = result.cwd;
|
|
188
|
+
this.running = null;
|
|
189
|
+
this.onStateChange();
|
|
190
|
+
rmSync(file, { force: true });
|
|
191
|
+
resolve(result);
|
|
192
|
+
},
|
|
193
|
+
settled: false,
|
|
194
|
+
};
|
|
195
|
+
this.running = running;
|
|
196
|
+
this.state.running = true;
|
|
197
|
+
this.state.mode = usePty ? "pty" : "pipe";
|
|
198
|
+
this.interrupted = false;
|
|
199
|
+
this.onStateChange();
|
|
200
|
+
|
|
201
|
+
const child = usePty
|
|
202
|
+
? spawn("script", ["-qec", wrapper, "/dev/null"], {
|
|
203
|
+
cwd: this.state.cwd,
|
|
204
|
+
env: { ...process.env, SHELL: this.shellPath },
|
|
205
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
206
|
+
detached: true,
|
|
207
|
+
})
|
|
208
|
+
: spawn(this.shellPath, ["-c", buildWrapper(this.shellName, this.state.cwd, file, done)], {
|
|
209
|
+
cwd: this.state.cwd,
|
|
210
|
+
env: process.env,
|
|
211
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
212
|
+
detached: true,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
this.child = child as ChildProcessWithoutNullStreams;
|
|
216
|
+
child.stdout.setEncoding("utf8");
|
|
217
|
+
child.stderr.setEncoding("utf8");
|
|
218
|
+
child.stdin.on("error", () => {
|
|
219
|
+
// EPIPE after the child exits: input loss at teardown is expected.
|
|
220
|
+
// Without this listener a keystroke racing the exit would raise an
|
|
221
|
+
// unhandled error on the stdin stream and take down the host.
|
|
222
|
+
});
|
|
223
|
+
child.stdout.on("data", (chunk: string) => this.handleChunk(String(chunk)));
|
|
224
|
+
child.stderr.on("data", (chunk: string) => this.handleChunk(String(chunk)));
|
|
225
|
+
child.on("error", (error) => {
|
|
226
|
+
// 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.
|
|
230
|
+
console.warn(
|
|
231
|
+
"[wishcraft] spawn failed:",
|
|
232
|
+
error instanceof Error ? error.message : String(error),
|
|
233
|
+
);
|
|
234
|
+
if (usePty) {
|
|
235
|
+
_resetScriptAvailableForTests();
|
|
236
|
+
}
|
|
237
|
+
running.resolve({ exitCode: 1, cwd: this.state.cwd });
|
|
238
|
+
});
|
|
239
|
+
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.
|
|
243
|
+
if (this.interrupted) {
|
|
244
|
+
running.resolve({ exitCode: 130, cwd: this.state.cwd });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
running.resolve({
|
|
248
|
+
exitCode: getCloseExitCode(code, signal),
|
|
249
|
+
cwd: this.state.cwd,
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
writeStdin(data: string): void {
|
|
256
|
+
if (this.child == null || this.child.stdin.destroyed) return;
|
|
257
|
+
this.child.stdin.write(data);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
interrupt(): void {
|
|
261
|
+
if (!this.child || !this.state.running) return;
|
|
262
|
+
this.interrupted = true;
|
|
263
|
+
this.signalGroup("SIGINT");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
dispose(): void {
|
|
267
|
+
this.disposed = true;
|
|
268
|
+
if (this.child) {
|
|
269
|
+
this.signalGroup("SIGKILL");
|
|
270
|
+
this.child = null;
|
|
271
|
+
}
|
|
272
|
+
this.running = null;
|
|
273
|
+
this.state.running = false;
|
|
274
|
+
rmSync(this.tempDir, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private signalGroup(signal: NodeJS.Signals): void {
|
|
278
|
+
const pid = this.child?.pid;
|
|
279
|
+
if (typeof pid !== "number") return;
|
|
280
|
+
try {
|
|
281
|
+
process.kill(-pid, signal);
|
|
282
|
+
} catch {
|
|
283
|
+
try {
|
|
284
|
+
this.child?.kill(signal);
|
|
285
|
+
} catch {
|
|
286
|
+
// Process group and direct kill both failed: nothing left to signal.
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private handleChunk(chunk: string): void {
|
|
292
|
+
const running = this.running;
|
|
293
|
+
if (!running || running.settled) return;
|
|
294
|
+
|
|
295
|
+
const merged = running.escapeTail + chunk;
|
|
296
|
+
running.escapeTail = "";
|
|
297
|
+
|
|
298
|
+
// Carry a trailing partial escape (unterminated CSI/OSC/DCS, or a bare
|
|
299
|
+
// ESC byte) to the next chunk so split sequences cannot leak their
|
|
300
|
+
// introducer into output — a lone ESC followed by e.g. "[31m" on the
|
|
301
|
+
// next chunk must filter as one SGR, not render as plain text.
|
|
302
|
+
let work = merged;
|
|
303
|
+
const partial = /(?:\x1b(?:\[[0-9;?]*[ -/]*|\][^\x07\x1b]*|[P^_][^\x07\x1b]*))$|\x1b$/.exec(work);
|
|
304
|
+
if (partial) {
|
|
305
|
+
running.escapeTail = partial[0];
|
|
306
|
+
work = work.slice(0, work.length - running.escapeTail.length);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const color = this.color && this.state.mode === "pty";
|
|
310
|
+
const filtered = filterPtyOutput(work, { color });
|
|
311
|
+
|
|
312
|
+
running.buffer += filtered;
|
|
313
|
+
const parts = running.buffer.split("\n");
|
|
314
|
+
running.buffer = parts.pop() ?? "";
|
|
315
|
+
|
|
316
|
+
for (const rawLine of parts) {
|
|
317
|
+
const line = rawLine.replace(/\r$/, "");
|
|
318
|
+
if (line.startsWith(`${running.done}:`)) {
|
|
319
|
+
const rest = line.slice(running.done.length + 1);
|
|
320
|
+
// exit code may not contain ':'; cwd may (rare) — split on first two only.
|
|
321
|
+
const firstColon = rest.indexOf(":");
|
|
322
|
+
if (firstColon !== -1) {
|
|
323
|
+
const exitCode = Number.parseInt(rest.slice(0, firstColon), 10);
|
|
324
|
+
const cwd = rest.slice(firstColon + 1);
|
|
325
|
+
running.resolve({
|
|
326
|
+
exitCode: Number.isFinite(exitCode) ? exitCode : 1,
|
|
327
|
+
cwd: cwd || this.state.cwd,
|
|
328
|
+
});
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (line.trim().length > 0) {
|
|
334
|
+
this.onOutput(line);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let scriptAvailableCache: boolean | null = null;
|
|
341
|
+
|
|
342
|
+
export function defaultScriptAvailable(): boolean {
|
|
343
|
+
if (scriptAvailableCache === null) {
|
|
344
|
+
try {
|
|
345
|
+
const probe = spawnSync("script", ["--version"], { stdio: "ignore" });
|
|
346
|
+
scriptAvailableCache = probe.status === 0;
|
|
347
|
+
} catch {
|
|
348
|
+
scriptAvailableCache = false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return scriptAvailableCache;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function _resetScriptAvailableForTests(): void {
|
|
355
|
+
scriptAvailableCache = null;
|
|
356
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ptyshell-managed.ts
|
|
3
|
+
* ------------------------------------------------------------------------
|
|
4
|
+
* Long-lived managed shell session backed by the v2 PTY runner (plan U13).
|
|
5
|
+
*
|
|
6
|
+
* Implements the same public surface as v1 `ManagedShellSession` so the
|
|
7
|
+
* construction site in bash-mode-actions.ts can swap without touching
|
|
8
|
+
* callers: `state`, `ensureReady()`, `runCommand()`, `interrupt()`,
|
|
9
|
+
* `dispose()`, plus `writeStdin()` for editor forward-mode (U4).
|
|
10
|
+
*
|
|
11
|
+
* Every command runs in its own `script(1)` PTY via PtyShellSession, so
|
|
12
|
+
* stdin-reading programs work and SGR color survives into the transcript.
|
|
13
|
+
* `ready` is true from construction — there is no persistent shell process
|
|
14
|
+
* to wait for; the PtyShellSession probes `script(1)` itself and degrades
|
|
15
|
+
* per command to plain pipes when it is missing (KTD2).
|
|
16
|
+
*
|
|
17
|
+
* Divergence from v1 (documented): v1 sourced `initScript` once into a
|
|
18
|
+
* long-lived shell. v2 has no persistent shell, so init runs as a preamble
|
|
19
|
+
* of every command. That keeps env/paths set by init available per command
|
|
20
|
+
* (ponytail: a persistent PTY shell is the upgrade path if init side
|
|
21
|
+
* effects per command ever matter).
|
|
22
|
+
* ------------------------------------------------------------------------
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { basename } from "node:path";
|
|
26
|
+
import type { BashTranscriptStore } from "./transcript.ts";
|
|
27
|
+
import { PtyShellSession } from "./pty-session.ts";
|
|
28
|
+
import type { ShellSessionState } from "./types.ts";
|
|
29
|
+
|
|
30
|
+
export class PtyManagedShellSession {
|
|
31
|
+
readonly state: ShellSessionState;
|
|
32
|
+
private readonly transcript: BashTranscriptStore;
|
|
33
|
+
private readonly onStateChange: () => void;
|
|
34
|
+
private readonly onCommandSuccess: (command: string, cwd: string) => void;
|
|
35
|
+
private readonly initScript: string;
|
|
36
|
+
private readonly pty: PtyShellSession;
|
|
37
|
+
private commandCounter = 0;
|
|
38
|
+
private currentCommandId: string | null = null;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
shellPath: string,
|
|
42
|
+
cwd: string,
|
|
43
|
+
transcript: BashTranscriptStore,
|
|
44
|
+
onStateChange: () => void,
|
|
45
|
+
onCommandSuccess: (command: string, cwd: string) => void,
|
|
46
|
+
initScript: string | null = null,
|
|
47
|
+
scriptAvailable?: () => boolean,
|
|
48
|
+
) {
|
|
49
|
+
this.transcript = transcript;
|
|
50
|
+
this.onStateChange = onStateChange;
|
|
51
|
+
this.onCommandSuccess = onCommandSuccess;
|
|
52
|
+
this.initScript = initScript ?? "";
|
|
53
|
+
const shellName = basename(shellPath).toLowerCase();
|
|
54
|
+
this.state = {
|
|
55
|
+
ready: true,
|
|
56
|
+
running: false,
|
|
57
|
+
shellPath,
|
|
58
|
+
shellName,
|
|
59
|
+
cwd,
|
|
60
|
+
lastExitCode: null,
|
|
61
|
+
};
|
|
62
|
+
this.pty = new PtyShellSession({
|
|
63
|
+
cwd,
|
|
64
|
+
shellPath,
|
|
65
|
+
onOutput: (line) => this.appendToTranscript(line),
|
|
66
|
+
// The managed session notifies every user-visible transition itself;
|
|
67
|
+
// passing the caller's callback through would double-fire renders.
|
|
68
|
+
onStateChange: () => {},
|
|
69
|
+
scriptAvailable,
|
|
70
|
+
color: true,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* v2 sessions support editor forward-mode only when the active transport
|
|
76
|
+
* is a real PTY — the script(1)-missing pipe fallback has no interactive
|
|
77
|
+
* stdin (KTD2), so forwarding would swallow input (CodeRabbit #dJjDJ).
|
|
78
|
+
*/
|
|
79
|
+
supportsForwardMode(): boolean {
|
|
80
|
+
return this.pty.state.mode !== "pipe";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async ensureReady(): Promise<void> {
|
|
84
|
+
// No persistent process; PtyShellSession probes script(1) lazily per
|
|
85
|
+
// command and degrades to pipes when absent (KTD2).
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async runCommand(command: string): Promise<void> {
|
|
89
|
+
if (this.state.running) {
|
|
90
|
+
throw new Error("Shell command already running");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const id = `cmd-${++this.commandCounter}`;
|
|
94
|
+
this.currentCommandId = id;
|
|
95
|
+
this.state.running = true;
|
|
96
|
+
this.transcript.startCommand(id, command, this.state.cwd);
|
|
97
|
+
this.onStateChange();
|
|
98
|
+
try {
|
|
99
|
+
const result = await this.pty.runCommand(
|
|
100
|
+
this.initScript ? `${this.initScript}\n${command}` : command,
|
|
101
|
+
);
|
|
102
|
+
this.state.lastExitCode = result.exitCode;
|
|
103
|
+
this.state.cwd = result.cwd;
|
|
104
|
+
this.transcript.finishCommand(id, result.exitCode);
|
|
105
|
+
if (result.exitCode === 0) {
|
|
106
|
+
// A throwing success callback must not re-finish the already
|
|
107
|
+
// finished record as exit 1 and notify "run failed" for a command
|
|
108
|
+
// that succeeded.
|
|
109
|
+
try {
|
|
110
|
+
this.onCommandSuccess(command, result.cwd);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.warn("[wishcraft] shell command succeeded but history callback failed", error);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const exitCode = 1;
|
|
117
|
+
this.state.lastExitCode = exitCode;
|
|
118
|
+
this.transcript.finishCommand(id, exitCode);
|
|
119
|
+
throw error;
|
|
120
|
+
} finally {
|
|
121
|
+
this.currentCommandId = null;
|
|
122
|
+
this.state.running = false;
|
|
123
|
+
this.onStateChange();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
writeStdin(data: string): void {
|
|
128
|
+
this.pty.writeStdin(data);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interrupt(): void {
|
|
132
|
+
this.pty.interrupt();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
dispose(): void {
|
|
136
|
+
this.pty.dispose();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private appendToTranscript(line: string): void {
|
|
140
|
+
const id = this.currentCommandId;
|
|
141
|
+
if (!id || !this.state.running) return;
|
|
142
|
+
this.transcript.appendOutput(id, line);
|
|
143
|
+
this.onStateChange();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { PtyManagedShellSession } from "./ptyshell-managed.ts";
|
|
2
|
+
import {
|
|
3
|
+
_resetScriptAvailableForTests,
|
|
4
|
+
defaultScriptAvailable,
|
|
5
|
+
} from "./pty-session.ts";
|
|
6
|
+
import type { BashTranscriptStore } from "./transcript.ts";
|
|
7
|
+
|
|
8
|
+
export type SessionPreference = "auto" | "v2";
|
|
9
|
+
|
|
10
|
+
export interface CreateShellSessionOptions {
|
|
11
|
+
cwd: string;
|
|
12
|
+
prefer?: SessionPreference;
|
|
13
|
+
transcript: BashTranscriptStore;
|
|
14
|
+
onStateChange?: () => void;
|
|
15
|
+
onCommandSuccess?: (command: string, cwd: string) => void;
|
|
16
|
+
initScript?: string | null;
|
|
17
|
+
shellPath?: string;
|
|
18
|
+
/** Override the script(1) availability probe (tests). */
|
|
19
|
+
scriptAvailable?: () => boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let warnedScriptMissing = false;
|
|
23
|
+
|
|
24
|
+
export function _resetWarnForTests(): void {
|
|
25
|
+
warnedScriptMissing = false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function warnScriptMissingOnce(): void {
|
|
29
|
+
if (warnedScriptMissing) return;
|
|
30
|
+
warnedScriptMissing = true;
|
|
31
|
+
console.warn(
|
|
32
|
+
"[wishcraft] script(1) unavailable; bash mode v2 runs commands via plain pipes (no color, no interactive stdin) until it is installed",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isPtyPreferred(): boolean {
|
|
37
|
+
return defaultScriptAvailable();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function _resetPtyProbeForTests(): void {
|
|
41
|
+
_resetScriptAvailableForTests();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createShellSession(
|
|
45
|
+
opts: CreateShellSessionOptions,
|
|
46
|
+
): PtyManagedShellSession {
|
|
47
|
+
// transcript is a typed required field (TS enforces it); the factory has
|
|
48
|
+
// no untyped JS consumers.
|
|
49
|
+
const ptyAvailable = opts.scriptAvailable ? opts.scriptAvailable() : isPtyPreferred();
|
|
50
|
+
if (!ptyAvailable) {
|
|
51
|
+
warnScriptMissingOnce();
|
|
52
|
+
}
|
|
53
|
+
// auto and v2 both route through the PTY-backed managed session; when
|
|
54
|
+
// script(1) is absent it degrades per command to plain pipes (KTD2).
|
|
55
|
+
return new PtyManagedShellSession(
|
|
56
|
+
opts.shellPath ?? "/bin/sh",
|
|
57
|
+
opts.cwd,
|
|
58
|
+
opts.transcript,
|
|
59
|
+
opts.onStateChange ?? (() => {}),
|
|
60
|
+
opts.onCommandSuccess ?? (() => {}),
|
|
61
|
+
opts.initScript ?? null,
|
|
62
|
+
opts.scriptAvailable,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript v2 (U4). ANSI-aware append: keep color escapes, drop malformed
|
|
3
|
+
* (dangling) SGR sequences, and truncate to a visible-width cap. Width is
|
|
4
|
+
* measured by the upstream `visibleWidth` helper; raw escape bytes are not
|
|
5
|
+
* counted, so a 10-column cap fits roughly 10 visible glyphs even when the
|
|
6
|
+
* underlying byte stream carries escape sequences.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
function stripMalformedSgr(text: string): string {
|
|
12
|
+
let out = "";
|
|
13
|
+
let i = 0;
|
|
14
|
+
while (i < text.length) {
|
|
15
|
+
if (text[i] === "\x1b" && text[i + 1] === "[") {
|
|
16
|
+
let j = i + 2;
|
|
17
|
+
while (j < text.length && /[0-9;]/.test(text[j] ?? "")) j += 1;
|
|
18
|
+
if (text[j] === "m") {
|
|
19
|
+
out += text.slice(i, j + 1);
|
|
20
|
+
}
|
|
21
|
+
i = j + 1;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
out += text[i];
|
|
25
|
+
i += 1;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function balancedSgr(text: string, maxWidth: number): string {
|
|
31
|
+
const stripped = stripMalformedSgr(text);
|
|
32
|
+
const visible = visibleWidth(stripped);
|
|
33
|
+
if (visible <= maxWidth) return stripped;
|
|
34
|
+
return truncateToVisibleWidth(stripped, maxWidth);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function truncateToVisibleWidth(text: string, maxWidth: number): string {
|
|
38
|
+
let visible = "";
|
|
39
|
+
let width = 0;
|
|
40
|
+
let colorActive = false;
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (i < text.length && width < maxWidth) {
|
|
43
|
+
const codePoint = text.codePointAt(i) ?? 0;
|
|
44
|
+
const ch = String.fromCodePoint(codePoint);
|
|
45
|
+
if (ch === "\x1b" && text[i + 1] === "[") {
|
|
46
|
+
let j = i + 2;
|
|
47
|
+
while (j < text.length && /[0-9;]/.test(text[j] ?? "")) j += 1;
|
|
48
|
+
if (text[j] === "m") {
|
|
49
|
+
const seq = text.slice(i, j + 1);
|
|
50
|
+
// Zero-width: keep the sequence at its original position so text
|
|
51
|
+
// before it stays unstyled; only a reset deactivates the format.
|
|
52
|
+
visible += seq;
|
|
53
|
+
colorActive = seq !== "\x1b[0m";
|
|
54
|
+
}
|
|
55
|
+
i = j + 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
visible += ch;
|
|
59
|
+
// Iterate display units, not UTF-16 code units: wide glyphs advance the
|
|
60
|
+
// cap by their visible width and surrogate pairs are never split.
|
|
61
|
+
width += visibleWidth(ch);
|
|
62
|
+
i += ch.length;
|
|
63
|
+
}
|
|
64
|
+
return colorActive ? `${visible}\x1b[0m` : visible;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function appendColored(
|
|
68
|
+
buf: readonly string[],
|
|
69
|
+
chunk: string,
|
|
70
|
+
maxWidth: number,
|
|
71
|
+
): string[] {
|
|
72
|
+
const normalized = chunk.replace(/\r/g, "");
|
|
73
|
+
const lines = normalized.split("\n");
|
|
74
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
75
|
+
const balanced = lines.map((line) => balancedSgr(line, maxWidth));
|
|
76
|
+
return [...buf, ...balanced];
|
|
77
|
+
}
|
package/bash-mode/transcript.ts
CHANGED
|
@@ -91,6 +91,24 @@ export class BashTranscriptStore {
|
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Shallow tail for hot render paths: last `count` commands with only the
|
|
96
|
+
* last `outputTail` lines each, no deep copy (the always-on powerline
|
|
97
|
+
* reads nothing else). Full snapshots stay on getSnapshot for
|
|
98
|
+
* doctor/export paths.
|
|
99
|
+
*/
|
|
100
|
+
recentCommands(
|
|
101
|
+
count: number,
|
|
102
|
+
outputTail = 6,
|
|
103
|
+
): { commands: BashCommandRecord[]; truncatedCommands: number } {
|
|
104
|
+
return {
|
|
105
|
+
commands: this.commands
|
|
106
|
+
.slice(-count)
|
|
107
|
+
.map((command) => ({ ...command, output: command.output.slice(-outputTail) })),
|
|
108
|
+
truncatedCommands: this.truncatedCommands,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
private enforceLimits(): void {
|
|
95
113
|
while (
|
|
96
114
|
this.commands.length > 1
|