pi-cc-extensions 0.1.1

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 (39) hide show
  1. package/README.md +141 -0
  2. package/assets/readme/context-preview.webp +0 -0
  3. package/assets/readme/context-usage.webp +0 -0
  4. package/assets/readme/hero.svg +101 -0
  5. package/assets/readme/session-reference.webp +0 -0
  6. package/assets/readme/showcase.webp +0 -0
  7. package/extensions/README.md +3 -0
  8. package/extensions/ccstyle-powerline/bash-mode/completion.ts +556 -0
  9. package/extensions/ccstyle-powerline/bash-mode/editor.ts +442 -0
  10. package/extensions/ccstyle-powerline/bash-mode/history.ts +151 -0
  11. package/extensions/ccstyle-powerline/bash-mode/shell-session.ts +286 -0
  12. package/extensions/ccstyle-powerline/bash-mode/transcript.ts +108 -0
  13. package/extensions/ccstyle-powerline/bash-mode/types.ts +59 -0
  14. package/extensions/ccstyle-powerline/colors.ts +69 -0
  15. package/extensions/ccstyle-powerline/context-usage.ts +46 -0
  16. package/extensions/ccstyle-powerline/fixed-editor/cluster.ts +107 -0
  17. package/extensions/ccstyle-powerline/fixed-editor/terminal-split.ts +1317 -0
  18. package/extensions/ccstyle-powerline/git-status.ts +218 -0
  19. package/extensions/ccstyle-powerline/icons.ts +181 -0
  20. package/extensions/ccstyle-powerline/powerline-config.ts +234 -0
  21. package/extensions/ccstyle-powerline/powerline.ts +3150 -0
  22. package/extensions/ccstyle-powerline/presets.ts +109 -0
  23. package/extensions/ccstyle-powerline/render-scheduler.ts +24 -0
  24. package/extensions/ccstyle-powerline/segments.ts +476 -0
  25. package/extensions/ccstyle-powerline/separators.ts +41 -0
  26. package/extensions/ccstyle-powerline/shortcuts.ts +57 -0
  27. package/extensions/ccstyle-powerline/theme.json +26 -0
  28. package/extensions/ccstyle-powerline/theme.ts +241 -0
  29. package/extensions/ccstyle-powerline/types.ts +197 -0
  30. package/extensions/ccstyle-powerline/welcome-dismiss.ts +34 -0
  31. package/extensions/ccstyle-powerline/welcome.ts +611 -0
  32. package/extensions/ccstyle-powerline/working-vibes.ts +695 -0
  33. package/extensions/claude-code-style.ts +987 -0
  34. package/extensions/context.ts +362 -0
  35. package/extensions/session-reference/README.md +28 -0
  36. package/extensions/session-reference/core.test.ts +74 -0
  37. package/extensions/session-reference/core.ts +258 -0
  38. package/extensions/session-reference/index.ts +379 -0
  39. package/package.json +33 -0
@@ -0,0 +1,286 @@
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 transcript: BashTranscriptStore;
91
+ private readonly onStateChange: () => void;
92
+ private readonly onCommandSuccess: (command: string, cwd: string) => void;
93
+ private process: ChildProcessWithoutNullStreams | null = null;
94
+ private readonly tempDir = mkdtempSync(join(tmpdir(), "powerline-bash-mode-"));
95
+ private buffer = "";
96
+ private commandCounter = 0;
97
+ private currentCommandId: string | null = null;
98
+ private readyPromise: Promise<void> | null = null;
99
+ private readyResolve: (() => void) | null = null;
100
+ private readyReject: ((error: Error) => void) | null = null;
101
+ private disposed = false;
102
+ readonly state: ShellSessionState;
103
+
104
+ constructor(
105
+ shellPath: string,
106
+ cwd: string,
107
+ transcript: BashTranscriptStore,
108
+ onStateChange: () => void,
109
+ onCommandSuccess: (command: string, cwd: string) => void,
110
+ ) {
111
+ this.shellPath = shellPath;
112
+ this.transcript = transcript;
113
+ this.onStateChange = onStateChange;
114
+ this.onCommandSuccess = onCommandSuccess;
115
+ const shellName = basename(shellPath).toLowerCase();
116
+ this.state = {
117
+ ready: false,
118
+ running: false,
119
+ shellPath,
120
+ shellName,
121
+ cwd,
122
+ lastExitCode: null,
123
+ };
124
+ }
125
+
126
+ async ensureReady(): Promise<void> {
127
+ if (this.state.ready) return;
128
+ if (this.readyPromise) return this.readyPromise;
129
+
130
+ this.readyPromise = new Promise<void>((resolve, reject) => {
131
+ this.readyResolve = resolve;
132
+ this.readyReject = reject;
133
+ });
134
+
135
+ this.process = spawn(this.shellPath, [], {
136
+ cwd: this.state.cwd,
137
+ env: {
138
+ ...process.env,
139
+ DISABLE_AUTO_UPDATE: "true",
140
+ DISABLE_UPDATE_PROMPT: "true",
141
+ },
142
+ stdio: ["pipe", "pipe", "pipe"],
143
+ detached: true,
144
+ });
145
+
146
+ this.process.stdout.setEncoding("utf8");
147
+ this.process.stderr.setEncoding("utf8");
148
+ this.process.stdout.on("data", (chunk) => this.handleChunk(String(chunk)));
149
+ this.process.stderr.on("data", (chunk) => this.handleChunk(String(chunk)));
150
+ this.process.on("error", (error) => {
151
+ if (!this.state.ready) {
152
+ this.readyReject?.(error instanceof Error ? error : new Error(String(error)));
153
+ }
154
+ });
155
+ this.process.on("close", (code, signal) => {
156
+ const exitCode = getCloseExitCode(code, signal);
157
+ if (!this.disposed && !this.state.ready) {
158
+ this.readyReject?.(new Error(`Shell failed to start (exit ${exitCode})`));
159
+ }
160
+
161
+ if (this.currentCommandId) {
162
+ this.transcript.finishCommand(this.currentCommandId, exitCode);
163
+ this.state.lastExitCode = exitCode;
164
+ this.currentCommandId = null;
165
+ }
166
+
167
+ this.process = null;
168
+ this.buffer = "";
169
+ this.readyPromise = null;
170
+ this.readyResolve = null;
171
+ this.readyReject = null;
172
+ this.state.ready = false;
173
+ this.state.running = false;
174
+ this.onStateChange();
175
+ });
176
+
177
+ this.sendRaw(getShellInitScript(this.state.shellName) + "\n");
178
+ return this.readyPromise;
179
+ }
180
+
181
+ async runCommand(command: string): Promise<void> {
182
+ await this.ensureReady();
183
+ if (!this.process) {
184
+ throw new Error("Shell process not available");
185
+ }
186
+ if (this.state.running) {
187
+ throw new Error("Shell command already running");
188
+ }
189
+
190
+ const id = `cmd-${++this.commandCounter}`;
191
+ const extension = this.state.shellName.includes("fish") ? "fish" : "sh";
192
+ const filePath = join(this.tempDir, `${id}.${extension}`);
193
+ writeFileSync(filePath, command.endsWith("\n") ? command : `${command}\n`, "utf8");
194
+
195
+ this.currentCommandId = id;
196
+ this.state.running = true;
197
+ this.transcript.startCommand(id, command, this.state.cwd);
198
+ this.onStateChange();
199
+ this.sendRaw(`__pi_eval ${quoteShellArg(id)} ${quoteShellArg(filePath)}\n`);
200
+ }
201
+
202
+ interrupt(): void {
203
+ if (!this.process || !this.state.running) return;
204
+ try {
205
+ process.kill(-this.process.pid!, "SIGINT");
206
+ } catch {
207
+ // The shell may not own a process group anymore.
208
+ this.process.kill("SIGINT");
209
+ }
210
+ }
211
+
212
+ dispose(): void {
213
+ this.disposed = true;
214
+ this.readyPromise = null;
215
+ this.readyResolve = null;
216
+ this.readyReject = null;
217
+ if (!this.process) return;
218
+ try {
219
+ process.kill(-this.process.pid!, "SIGKILL");
220
+ } catch {
221
+ // The shell may not own a process group anymore.
222
+ this.process.kill("SIGKILL");
223
+ }
224
+ this.process = null;
225
+ }
226
+
227
+ private sendRaw(text: string): void {
228
+ if (!this.process) return;
229
+ this.process.stdin.write(text);
230
+ }
231
+
232
+ private handleChunk(chunk: string): void {
233
+ const sanitized = stripAnsi(chunk).replace(/\r/g, "");
234
+ if (!sanitized) return;
235
+
236
+ this.buffer += sanitized;
237
+ const parts = this.buffer.split("\n");
238
+ this.buffer = parts.pop() ?? "";
239
+
240
+ for (const rawLine of parts) {
241
+ const line = rawLine.trimEnd();
242
+ if (!this.state.ready) {
243
+ if (line.startsWith(`${READY_SENTINEL}:`)) {
244
+ this.state.ready = true;
245
+ this.state.cwd = line.slice(READY_SENTINEL.length + 1) || this.state.cwd;
246
+ this.readyResolve?.();
247
+ this.readyResolve = null;
248
+ this.readyReject = null;
249
+ this.onStateChange();
250
+ }
251
+ continue;
252
+ }
253
+
254
+ if (line.startsWith(`${COMMAND_START_SENTINEL}:`)) {
255
+ const [, id, cwd] = line.split(":");
256
+ if (cwd) this.state.cwd = cwd;
257
+ this.currentCommandId = id ?? this.currentCommandId;
258
+ this.onStateChange();
259
+ continue;
260
+ }
261
+
262
+ if (line.startsWith(`${COMMAND_DONE_SENTINEL}:`)) {
263
+ const [, id, exitCodeText, cwd] = line.split(":");
264
+ const exitCode = Number.parseInt(exitCodeText ?? "1", 10);
265
+ this.state.running = false;
266
+ this.state.lastExitCode = Number.isFinite(exitCode) ? exitCode : 1;
267
+ if (cwd) this.state.cwd = cwd;
268
+ if (id) {
269
+ this.transcript.finishCommand(id, this.state.lastExitCode);
270
+ const snapshot = this.transcript.getSnapshot();
271
+ const command = snapshot.commands.find((entry) => entry.id === id);
272
+ if (command && this.state.lastExitCode === 0) {
273
+ this.onCommandSuccess(command.command, this.state.cwd);
274
+ }
275
+ }
276
+ this.currentCommandId = null;
277
+ this.onStateChange();
278
+ continue;
279
+ }
280
+
281
+ if (!this.currentCommandId) continue;
282
+ this.transcript.appendOutput(this.currentCommandId, line);
283
+ this.onStateChange();
284
+ }
285
+ }
286
+ }
@@ -0,0 +1,108 @@
1
+ import type { BashCommandRecord, BashModeSettings, BashTranscriptSnapshot } from "./types.ts";
2
+
3
+ function byteLength(value: string): number {
4
+ return Buffer.byteLength(value, "utf8");
5
+ }
6
+
7
+ function compactLines(lines: string[]): string[] {
8
+ const normalized: string[] = [];
9
+ for (const line of lines) {
10
+ const sanitized = line.replace(/\r/g, "");
11
+ if (sanitized.length === 0) {
12
+ normalized.push("");
13
+ continue;
14
+ }
15
+ normalized.push(...sanitized.split("\n"));
16
+ }
17
+ return normalized;
18
+ }
19
+
20
+ export class BashTranscriptStore {
21
+ private readonly settings: Pick<BashModeSettings, "transcriptMaxLines" | "transcriptMaxBytes">;
22
+ private commands: BashCommandRecord[] = [];
23
+ private commandIndex = new Map<string, BashCommandRecord>();
24
+ private totalLines = 0;
25
+ private totalBytes = 0;
26
+ private truncatedCommands = 0;
27
+
28
+ constructor(settings: Pick<BashModeSettings, "transcriptMaxLines" | "transcriptMaxBytes">) {
29
+ this.settings = settings;
30
+ }
31
+
32
+ startCommand(id: string, command: string, cwdAtStart: string): BashCommandRecord {
33
+ const entry: BashCommandRecord = {
34
+ id,
35
+ command,
36
+ cwdAtStart,
37
+ startedAt: Date.now(),
38
+ output: [],
39
+ outputBytes: 0,
40
+ exitCode: null,
41
+ finishedAt: null,
42
+ truncated: false,
43
+ };
44
+
45
+ this.commands.push(entry);
46
+ this.commandIndex.set(id, entry);
47
+ this.enforceLimits();
48
+ return entry;
49
+ }
50
+
51
+ appendOutput(id: string, chunk: string): void {
52
+ const entry = this.commandIndex.get(id);
53
+ if (!entry || !chunk) return;
54
+
55
+ const lines = compactLines([chunk]);
56
+ if (lines.length === 0) return;
57
+
58
+ entry.output.push(...lines);
59
+ const addedBytes = lines.reduce((sum, line) => sum + byteLength(line) + 1, 0);
60
+ entry.outputBytes += addedBytes;
61
+ this.totalLines += lines.length;
62
+ this.totalBytes += addedBytes;
63
+ this.enforceLimits();
64
+ }
65
+
66
+ finishCommand(id: string, exitCode: number): void {
67
+ const entry = this.commandIndex.get(id);
68
+ if (!entry) return;
69
+
70
+ entry.exitCode = exitCode;
71
+ entry.finishedAt = Date.now();
72
+ }
73
+
74
+ clear(): void {
75
+ this.commands = [];
76
+ this.commandIndex.clear();
77
+ this.totalLines = 0;
78
+ this.totalBytes = 0;
79
+ this.truncatedCommands = 0;
80
+ }
81
+
82
+ getSnapshot(): BashTranscriptSnapshot {
83
+ return {
84
+ commands: this.commands.map((command) => ({
85
+ ...command,
86
+ output: [...command.output],
87
+ })),
88
+ totalLines: this.totalLines,
89
+ totalBytes: this.totalBytes,
90
+ truncatedCommands: this.truncatedCommands,
91
+ };
92
+ }
93
+
94
+ private enforceLimits(): void {
95
+ while (
96
+ this.commands.length > 1
97
+ && (this.totalLines > this.settings.transcriptMaxLines || this.totalBytes > this.settings.transcriptMaxBytes)
98
+ ) {
99
+ const removed = this.commands.shift();
100
+ if (!removed) break;
101
+ this.commandIndex.delete(removed.id);
102
+ this.totalLines = Math.max(0, this.totalLines - removed.output.length);
103
+ this.totalBytes = Math.max(0, this.totalBytes - removed.outputBytes);
104
+ this.truncatedCommands += 1;
105
+ removed.truncated = true;
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,59 @@
1
+ export interface BashModeSettings {
2
+ toggleShortcut: string;
3
+ transcriptMaxLines: number;
4
+ transcriptMaxBytes: number;
5
+ }
6
+
7
+ export interface BashCommandRecord {
8
+ id: string;
9
+ command: string;
10
+ startedAt: number;
11
+ cwdAtStart: string;
12
+ output: string[];
13
+ outputBytes: number;
14
+ exitCode: number | null;
15
+ finishedAt: number | null;
16
+ truncated: boolean;
17
+ }
18
+
19
+ export interface BashTranscriptSnapshot {
20
+ commands: BashCommandRecord[];
21
+ totalLines: number;
22
+ totalBytes: number;
23
+ truncatedCommands: number;
24
+ }
25
+
26
+ export interface GhostSuggestion {
27
+ value: string;
28
+ source:
29
+ | "project-history"
30
+ | "global-history"
31
+ | "git"
32
+ | "path"
33
+ | "executable";
34
+ }
35
+
36
+ export interface ExtendedCompletionItem {
37
+ value: string;
38
+ label: string;
39
+ description?: string;
40
+ replacement: string;
41
+ startCol: number;
42
+ endCol: number;
43
+ source:
44
+ | "project-history"
45
+ | "global-history"
46
+ | "git"
47
+ | "path"
48
+ | "executable";
49
+ score: number;
50
+ }
51
+
52
+ export interface ShellSessionState {
53
+ ready: boolean;
54
+ running: boolean;
55
+ shellPath: string;
56
+ shellName: string;
57
+ cwd: string;
58
+ lastExitCode: number | null;
59
+ }
@@ -0,0 +1,69 @@
1
+ // ANSI escape codes for colors
2
+ // VS Code Modern Dark-inspired colors for the startup/editor chrome
3
+
4
+ export interface AnsiColors {
5
+ getBgAnsi(r: number, g: number, b: number): string;
6
+ getFgAnsi(r: number, g: number, b: number): string;
7
+ getFgAnsi256(code: number): string;
8
+ reset: string;
9
+ }
10
+
11
+ export const ansi: AnsiColors = {
12
+ getBgAnsi: (r, g, b) => `\x1b[48;2;${r};${g};${b}m`,
13
+ getFgAnsi: (r, g, b) => `\x1b[38;2;${r};${g};${b}m`,
14
+ getFgAnsi256: (code) => `\x1b[38;5;${code}m`,
15
+ reset: "\x1b[0m",
16
+ };
17
+
18
+ // Convert hex to RGB tuple
19
+ function hexToRgb(hex: string): [number, number, number] {
20
+ const h = hex.replace("#", "");
21
+ return [
22
+ parseInt(h.slice(0, 2), 16),
23
+ parseInt(h.slice(2, 4), 16),
24
+ parseInt(h.slice(4, 6), 16),
25
+ ];
26
+ }
27
+
28
+ // Colors used by welcome/editor chrome rendering
29
+ const THEME = {
30
+ sep: "#6a6a6a", // Muted editor chrome
31
+ model: "#3794ff", // VS Code Modern Dark accent blue
32
+ path: "#4ec9b0", // Type/interface teal
33
+ gitClean: "#89d185", // Modern Dark success green
34
+ accent: "#3794ff", // VS Code focus blue
35
+ };
36
+
37
+ // Color name to ANSI code mapping
38
+ type ColorName = "sep" | "model" | "path" | "gitClean" | "accent";
39
+
40
+ function getAnsiCode(color: ColorName): string {
41
+ const value = THEME[color as keyof typeof THEME];
42
+
43
+ if (value === undefined || value === "") {
44
+ return ""; // No color, use terminal default
45
+ }
46
+
47
+ if (typeof value === "number") {
48
+ return ansi.getFgAnsi256(value);
49
+ }
50
+
51
+ if (typeof value === "string" && value.startsWith("#")) {
52
+ const [r, g, b] = hexToRgb(value);
53
+ return ansi.getFgAnsi(r, g, b);
54
+ }
55
+
56
+ return "";
57
+ }
58
+
59
+ // Helper to apply foreground color only (no reset - caller manages reset)
60
+ export function fgOnly(color: ColorName, text: string): string {
61
+ const code = getAnsiCode(color);
62
+ return code ? `${code}${text}` : text;
63
+ }
64
+
65
+ // Get raw ANSI code for a color
66
+ export function getFgAnsiCode(color: ColorName): string {
67
+ return getAnsiCode(color);
68
+ }
69
+
@@ -0,0 +1,46 @@
1
+ interface CoreContextUsage {
2
+ contextTokens: number | null;
3
+ contextWindow: number;
4
+ contextPercent: number | null;
5
+ }
6
+
7
+ function isRecord(value: unknown): value is Record<string, unknown> {
8
+ return typeof value === "object" && value !== null;
9
+ }
10
+
11
+ export function readCoreContextUsage(ctx: unknown): CoreContextUsage | null {
12
+ if (!isRecord(ctx) || typeof ctx.getContextUsage !== "function") {
13
+ return null;
14
+ }
15
+
16
+ const usage = ctx.getContextUsage();
17
+ if (!isRecord(usage)) {
18
+ return null;
19
+ }
20
+
21
+ const tokens = usage.tokens;
22
+ const contextWindow = usage.contextWindow;
23
+ if (
24
+ (tokens !== null && (typeof tokens !== "number" || !Number.isFinite(tokens)))
25
+ || typeof contextWindow !== "number"
26
+ || !Number.isFinite(contextWindow)
27
+ || contextWindow <= 0
28
+ ) {
29
+ return null;
30
+ }
31
+
32
+ // pi intentionally reports null immediately after compaction. Preserve that
33
+ // state instead of falling back to the last pre-compaction assistant usage.
34
+ if (tokens === null) {
35
+ return { contextTokens: null, contextWindow, contextPercent: null };
36
+ }
37
+
38
+ const percent = usage.percent;
39
+ return {
40
+ contextTokens: tokens,
41
+ contextWindow,
42
+ contextPercent: typeof percent === "number" && Number.isFinite(percent)
43
+ ? percent
44
+ : (tokens / contextWindow) * 100,
45
+ };
46
+ }
@@ -0,0 +1,107 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ export const CURSOR_MARKER = "\x1b_pi:c\x07";
4
+
5
+ export interface FixedEditorClusterInput {
6
+ width: number;
7
+ terminalRows: number;
8
+ statusLines?: string[];
9
+ topLines?: string[];
10
+ editorLines: string[];
11
+ secondaryLines?: string[];
12
+ transcriptLines?: string[];
13
+ }
14
+
15
+ export interface FixedEditorCursor {
16
+ row: number;
17
+ col: number;
18
+ }
19
+
20
+ export interface FixedEditorClusterRender {
21
+ lines: string[];
22
+ cursor: FixedEditorCursor | null;
23
+ }
24
+
25
+ function normalizeLines(lines: string[] | undefined, width: number): string[] {
26
+ if (!lines || width <= 0) return [];
27
+
28
+ return lines
29
+ .filter((line) => line !== undefined && line !== null)
30
+ .map((line) => visibleWidth(line) > width ? truncateToWidth(line, width, "", true) : line);
31
+ }
32
+
33
+ function takeTail(lines: string[], count: number): string[] {
34
+ if (count <= 0) return [];
35
+ return lines.length <= count ? lines : lines.slice(lines.length - count);
36
+ }
37
+
38
+ function capEditorLines(lines: string[], count: number): string[] {
39
+ if (count <= 0) return [];
40
+ if (lines.length <= count) return lines;
41
+
42
+ const cursorRow = lines.findIndex((line) => line.includes(CURSOR_MARKER));
43
+ if (cursorRow !== -1) {
44
+ const start = Math.max(0, Math.min(cursorRow - count + 1, lines.length - count));
45
+ return lines.slice(start, start + count);
46
+ }
47
+
48
+ const selectedRow = lines.findIndex((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimStart().startsWith("→ "));
49
+ if (selectedRow === -1) {
50
+ return lines.slice(0, count);
51
+ }
52
+
53
+ const start = Math.max(0, Math.min(selectedRow - Math.floor(count / 2), lines.length - count));
54
+ return lines.slice(start, start + count);
55
+ }
56
+
57
+ function extractCursor(lines: string[]): FixedEditorClusterRender {
58
+ let cursor: FixedEditorCursor | null = null;
59
+ const cleaned = lines.map((line, row) => {
60
+ const markerIndex = line.indexOf(CURSOR_MARKER);
61
+ if (markerIndex === -1) return line;
62
+
63
+ if (!cursor) {
64
+ cursor = {
65
+ row,
66
+ col: visibleWidth(line.slice(0, markerIndex)),
67
+ };
68
+ }
69
+
70
+ return line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
71
+ });
72
+
73
+ return { lines: cleaned, cursor };
74
+ }
75
+
76
+ export function renderFixedEditorCluster(input: FixedEditorClusterInput): FixedEditorClusterRender {
77
+ const width = Math.max(1, input.width);
78
+ const maxRows = Math.max(1, input.terminalRows - 1);
79
+
80
+ const statusLines = normalizeLines(input.statusLines, width);
81
+ const topLines = normalizeLines(input.topLines, width);
82
+ const editorSource = normalizeLines(input.editorLines, width);
83
+ const secondaryLines = normalizeLines(input.secondaryLines, width);
84
+ const transcriptLines = normalizeLines(input.transcriptLines, width);
85
+
86
+ const editorLines = capEditorLines(editorSource, maxRows);
87
+ let remaining = maxRows - editorLines.length;
88
+
89
+ const top = takeTail(topLines, remaining);
90
+ remaining -= top.length;
91
+
92
+ const secondary = takeTail(secondaryLines, remaining);
93
+ remaining -= secondary.length;
94
+
95
+ const status = takeTail(statusLines, remaining);
96
+ remaining -= status.length;
97
+
98
+ const transcript = takeTail(transcriptLines, remaining);
99
+
100
+ return extractCursor([
101
+ ...status,
102
+ ...top,
103
+ ...editorLines,
104
+ ...secondary,
105
+ ...transcript,
106
+ ]);
107
+ }