@groeponline/pi-wishcraft 1.4.0 → 1.6.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 +28 -0
- package/CHANGELOG.md +25 -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 +6 -7
- package/src/extension/welcome/welcome-integration.ts +23 -1
- package/src/motion/frames.ts +19 -9
- package/src/render/layout.ts +95 -0
- package/src/render/motion-rail.ts +79 -0
- package/src/render/paint.ts +40 -0
- package/src/render/v2-adapter.ts +68 -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/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,25 @@
|
|
|
1
|
+
/** Pure working-indicator frames shared by status renderers and tests. */
|
|
2
|
+
|
|
3
|
+
export type WorkingIndicatorStyle = "dots" | "pulse" | "bar" | "ascii";
|
|
4
|
+
|
|
5
|
+
const FRAMES: Record<WorkingIndicatorStyle, readonly string[]> = {
|
|
6
|
+
dots: ["· ", "·· ", "···", " ··", " ·", " ··"],
|
|
7
|
+
pulse: ["○", "◌", "●", "◌"],
|
|
8
|
+
bar: ["[= ]", "[== ]", "[===]", "[ ==]", "[ =]"],
|
|
9
|
+
ascii: ["-", "\\", "|", "/"],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function workingIndicatorFrame(
|
|
13
|
+
style: WorkingIndicatorStyle = "dots",
|
|
14
|
+
tick = 0,
|
|
15
|
+
motion: "full" | "reduced" | "functional" | "off" = "full",
|
|
16
|
+
): string {
|
|
17
|
+
const frames = FRAMES[style] ?? FRAMES.dots;
|
|
18
|
+
if (motion === "off" || motion === "reduced") return frames[0]!;
|
|
19
|
+
if (motion === "functional") return frames[tick % 2 === 0 ? 0 : Math.min(1, frames.length - 1)]!;
|
|
20
|
+
return frames[Math.abs(Math.floor(tick)) % frames.length]!;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function workingIndicatorStyles(): WorkingIndicatorStyle[] {
|
|
24
|
+
return Object.keys(FRAMES) as WorkingIndicatorStyle[];
|
|
25
|
+
}
|
|
@@ -19,6 +19,8 @@ export {
|
|
|
19
19
|
} from "./manager.ts";
|
|
20
20
|
|
|
21
21
|
export type { VibeMode } from "./storage.ts";
|
|
22
|
+
export type { WorkingIndicatorStyle } from "./frames.ts";
|
|
23
|
+
export { workingIndicatorFrame, workingIndicatorStyles } from "./frames.ts";
|
|
22
24
|
|
|
23
25
|
export type {
|
|
24
26
|
GenerateVibesResult,
|
|
@@ -9,6 +9,7 @@ import { join, dirname } from "node:path";
|
|
|
9
9
|
import { getAgentPath } from "../paths/agent-dirs.ts";
|
|
10
10
|
|
|
11
11
|
export type VibeMode = "generate" | "file";
|
|
12
|
+
export type WorkingIndicatorStyle = "dots" | "pulse" | "bar" | "ascii";
|
|
12
13
|
|
|
13
14
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
14
15
|
// Constants
|
|
@@ -51,6 +52,7 @@ export interface VibeConfig {
|
|
|
51
52
|
refreshInterval: number; // default: 30000ms (30s)
|
|
52
53
|
promptTemplate: string; // template with {theme}, {task}, {exclude} placeholders
|
|
53
54
|
maxLength: number; // default: 65 chars
|
|
55
|
+
workingIndicatorStyle: WorkingIndicatorStyle;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -180,6 +182,14 @@ export function loadConfig(): VibeConfig {
|
|
|
180
182
|
? Math.max(0, settings.workingVibeRefreshInterval)
|
|
181
183
|
: 30;
|
|
182
184
|
|
|
185
|
+
const rawIndicator = settings.wishcraft && isRecord(settings.wishcraft)
|
|
186
|
+
? settings.wishcraft.workingIndicatorStyle
|
|
187
|
+
: settings.workingIndicatorStyle;
|
|
188
|
+
const workingIndicatorStyle: WorkingIndicatorStyle =
|
|
189
|
+
rawIndicator === "pulse" || rawIndicator === "bar" || rawIndicator === "ascii"
|
|
190
|
+
? rawIndicator
|
|
191
|
+
: "dots";
|
|
192
|
+
|
|
183
193
|
const maxLength =
|
|
184
194
|
typeof settings.workingVibeMaxLength === "number" &&
|
|
185
195
|
Number.isFinite(settings.workingVibeMaxLength)
|
|
@@ -204,6 +214,7 @@ export function loadConfig(): VibeConfig {
|
|
|
204
214
|
? settings.workingVibePrompt
|
|
205
215
|
: DEFAULT_PROMPT,
|
|
206
216
|
maxLength,
|
|
217
|
+
workingIndicatorStyle,
|
|
207
218
|
};
|
|
208
219
|
}
|
|
209
220
|
|
|
@@ -1,294 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { basename, join } from "node:path";
|
|
4
|
-
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
5
|
-
import type { BashTranscriptStore } from "./transcript.ts";
|
|
6
|
-
import type { ShellSessionState } from "./types.ts";
|
|
7
|
-
|
|
8
|
-
const READY_SENTINEL = "__PI_READY__";
|
|
9
|
-
const COMMAND_START_SENTINEL = "__PI_CMD_START__";
|
|
10
|
-
const COMMAND_DONE_SENTINEL = "__PI_CMD_DONE__";
|
|
11
|
-
|
|
12
|
-
function stripAnsi(value: string): string {
|
|
13
|
-
return value
|
|
14
|
-
.replace(/\x1B\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
15
|
-
.replace(/\x1B\][^\u0007]*(?:\u0007|\x1b\\)/g, "")
|
|
16
|
-
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "");
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function quoteShellArg(value: string): string {
|
|
20
|
-
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function getCloseExitCode(code: number | null, signal: NodeJS.Signals | null): number {
|
|
24
|
-
if (typeof code === "number") {
|
|
25
|
-
return code;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (signal === "SIGINT") {
|
|
29
|
-
return 130;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (signal === "SIGTERM") {
|
|
33
|
-
return 143;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
if (signal === "SIGKILL") {
|
|
37
|
-
return 137;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return 1;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function getShellInitScript(shellName: string): string {
|
|
44
|
-
if (shellName.includes("fish")) {
|
|
45
|
-
return `
|
|
46
|
-
function __pi_eval
|
|
47
|
-
set -l __pi_id $argv[1]
|
|
48
|
-
set -l __pi_file $argv[2]
|
|
49
|
-
echo "${COMMAND_START_SENTINEL}:$__pi_id:$PWD"
|
|
50
|
-
source $__pi_file
|
|
51
|
-
set -l __pi_status $status
|
|
52
|
-
rm -f $__pi_file
|
|
53
|
-
echo "${COMMAND_DONE_SENTINEL}:$__pi_id:$__pi_status:$PWD"
|
|
54
|
-
end
|
|
55
|
-
echo "${READY_SENTINEL}:$PWD"
|
|
56
|
-
`;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (shellName.includes("bash")) {
|
|
60
|
-
return `
|
|
61
|
-
__pi_eval() {
|
|
62
|
-
local __pi_id="$1"
|
|
63
|
-
local __pi_file="$2"
|
|
64
|
-
printf '%s:%s:%s\n' '${COMMAND_START_SENTINEL}' "$__pi_id" "$PWD"
|
|
65
|
-
source "$__pi_file"
|
|
66
|
-
local __pi_status=$?
|
|
67
|
-
rm -f "$__pi_file"
|
|
68
|
-
printf '%s:%s:%s:%s\n' '${COMMAND_DONE_SENTINEL}' "$__pi_id" "$__pi_status" "$PWD"
|
|
69
|
-
}
|
|
70
|
-
printf '%s:%s\n' '${READY_SENTINEL}' "$PWD"
|
|
71
|
-
`;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return `
|
|
75
|
-
function __pi_eval() {
|
|
76
|
-
local __pi_id="$1"
|
|
77
|
-
local __pi_file="$2"
|
|
78
|
-
print -r -- "${COMMAND_START_SENTINEL}:$__pi_id:$PWD"
|
|
79
|
-
builtin source "$__pi_file"
|
|
80
|
-
local __pi_status=$?
|
|
81
|
-
rm -f "$__pi_file"
|
|
82
|
-
print -r -- "${COMMAND_DONE_SENTINEL}:$__pi_id:$__pi_status:$PWD"
|
|
83
|
-
}
|
|
84
|
-
print -r -- "${READY_SENTINEL}:$PWD"
|
|
85
|
-
`;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export class ManagedShellSession {
|
|
89
|
-
private readonly shellPath: string;
|
|
90
|
-
private readonly initScript: string | null;
|
|
91
|
-
private readonly transcript: BashTranscriptStore;
|
|
92
|
-
private readonly onStateChange: () => void;
|
|
93
|
-
private readonly onCommandSuccess: (command: string, cwd: string) => void;
|
|
94
|
-
private process: ChildProcessWithoutNullStreams | null = null;
|
|
95
|
-
private readonly tempDir = mkdtempSync(join(tmpdir(), "powerline-bash-mode-"));
|
|
96
|
-
private buffer = "";
|
|
97
|
-
private commandCounter = 0;
|
|
98
|
-
private currentCommandId: string | null = null;
|
|
99
|
-
private readyPromise: Promise<void> | null = null;
|
|
100
|
-
private readyResolve: (() => void) | null = null;
|
|
101
|
-
private readyReject: ((error: Error) => void) | null = null;
|
|
102
|
-
private disposed = false;
|
|
103
|
-
readonly state: ShellSessionState;
|
|
104
|
-
|
|
105
|
-
constructor(
|
|
106
|
-
shellPath: string,
|
|
107
|
-
cwd: string,
|
|
108
|
-
transcript: BashTranscriptStore,
|
|
109
|
-
onStateChange: () => void,
|
|
110
|
-
onCommandSuccess: (command: string, cwd: string) => void,
|
|
111
|
-
initScript: string | null = null,
|
|
112
|
-
) {
|
|
113
|
-
this.shellPath = shellPath;
|
|
114
|
-
this.initScript = initScript;
|
|
115
|
-
this.transcript = transcript;
|
|
116
|
-
this.onStateChange = onStateChange;
|
|
117
|
-
this.onCommandSuccess = onCommandSuccess;
|
|
118
|
-
const shellName = basename(shellPath).toLowerCase();
|
|
119
|
-
this.state = {
|
|
120
|
-
ready: false,
|
|
121
|
-
running: false,
|
|
122
|
-
shellPath,
|
|
123
|
-
shellName,
|
|
124
|
-
cwd,
|
|
125
|
-
lastExitCode: null,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async ensureReady(): Promise<void> {
|
|
130
|
-
if (this.state.ready) return;
|
|
131
|
-
if (this.readyPromise) return this.readyPromise;
|
|
132
|
-
|
|
133
|
-
this.readyPromise = new Promise<void>((resolve, reject) => {
|
|
134
|
-
this.readyResolve = resolve;
|
|
135
|
-
this.readyReject = reject;
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
this.process = spawn(this.shellPath, [], {
|
|
139
|
-
cwd: this.state.cwd,
|
|
140
|
-
env: {
|
|
141
|
-
...process.env,
|
|
142
|
-
DISABLE_AUTO_UPDATE: "true",
|
|
143
|
-
DISABLE_UPDATE_PROMPT: "true",
|
|
144
|
-
},
|
|
145
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
146
|
-
detached: true,
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
this.process.stdout.setEncoding("utf8");
|
|
150
|
-
this.process.stderr.setEncoding("utf8");
|
|
151
|
-
this.process.stdout.on("data", (chunk) => this.handleChunk(String(chunk)));
|
|
152
|
-
this.process.stderr.on("data", (chunk) => this.handleChunk(String(chunk)));
|
|
153
|
-
this.process.on("error", (error) => {
|
|
154
|
-
if (!this.state.ready) {
|
|
155
|
-
this.readyReject?.(error instanceof Error ? error : new Error(String(error)));
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
this.process.on("close", (code, signal) => {
|
|
159
|
-
const exitCode = getCloseExitCode(code, signal);
|
|
160
|
-
if (!this.disposed && !this.state.ready) {
|
|
161
|
-
this.readyReject?.(new Error(`Shell failed to start (exit ${exitCode})`));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (this.currentCommandId) {
|
|
165
|
-
this.transcript.finishCommand(this.currentCommandId, exitCode);
|
|
166
|
-
this.state.lastExitCode = exitCode;
|
|
167
|
-
this.currentCommandId = null;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
this.process = null;
|
|
171
|
-
this.buffer = "";
|
|
172
|
-
this.readyPromise = null;
|
|
173
|
-
this.readyResolve = null;
|
|
174
|
-
this.readyReject = null;
|
|
175
|
-
this.state.ready = false;
|
|
176
|
-
this.state.running = false;
|
|
177
|
-
this.onStateChange();
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
const projectInit = this.initScript
|
|
181
|
-
? this.initScript.endsWith("\n")
|
|
182
|
-
? this.initScript
|
|
183
|
-
: `${this.initScript}\n`
|
|
184
|
-
: "";
|
|
185
|
-
this.sendRaw(projectInit + getShellInitScript(this.state.shellName) + "\n");
|
|
186
|
-
return this.readyPromise;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
async runCommand(command: string): Promise<void> {
|
|
190
|
-
await this.ensureReady();
|
|
191
|
-
if (!this.process) {
|
|
192
|
-
throw new Error("Shell process not available");
|
|
193
|
-
}
|
|
194
|
-
if (this.state.running) {
|
|
195
|
-
throw new Error("Shell command already running");
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const id = `cmd-${++this.commandCounter}`;
|
|
199
|
-
const extension = this.state.shellName.includes("fish") ? "fish" : "sh";
|
|
200
|
-
const filePath = join(this.tempDir, `${id}.${extension}`);
|
|
201
|
-
writeFileSync(filePath, command.endsWith("\n") ? command : `${command}\n`, "utf8");
|
|
202
|
-
|
|
203
|
-
this.currentCommandId = id;
|
|
204
|
-
this.state.running = true;
|
|
205
|
-
this.transcript.startCommand(id, command, this.state.cwd);
|
|
206
|
-
this.onStateChange();
|
|
207
|
-
this.sendRaw(`__pi_eval ${quoteShellArg(id)} ${quoteShellArg(filePath)}\n`);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
interrupt(): void {
|
|
211
|
-
if (!this.process || !this.state.running) return;
|
|
212
|
-
try {
|
|
213
|
-
process.kill(-this.process.pid!, "SIGINT");
|
|
214
|
-
} catch {
|
|
215
|
-
// The shell may not own a process group anymore.
|
|
216
|
-
this.process.kill("SIGINT");
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
dispose(): void {
|
|
221
|
-
this.disposed = true;
|
|
222
|
-
this.readyPromise = null;
|
|
223
|
-
this.readyResolve = null;
|
|
224
|
-
this.readyReject = null;
|
|
225
|
-
if (!this.process) return;
|
|
226
|
-
try {
|
|
227
|
-
process.kill(-this.process.pid!, "SIGKILL");
|
|
228
|
-
} catch {
|
|
229
|
-
// The shell may not own a process group anymore.
|
|
230
|
-
this.process.kill("SIGKILL");
|
|
231
|
-
}
|
|
232
|
-
this.process = null;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
private sendRaw(text: string): void {
|
|
236
|
-
if (!this.process) return;
|
|
237
|
-
this.process.stdin.write(text);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
private handleChunk(chunk: string): void {
|
|
241
|
-
const sanitized = stripAnsi(chunk).replace(/\r/g, "");
|
|
242
|
-
if (!sanitized) return;
|
|
243
|
-
|
|
244
|
-
this.buffer += sanitized;
|
|
245
|
-
const parts = this.buffer.split("\n");
|
|
246
|
-
this.buffer = parts.pop() ?? "";
|
|
247
|
-
|
|
248
|
-
for (const rawLine of parts) {
|
|
249
|
-
const line = rawLine.trimEnd();
|
|
250
|
-
if (!this.state.ready) {
|
|
251
|
-
if (line.startsWith(`${READY_SENTINEL}:`)) {
|
|
252
|
-
this.state.ready = true;
|
|
253
|
-
this.state.cwd = line.slice(READY_SENTINEL.length + 1) || this.state.cwd;
|
|
254
|
-
this.readyResolve?.();
|
|
255
|
-
this.readyResolve = null;
|
|
256
|
-
this.readyReject = null;
|
|
257
|
-
this.onStateChange();
|
|
258
|
-
}
|
|
259
|
-
continue;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
if (line.startsWith(`${COMMAND_START_SENTINEL}:`)) {
|
|
263
|
-
const [, id, cwd] = line.split(":");
|
|
264
|
-
if (cwd) this.state.cwd = cwd;
|
|
265
|
-
this.currentCommandId = id ?? this.currentCommandId;
|
|
266
|
-
this.onStateChange();
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
if (line.startsWith(`${COMMAND_DONE_SENTINEL}:`)) {
|
|
271
|
-
const [, id, exitCodeText, cwd] = line.split(":");
|
|
272
|
-
const exitCode = Number.parseInt(exitCodeText ?? "1", 10);
|
|
273
|
-
this.state.running = false;
|
|
274
|
-
this.state.lastExitCode = Number.isFinite(exitCode) ? exitCode : 1;
|
|
275
|
-
if (cwd) this.state.cwd = cwd;
|
|
276
|
-
if (id) {
|
|
277
|
-
this.transcript.finishCommand(id, this.state.lastExitCode);
|
|
278
|
-
const snapshot = this.transcript.getSnapshot();
|
|
279
|
-
const command = snapshot.commands.find((entry) => entry.id === id);
|
|
280
|
-
if (command && this.state.lastExitCode === 0) {
|
|
281
|
-
this.onCommandSuccess(command.command, this.state.cwd);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
this.currentCommandId = null;
|
|
285
|
-
this.onStateChange();
|
|
286
|
-
continue;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (!this.currentCommandId) continue;
|
|
290
|
-
this.transcript.appendOutput(this.currentCommandId, line);
|
|
291
|
-
this.onStateChange();
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
package/src/signal/render.ts
DELETED
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Three-lane Signal renderer.
|
|
3
|
-
*
|
|
4
|
-
* Left: model/repository state. Center: live semantic activity and motion rail.
|
|
5
|
-
* Right: context/queue/usage. Modules retain the existing per-segment fault
|
|
6
|
-
* isolation because every item still renders through `renderSegment`.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
-
import type {
|
|
11
|
-
PresetDef,
|
|
12
|
-
SegmentContext,
|
|
13
|
-
SignalSpec,
|
|
14
|
-
StatusLineSegmentId,
|
|
15
|
-
StatusLineSeparatorStyle,
|
|
16
|
-
} from "../config/types.ts";
|
|
17
|
-
import { mergeSegmentsWithCustomItems } from "../config/powerline-config.ts";
|
|
18
|
-
import { renderSegment } from "../segments/index.ts";
|
|
19
|
-
import { ansi, colorEnabled, getFgAnsiCode } from "../theme/colors.ts";
|
|
20
|
-
import { getSeparator } from "../theme/separators.ts";
|
|
21
|
-
import { frameAt, getMotion, sweepPosition, trailGlyph } from "../motion/index.ts";
|
|
22
|
-
import type { SignalRuntime } from "./controller.ts";
|
|
23
|
-
import { getContributedSignalSources } from "../extension/contrib/registry.ts";
|
|
24
|
-
|
|
25
|
-
export interface SignalRenderOptions {
|
|
26
|
-
separatorStyle: StatusLineSeparatorStyle;
|
|
27
|
-
signal: SignalSpec;
|
|
28
|
-
ascii?: boolean;
|
|
29
|
-
layout?: import("../config/types.ts").StatusLineLayout | null;
|
|
30
|
-
disabledSegments?: StatusLineSegmentId[];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface Module {
|
|
34
|
-
content: string;
|
|
35
|
-
width: number;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function renderSignal(
|
|
39
|
-
ctx: SegmentContext,
|
|
40
|
-
preset: PresetDef,
|
|
41
|
-
runtime: SignalRuntime,
|
|
42
|
-
availableWidth: number,
|
|
43
|
-
options: SignalRenderOptions,
|
|
44
|
-
): { topContent: string; secondaryContent: string } {
|
|
45
|
-
const merged = mergeSegmentsWithCustomItems(
|
|
46
|
-
preset,
|
|
47
|
-
ctx.effectiveCustomItems,
|
|
48
|
-
{
|
|
49
|
-
layout: options.layout ?? null,
|
|
50
|
-
disabledSegments: options.disabledSegments ?? [],
|
|
51
|
-
},
|
|
52
|
-
);
|
|
53
|
-
const left = renderModules(merged.leftSegments, ctx);
|
|
54
|
-
const right = [
|
|
55
|
-
...renderModules(merged.rightSegments, ctx),
|
|
56
|
-
...renderContributedSources(ctx),
|
|
57
|
-
];
|
|
58
|
-
const secondary = renderModules(merged.secondarySegments, ctx);
|
|
59
|
-
const separator = getSeparator(options.separatorStyle).left;
|
|
60
|
-
|
|
61
|
-
const center = renderActivity(runtime, options.signal, options.ascii);
|
|
62
|
-
const top = fitLanes(left, center, right, separator, availableWidth);
|
|
63
|
-
return {
|
|
64
|
-
topContent: top,
|
|
65
|
-
secondaryContent: joinModules(secondary, separator, availableWidth),
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function renderModules(ids: readonly StatusLineSegmentId[], ctx: SegmentContext): Module[] {
|
|
70
|
-
const modules: Module[] = [];
|
|
71
|
-
for (const id of ids) {
|
|
72
|
-
// renderSegment is the established per-module error boundary.
|
|
73
|
-
const rendered = renderSegment(id, ctx);
|
|
74
|
-
if (!rendered.visible || !rendered.content) continue;
|
|
75
|
-
modules.push({
|
|
76
|
-
content: rendered.content,
|
|
77
|
-
width: visibleWidth(rendered.content),
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
return modules;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function renderActivity(
|
|
84
|
-
runtime: SignalRuntime,
|
|
85
|
-
spec: SignalSpec,
|
|
86
|
-
ascii = false,
|
|
87
|
-
): string {
|
|
88
|
-
const def = getMotion(runtime.motionId) ?? getMotion(spec.animation);
|
|
89
|
-
const glyph = def
|
|
90
|
-
? frameAt(def, runtime.tick, ascii)
|
|
91
|
-
: ascii
|
|
92
|
-
? "*"
|
|
93
|
-
: "◆";
|
|
94
|
-
const label = runtime.activity || "ready";
|
|
95
|
-
const open = spec.caps.leftOpen ?? "";
|
|
96
|
-
const close = spec.caps.leftClose ?? "";
|
|
97
|
-
const dim = getFgAnsiCode("sep");
|
|
98
|
-
const hot = getFgAnsiCode("accent");
|
|
99
|
-
const reset = colorEnabled() ? ansi.reset : "";
|
|
100
|
-
// ponytail: vivid sweep — 12-char rail with travelling head + 6-char trail, not a single glyph
|
|
101
|
-
const RAIL_WIDTH = 12;
|
|
102
|
-
let railInner: string;
|
|
103
|
-
if (!runtime.active) {
|
|
104
|
-
railInner = "━".repeat(RAIL_WIDTH);
|
|
105
|
-
} else {
|
|
106
|
-
const pos = sweepPosition(runtime.tick, RAIL_WIDTH, true);
|
|
107
|
-
let built = "";
|
|
108
|
-
for (let i = 0; i < RAIL_WIDTH; i++) {
|
|
109
|
-
const dist = Math.abs(i - pos);
|
|
110
|
-
if (dist === 0) built += glyph;
|
|
111
|
-
else if (dist <= 6) built += trailGlyph(dist, ascii);
|
|
112
|
-
else built += "━";
|
|
113
|
-
}
|
|
114
|
-
railInner = built;
|
|
115
|
-
}
|
|
116
|
-
const railColor = runtime.active ? hot : dim;
|
|
117
|
-
const rail = `${spec.separators.left}${railInner}${spec.separators.right}`;
|
|
118
|
-
return `${railColor}${open}${rail}${close}${reset} ${label}`;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function fitLanes(
|
|
122
|
-
leftInput: Module[],
|
|
123
|
-
center: string,
|
|
124
|
-
rightInput: Module[],
|
|
125
|
-
separator: string,
|
|
126
|
-
availableWidth: number,
|
|
127
|
-
): string {
|
|
128
|
-
if (availableWidth <= 0) return "";
|
|
129
|
-
const left = [...leftInput];
|
|
130
|
-
const right = [...rightInput];
|
|
131
|
-
const sep = styledSeparator(separator);
|
|
132
|
-
const centerWidth = visibleWidth(center);
|
|
133
|
-
|
|
134
|
-
const lane = (modules: Module[]) => modules.map((module) => module.content).join(sep);
|
|
135
|
-
const totalWidth = () => {
|
|
136
|
-
const leftText = lane(left);
|
|
137
|
-
const rightText = lane(right);
|
|
138
|
-
return (
|
|
139
|
-
visibleWidth(leftText) +
|
|
140
|
-
visibleWidth(rightText) +
|
|
141
|
-
centerWidth +
|
|
142
|
-
(leftText ? 2 : 0) +
|
|
143
|
-
(rightText ? 2 : 0)
|
|
144
|
-
);
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
// Preserve the model/git side first, then context/queue. Remove the least
|
|
148
|
-
// important outer modules until all three lanes fit.
|
|
149
|
-
while (totalWidth() + 2 > availableWidth && right.length > 1) right.shift();
|
|
150
|
-
while (totalWidth() + 2 > availableWidth && left.length > 1) left.pop();
|
|
151
|
-
while (totalWidth() + 2 > availableWidth && right.length > 0) right.shift();
|
|
152
|
-
while (totalWidth() + 2 > availableWidth && left.length > 0) left.pop();
|
|
153
|
-
|
|
154
|
-
const leftText = lane(left);
|
|
155
|
-
const rightText = lane(right);
|
|
156
|
-
if (centerWidth + 2 > availableWidth) {
|
|
157
|
-
return ` ${truncatePlain(center, Math.max(0, availableWidth - 2))} `;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const contentWidth =
|
|
161
|
-
visibleWidth(leftText) +
|
|
162
|
-
visibleWidth(rightText) +
|
|
163
|
-
centerWidth +
|
|
164
|
-
(leftText ? 2 : 0) +
|
|
165
|
-
(rightText ? 2 : 0);
|
|
166
|
-
const flexible = Math.max(0, availableWidth - 2 - contentWidth);
|
|
167
|
-
const before = leftText ? " " : "";
|
|
168
|
-
const after = rightText ? " " : "";
|
|
169
|
-
// Extra space lives between center and right, keeping state metrics pinned.
|
|
170
|
-
return ` ${leftText}${before}${center}${after}${" ".repeat(flexible)}${rightText} `;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function joinModules(modules: Module[], separator: string, width: number): string {
|
|
174
|
-
if (modules.length === 0) return "";
|
|
175
|
-
const sep = styledSeparator(separator);
|
|
176
|
-
const fitted: Module[] = [];
|
|
177
|
-
let used = 2;
|
|
178
|
-
for (const module of modules) {
|
|
179
|
-
const next = module.width + (fitted.length ? visibleWidth(separator) + 2 : 0);
|
|
180
|
-
if (used + next > width) break;
|
|
181
|
-
fitted.push(module);
|
|
182
|
-
used += next;
|
|
183
|
-
}
|
|
184
|
-
return fitted.length ? ` ${fitted.map((module) => module.content).join(sep)} ` : "";
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const ANSI_CONTROL_RE = /\x1B(?:\][^\x07]*(?:\x07|\x1B\\)|\[[0-?]*[ -/]*[@-~]|[@-_])|\x9B[0-?]*[ -/]*[@-~]|\x9D[^\x07]*(?:\x07|\x1B\\)/g;
|
|
188
|
-
|
|
189
|
-
function stripAnsiControls(text: string): string {
|
|
190
|
-
return text.replace(ANSI_CONTROL_RE, "");
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function renderContributedSources(ctx: SegmentContext): Module[] {
|
|
194
|
-
const out: Module[] = [];
|
|
195
|
-
for (const src of getContributedSignalSources()) {
|
|
196
|
-
try {
|
|
197
|
-
const raw = src.render?.(ctx);
|
|
198
|
-
if (!raw || typeof raw !== "string") continue;
|
|
199
|
-
const content = raw.trim();
|
|
200
|
-
if (!stripAnsiControls(content).trim()) continue;
|
|
201
|
-
// ponytail: fault-isolated — a throwing or empty source never takes down Signal
|
|
202
|
-
out.push({ content, width: visibleWidth(content) });
|
|
203
|
-
} catch {
|
|
204
|
-
// skip failing contribution
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return out;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function styledSeparator(separator: string): string {
|
|
211
|
-
const color = getFgAnsiCode("sep");
|
|
212
|
-
const reset = colorEnabled() ? ansi.reset : "";
|
|
213
|
-
return ` ${color}${separator}${reset} `;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function truncatePlain(text: string, width: number): string {
|
|
217
|
-
if (width <= 0) return "";
|
|
218
|
-
// Activity labels are ASCII; strip ANSI before this emergency narrow path.
|
|
219
|
-
const plain = text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
220
|
-
return plain.length <= width ? plain : plain.slice(0, Math.max(0, width - 1)) + "…";
|
|
221
|
-
}
|