jules-orchestrator-kit 0.52.8 → 0.53.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/README.md +66 -7
- package/bin/agentctl.mjs +172 -1
- package/bin/init.js +87 -74
- package/index.mjs +10 -0
- package/package.json +5 -2
- package/scripts/jules-create.mjs +5 -16
- package/scripts/jules-patch.mjs +18 -10
- package/scripts/stale-base-check.mjs +1 -1
- package/src/ci-templates.mjs +199 -0
- package/src/config.mjs +39 -11
- package/src/engine.mjs +2 -21
- package/src/env-aliases.mjs +80 -0
- package/src/mcp.mjs +19 -1
- package/src/ops/doctor-registry.mjs +53 -32
- package/src/ops/next-step.mjs +38 -7
- package/src/profiles-io.mjs +77 -0
- package/src/profiles.mjs +188 -0
- package/src/provider-readiness.mjs +215 -0
- package/src/security.mjs +42 -1
- package/src/tui.mjs +14 -3
- package/src/wizard-init.mjs +32 -4
- package/src/ops/doctor-planner.mjs +0 -220
- package/src/ops/receipts.mjs +0 -171
- package/src/ops/swarm-actions.mjs +0 -156
- package/src/ops/task-actions.mjs +0 -214
- package/src/ops/transaction.mjs +0 -278
- package/src/ux/capabilities.mjs +0 -180
- package/src/ux/diff-viewer.mjs +0 -289
- package/src/ux/layout.mjs +0 -195
- package/src/ux/log-viewer.mjs +0 -110
- package/src/ux/palette.mjs +0 -179
- package/src/ux/queue-model.mjs +0 -206
- package/src/ux/renderer.mjs +0 -188
- package/src/ux/swarm-model.mjs +0 -146
- package/src/ux/terminal-session.mjs +0 -215
- package/src/ux/widgets.mjs +0 -323
- /package/src/{ux/key-decoder.mjs → key-decoder.mjs} +0 -0
package/src/ux/palette.mjs
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import { listCommandsByCategory } from "../ops/command-registry.mjs";
|
|
2
|
-
import { clipText } from "./layout.mjs";
|
|
3
|
-
import { getSymbols } from "./capabilities.mjs";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* @typedef {import("../ops/command-registry.mjs").CommandDescriptor} CommandDescriptor
|
|
7
|
-
* @typedef {import("../ops/command-registry.mjs").CommandCategory} CommandCategory
|
|
8
|
-
* @typedef {import("./capabilities.mjs").TerminalCapabilities} TerminalCapabilities
|
|
9
|
-
* @typedef {import("./renderer.mjs").RenderFrame} RenderFrame
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Create command palette state manager.
|
|
14
|
-
* @param {Object} [opts]
|
|
15
|
-
* @param {CommandCategory} [opts.initialCategory]
|
|
16
|
-
*/
|
|
17
|
-
export function createPaletteState(opts = {}) {
|
|
18
|
-
let filter = "";
|
|
19
|
-
let category = opts.initialCategory || null;
|
|
20
|
-
let selectedIndex = 0;
|
|
21
|
-
|
|
22
|
-
function getFilteredCommands() {
|
|
23
|
-
let pool = listCommandsByCategory(category || undefined);
|
|
24
|
-
if (!filter || !filter.trim()) return pool;
|
|
25
|
-
|
|
26
|
-
const tokens = filter.trim().toLowerCase().split(/\s+/);
|
|
27
|
-
return pool.filter((cmd) => {
|
|
28
|
-
const text = `${cmd.id} ${cmd.title} ${cmd.description} ${cmd.category} ${cmd.shortcuts.join(" ")}`.toLowerCase();
|
|
29
|
-
return tokens.every((token) => text.includes(token));
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function clampSelection() {
|
|
34
|
-
const list = getFilteredCommands();
|
|
35
|
-
if (list.length === 0) {
|
|
36
|
-
selectedIndex = 0;
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
if (selectedIndex < 0) selectedIndex = 0;
|
|
40
|
-
if (selectedIndex >= list.length) selectedIndex = list.length - 1;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
clampSelection();
|
|
44
|
-
|
|
45
|
-
return {
|
|
46
|
-
get filter() {
|
|
47
|
-
return filter;
|
|
48
|
-
},
|
|
49
|
-
set filter(val) {
|
|
50
|
-
filter = String(val);
|
|
51
|
-
selectedIndex = 0;
|
|
52
|
-
clampSelection();
|
|
53
|
-
},
|
|
54
|
-
get category() {
|
|
55
|
-
return category;
|
|
56
|
-
},
|
|
57
|
-
set category(val) {
|
|
58
|
-
category = val;
|
|
59
|
-
selectedIndex = 0;
|
|
60
|
-
clampSelection();
|
|
61
|
-
},
|
|
62
|
-
get selectedIndex() {
|
|
63
|
-
return selectedIndex;
|
|
64
|
-
},
|
|
65
|
-
|
|
66
|
-
move(delta) {
|
|
67
|
-
const list = getFilteredCommands();
|
|
68
|
-
if (list.length === 0) return;
|
|
69
|
-
selectedIndex += delta;
|
|
70
|
-
clampSelection();
|
|
71
|
-
},
|
|
72
|
-
|
|
73
|
-
nextCategory() {
|
|
74
|
-
const categories = [null, "Create", "Inspect", "Operate", "Repair", "Configure"];
|
|
75
|
-
const currentIdx = categories.indexOf(category);
|
|
76
|
-
const nextIdx = (currentIdx + 1) % categories.length;
|
|
77
|
-
this.category = categories[nextIdx];
|
|
78
|
-
},
|
|
79
|
-
|
|
80
|
-
getSelectedCommand() {
|
|
81
|
-
const list = getFilteredCommands();
|
|
82
|
-
return list[selectedIndex] || null;
|
|
83
|
-
},
|
|
84
|
-
|
|
85
|
-
getFilteredCommands,
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Render native command palette view frame.
|
|
91
|
-
* @param {ReturnType<typeof createPaletteState>} state
|
|
92
|
-
* @param {TerminalCapabilities} caps
|
|
93
|
-
* @returns {RenderFrame}
|
|
94
|
-
*/
|
|
95
|
-
export function renderPaletteView(state, caps) {
|
|
96
|
-
const width = caps.columns || 80;
|
|
97
|
-
const height = caps.rows || 24;
|
|
98
|
-
const symbols = getSymbols(caps);
|
|
99
|
-
|
|
100
|
-
const lines = [];
|
|
101
|
-
|
|
102
|
-
// Header line
|
|
103
|
-
const titleStr = " agentctl · Command Palette ";
|
|
104
|
-
lines.push({
|
|
105
|
-
runs: [{ text: clipText(titleStr, width), style: { fg: "cyan", bold: true, inverse: true } }],
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// Filter input line
|
|
109
|
-
const promptStr = ` > ${state.filter}${symbols.bullet}`;
|
|
110
|
-
lines.push({
|
|
111
|
-
runs: [{ text: clipText(promptStr, width), style: { fg: "yellow" } }],
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// Category tabs line
|
|
115
|
-
const categories = [
|
|
116
|
-
{ label: "All", value: null },
|
|
117
|
-
{ label: "Create", value: "Create" },
|
|
118
|
-
{ label: "Inspect", value: "Inspect" },
|
|
119
|
-
{ label: "Operate", value: "Operate" },
|
|
120
|
-
{ label: "Repair", value: "Repair" },
|
|
121
|
-
{ label: "Configure", value: "Configure" },
|
|
122
|
-
];
|
|
123
|
-
|
|
124
|
-
const tabRuns = [];
|
|
125
|
-
for (const cat of categories) {
|
|
126
|
-
const isSelected = state.category === cat.value;
|
|
127
|
-
const label = `[${cat.label}] `;
|
|
128
|
-
if (isSelected) {
|
|
129
|
-
tabRuns.push({ text: label, style: { fg: "cyan", bold: true, underline: true } });
|
|
130
|
-
} else {
|
|
131
|
-
tabRuns.push({ text: label, style: { fg: "white", dim: true } });
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
lines.push({ runs: tabRuns });
|
|
135
|
-
|
|
136
|
-
// Command items body
|
|
137
|
-
const filtered = state.getFilteredCommands();
|
|
138
|
-
const maxBodyLines = height - 5;
|
|
139
|
-
|
|
140
|
-
if (filtered.length === 0) {
|
|
141
|
-
lines.push({
|
|
142
|
-
runs: [{ text: " No matching commands found.", style: { fg: "yellow", dim: true } }],
|
|
143
|
-
});
|
|
144
|
-
} else {
|
|
145
|
-
for (let i = 0; i < Math.min(filtered.length, maxBodyLines); i++) {
|
|
146
|
-
const cmd = filtered[i];
|
|
147
|
-
const isSelected = i === state.selectedIndex;
|
|
148
|
-
|
|
149
|
-
const pointer = isSelected ? `${symbols.select} ` : " ";
|
|
150
|
-
const catBadge = `[${cmd.category.padEnd(9)}] `;
|
|
151
|
-
const cmdTitle = cmd.title.padEnd(16);
|
|
152
|
-
const desc = cmd.description;
|
|
153
|
-
|
|
154
|
-
const lineText = `${pointer}${catBadge}${cmdTitle} ${desc}`;
|
|
155
|
-
const style = isSelected ? { fg: "cyan", bold: true } : { fg: "default" };
|
|
156
|
-
|
|
157
|
-
lines.push({
|
|
158
|
-
runs: [{ text: clipText(lineText, width), style }],
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Pad remaining height
|
|
164
|
-
while (lines.length < height - 1) {
|
|
165
|
-
lines.push({ runs: [{ text: " ".repeat(width), style: { fg: "default" } }] });
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Footer status bar line
|
|
169
|
-
lines.push({
|
|
170
|
-
runs: [
|
|
171
|
-
{
|
|
172
|
-
text: clipText("Up/Down: move Enter: select Tab: category Esc: quit", width),
|
|
173
|
-
style: { fg: "cyan", dim: true },
|
|
174
|
-
},
|
|
175
|
-
],
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
return { width, height, lines };
|
|
179
|
-
}
|
package/src/ux/queue-model.mjs
DELETED
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
|
-
import { parseEnvelopeHeader } from "../envelope.mjs";
|
|
5
|
-
import { isPidAlive } from "../state.mjs";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* @typedef {"pending" | "dispatching" | "remote-queued" | "planning" | "awaiting-plan-approval" | "awaiting-user-feedback" | "running" | "output-ready" | "verifying" | "deferred" | "quarantined" | "failed" | "completed" | "cancelled"} TaskState
|
|
9
|
-
*
|
|
10
|
-
* @typedef {Object} QueueTaskSummary
|
|
11
|
-
* @property {string} id
|
|
12
|
-
* @property {string} title
|
|
13
|
-
* @property {TaskState} state
|
|
14
|
-
* @property {string} riskTier
|
|
15
|
-
* @property {number} attempt
|
|
16
|
-
* @property {number} maxAttempts
|
|
17
|
-
* @property {string} createdAt
|
|
18
|
-
* @property {string} updatedAt
|
|
19
|
-
* @property {number} durationMs
|
|
20
|
-
* @property {string} [source]
|
|
21
|
-
* @property {string} [startingBranch]
|
|
22
|
-
* @property {boolean} autoPr
|
|
23
|
-
* @property {boolean} requirePlanApproval
|
|
24
|
-
* @property {string} [verifyCmd]
|
|
25
|
-
* @property {string} [slotId]
|
|
26
|
-
* @property {number} [pid]
|
|
27
|
-
* @property {string} [worktree]
|
|
28
|
-
* @property {string} [errorSummary]
|
|
29
|
-
* @property {string} envelopeHash
|
|
30
|
-
* @property {string | null} stateHash
|
|
31
|
-
* @property {number} stateRevision
|
|
32
|
-
* @property {string} taskFile
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
function isQueueTaskFile(fileName, content = "") {
|
|
36
|
-
if (!fileName || fileName.toLowerCase() === "readme.md" || !fileName.toLowerCase().endsWith(".md")) {
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
|
-
if (/^task-.*\.md$/i.test(fileName)) return true;
|
|
40
|
-
return Boolean(parseEnvelopeHeader(content) || content.includes("# Task ID:"));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* @typedef {Object} QueueSnapshot
|
|
45
|
-
* @property {"agentctl/queue-snapshot-v1"} schema
|
|
46
|
-
* @property {string} repository
|
|
47
|
-
* @property {string} generatedAt
|
|
48
|
-
* @property {string} revision
|
|
49
|
-
* @property {Record<TaskState, number>} counts
|
|
50
|
-
* @property {QueueTaskSummary[]} tasks
|
|
51
|
-
* @property {string[]} warnings
|
|
52
|
-
*/
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Build immutable QueueSnapshot by reading canonical .agent/jules-queue/ tasks and sidecars.
|
|
56
|
-
* @param {string} root
|
|
57
|
-
* @param {Object} [options]
|
|
58
|
-
* @param {boolean} [options.includeCompleted=true]
|
|
59
|
-
* @param {number} [options.limit=100]
|
|
60
|
-
* @returns {Promise<QueueSnapshot>}
|
|
61
|
-
*/
|
|
62
|
-
export async function buildQueueSnapshot(root, options = {}) {
|
|
63
|
-
const repoRoot = resolve(root || process.cwd());
|
|
64
|
-
const queueDir = join(repoRoot, ".agent", "jules-queue");
|
|
65
|
-
const warnings = [];
|
|
66
|
-
const limit = Number.isFinite(options.limit) ? Math.max(0, Math.floor(options.limit)) : 100;
|
|
67
|
-
const includeCompleted = options.includeCompleted !== false;
|
|
68
|
-
/** @type {QueueTaskSummary[]} */
|
|
69
|
-
const tasks = [];
|
|
70
|
-
|
|
71
|
-
const counts = {
|
|
72
|
-
pending: 0,
|
|
73
|
-
dispatching: 0,
|
|
74
|
-
"remote-queued": 0,
|
|
75
|
-
planning: 0,
|
|
76
|
-
"awaiting-plan-approval": 0,
|
|
77
|
-
"awaiting-user-feedback": 0,
|
|
78
|
-
running: 0,
|
|
79
|
-
"output-ready": 0,
|
|
80
|
-
verifying: 0,
|
|
81
|
-
deferred: 0,
|
|
82
|
-
quarantined: 0,
|
|
83
|
-
failed: 0,
|
|
84
|
-
completed: 0,
|
|
85
|
-
cancelled: 0,
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
if (!existsSync(queueDir)) {
|
|
89
|
-
warnings.push("Canonical queue directory .agent/jules-queue does not exist.");
|
|
90
|
-
} else {
|
|
91
|
-
try {
|
|
92
|
-
const files = readdirSync(queueDir).filter((f) => f.toLowerCase().endsWith(".md"));
|
|
93
|
-
for (const file of files) {
|
|
94
|
-
const taskId = file.replace(/\.md$/, "");
|
|
95
|
-
const taskPath = join(queueDir, file);
|
|
96
|
-
const statePath = join(queueDir, `${taskId}.state.json`);
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
const content = readFileSync(taskPath, "utf-8");
|
|
100
|
-
if (!isQueueTaskFile(file, content)) continue;
|
|
101
|
-
const envelope = parseEnvelopeHeader(content) || {};
|
|
102
|
-
const envelopeHash = "sha256:" + createHash("sha256").update(content).digest("hex");
|
|
103
|
-
|
|
104
|
-
let sidecar = {};
|
|
105
|
-
let stateHash = null;
|
|
106
|
-
if (existsSync(statePath)) {
|
|
107
|
-
try {
|
|
108
|
-
const stateContent = readFileSync(statePath, "utf-8");
|
|
109
|
-
stateHash = "sha256:" + createHash("sha256").update(stateContent).digest("hex");
|
|
110
|
-
sidecar = JSON.parse(stateContent);
|
|
111
|
-
} catch {
|
|
112
|
-
warnings.push(`Malformed sidecar file for task ${taskId}`);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const knownStates = new Set([
|
|
117
|
-
"pending", "dispatching", "remote-queued", "planning", "awaiting-plan-approval",
|
|
118
|
-
"awaiting-user-feedback", "running", "output-ready", "verifying", "deferred",
|
|
119
|
-
"quarantined", "failed", "completed", "cancelled",
|
|
120
|
-
]);
|
|
121
|
-
let state = knownStates.has(sidecar.state) ? sidecar.state : "pending";
|
|
122
|
-
if (sidecar.state && state === "pending" && sidecar.state !== "pending") {
|
|
123
|
-
warnings.push(`Unknown state '${sidecar.state}' for task ${taskId}; treating it as pending`);
|
|
124
|
-
}
|
|
125
|
-
if ((state === "running" || state === "verifying") && sidecar.pid !== undefined && !isPidAlive(sidecar.pid)) {
|
|
126
|
-
state = "failed";
|
|
127
|
-
warnings.push(`Task ${taskId} references dead PID ${sidecar.pid}; treating it as failed`);
|
|
128
|
-
}
|
|
129
|
-
counts[state] = (counts[state] || 0) + 1;
|
|
130
|
-
|
|
131
|
-
if (!includeCompleted && (state === "completed" || state === "cancelled")) {
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
tasks.push({
|
|
136
|
-
id: taskId,
|
|
137
|
-
title: envelope.title || taskId,
|
|
138
|
-
state,
|
|
139
|
-
riskTier: envelope.riskTier || "R1",
|
|
140
|
-
attempt: sidecar.attempt ?? 0,
|
|
141
|
-
maxAttempts: sidecar.maxAttempts ?? envelope.maxAttempts ?? 3,
|
|
142
|
-
createdAt: sidecar.createdAt || envelope.createdAt || new Date().toISOString(),
|
|
143
|
-
updatedAt: sidecar.updatedAt || new Date().toISOString(),
|
|
144
|
-
durationMs: sidecar.durationMs ?? 0,
|
|
145
|
-
source: envelope.source,
|
|
146
|
-
startingBranch: envelope.startingBranch,
|
|
147
|
-
autoPr: Boolean(envelope.autoPr),
|
|
148
|
-
requirePlanApproval: Boolean(envelope.requirePlanApproval),
|
|
149
|
-
verifyCmd: envelope.verifyCmd,
|
|
150
|
-
slotId: sidecar.slotId,
|
|
151
|
-
pid: sidecar.pid,
|
|
152
|
-
worktree: sidecar.worktree,
|
|
153
|
-
errorSummary: sidecar.errorSummary,
|
|
154
|
-
envelopeHash,
|
|
155
|
-
stateHash,
|
|
156
|
-
stateRevision: sidecar.revision ?? 1,
|
|
157
|
-
taskFile: `.agent/jules-queue/${file}`,
|
|
158
|
-
});
|
|
159
|
-
} catch (err) {
|
|
160
|
-
warnings.push(`Failed to read task ${taskId}: ${err.message}`);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
} catch (err) {
|
|
164
|
-
warnings.push(`Failed to list queue directory: ${err.message}`);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Sort tasks by updatedAt descending
|
|
169
|
-
tasks.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
170
|
-
|
|
171
|
-
const generatedAt = new Date().toISOString();
|
|
172
|
-
const revisionPayload = JSON.stringify({ repository: repoRoot, generatedAt, counts, taskCount: tasks.length });
|
|
173
|
-
const revision = "sha256:" + createHash("sha256").update(revisionPayload).digest("hex");
|
|
174
|
-
|
|
175
|
-
return {
|
|
176
|
-
schema: "agentctl/queue-snapshot-v1",
|
|
177
|
-
repository: repoRoot,
|
|
178
|
-
generatedAt,
|
|
179
|
-
revision,
|
|
180
|
-
counts,
|
|
181
|
-
tasks: tasks.slice(0, limit),
|
|
182
|
-
warnings,
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Queue state reducer for updating view navigation state.
|
|
188
|
-
* @param {Record<string, any>} state
|
|
189
|
-
* @param {import("../key-decoder.mjs").KeyEvent | { type: string, payload?: any }} event
|
|
190
|
-
* @returns {Record<string, any>}
|
|
191
|
-
*/
|
|
192
|
-
export function reduceQueueState(state, event) {
|
|
193
|
-
const nextState = { ...state };
|
|
194
|
-
if (!event) return nextState;
|
|
195
|
-
|
|
196
|
-
if (typeof event === "object" && "type" in event) {
|
|
197
|
-
if (event.type === "SET_FILTER") {
|
|
198
|
-
nextState.filter = String(event.payload || "");
|
|
199
|
-
nextState.selectedIndex = 0;
|
|
200
|
-
} else if (event.type === "SELECT_TASK") {
|
|
201
|
-
nextState.selectedTaskId = String(event.payload || "");
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return nextState;
|
|
206
|
-
}
|
package/src/ux/renderer.mjs
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import { getStringWidth, clipText, stripAnsi } from "./layout.mjs";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @typedef {Object} TextStyle
|
|
5
|
-
* @property {"default" | "red" | "green" | "yellow" | "blue" | "cyan" | "white"} [fg]
|
|
6
|
-
* @property {"default" | "blue" | "cyan" | "bgRed" | "bgGreen"} [bg]
|
|
7
|
-
* @property {boolean} [bold]
|
|
8
|
-
* @property {boolean} [dim]
|
|
9
|
-
* @property {boolean} [underline]
|
|
10
|
-
* @property {boolean} [inverse]
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* @typedef {Object} CellRun
|
|
15
|
-
* @property {string} text
|
|
16
|
-
* @property {TextStyle} style
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* @typedef {Object} ScreenLine
|
|
21
|
-
* @property {CellRun[]} runs
|
|
22
|
-
* @property {string} [key]
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* @typedef {Object} RenderFrame
|
|
27
|
-
* @property {number} width
|
|
28
|
-
* @property {number} height
|
|
29
|
-
* @property {ScreenLine[]} lines
|
|
30
|
-
* @property {{ row: number, column: number, visible: boolean }} [cursor]
|
|
31
|
-
* @property {string} [title]
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
const FG_CODES = {
|
|
35
|
-
red: 31,
|
|
36
|
-
green: 32,
|
|
37
|
-
yellow: 33,
|
|
38
|
-
blue: 34,
|
|
39
|
-
cyan: 36,
|
|
40
|
-
white: 37,
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const BG_CODES = {
|
|
44
|
-
blue: 44,
|
|
45
|
-
cyan: 46,
|
|
46
|
-
bgRed: 41,
|
|
47
|
-
bgGreen: 42,
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Strip or sanitize untrusted control characters and ANSI escape sequences.
|
|
52
|
-
* @param {string} text
|
|
53
|
-
* @returns {string}
|
|
54
|
-
*/
|
|
55
|
-
export function sanitizeControlChars(text) {
|
|
56
|
-
if (text === null || text === undefined) return "";
|
|
57
|
-
// Strip CSI, OSC, and single-byte ANSI sequences before removing controls.
|
|
58
|
-
return stripAnsi(text).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Encode style to ANSI escape code sequence.
|
|
63
|
-
* @param {TextStyle} [style]
|
|
64
|
-
* @returns {string}
|
|
65
|
-
*/
|
|
66
|
-
export function encodeAnsiStyle(style) {
|
|
67
|
-
if (!style) return "";
|
|
68
|
-
const codes = [];
|
|
69
|
-
|
|
70
|
-
if (style.bold) codes.push(1);
|
|
71
|
-
if (style.dim) codes.push(2);
|
|
72
|
-
if (style.underline) codes.push(4);
|
|
73
|
-
if (style.inverse) codes.push(7);
|
|
74
|
-
|
|
75
|
-
if (style.fg && FG_CODES[style.fg]) {
|
|
76
|
-
codes.push(FG_CODES[style.fg]);
|
|
77
|
-
}
|
|
78
|
-
if (style.bg && BG_CODES[style.bg]) {
|
|
79
|
-
codes.push(BG_CODES[style.bg]);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (codes.length === 0) return "";
|
|
83
|
-
return `\u001b[${codes.join(";")}m`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Format cell runs into line string with ANSI styling applied.
|
|
88
|
-
* @param {ScreenLine} line
|
|
89
|
-
* @param {number} maxWidth
|
|
90
|
-
* @param {import("./capabilities.mjs").TerminalCapabilities} capabilities
|
|
91
|
-
* @returns {string}
|
|
92
|
-
*/
|
|
93
|
-
export function renderLineToAnsi(line, maxWidth, capabilities) {
|
|
94
|
-
let lineText = "";
|
|
95
|
-
let currentWidth = 0;
|
|
96
|
-
|
|
97
|
-
for (const run of line.runs || []) {
|
|
98
|
-
if (currentWidth >= maxWidth) break;
|
|
99
|
-
const cleanText = sanitizeControlChars(run.text);
|
|
100
|
-
const runWidth = getStringWidth(cleanText);
|
|
101
|
-
|
|
102
|
-
let textToDraw = cleanText;
|
|
103
|
-
if (currentWidth + runWidth > maxWidth) {
|
|
104
|
-
textToDraw = clipText(cleanText, maxWidth - currentWidth, "");
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const runDrawWidth = getStringWidth(textToDraw);
|
|
108
|
-
currentWidth += runDrawWidth;
|
|
109
|
-
|
|
110
|
-
if (capabilities.ansi && run.style) {
|
|
111
|
-
const styleAnsi = encodeAnsiStyle(run.style);
|
|
112
|
-
if (styleAnsi) {
|
|
113
|
-
lineText += `${styleAnsi}${textToDraw}\u001b[0m`;
|
|
114
|
-
} else {
|
|
115
|
-
lineText += textToDraw;
|
|
116
|
-
}
|
|
117
|
-
} else {
|
|
118
|
-
lineText += textToDraw;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Pad remaining line width with spaces
|
|
123
|
-
if (currentWidth < maxWidth) {
|
|
124
|
-
lineText += " ".repeat(maxWidth - currentWidth);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return lineText;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Render complete frame into ANSI string buffer.
|
|
132
|
-
* @param {RenderFrame} frame
|
|
133
|
-
* @param {import("./capabilities.mjs").TerminalCapabilities} capabilities
|
|
134
|
-
* @returns {string}
|
|
135
|
-
*/
|
|
136
|
-
export function renderFrameToAnsi(frame, capabilities) {
|
|
137
|
-
if (!capabilities.ansi) {
|
|
138
|
-
return renderFrameToString(frame);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
let output = capabilities.alternateScreen ? "\u001b[H" : "\u001b[1;1H";
|
|
142
|
-
|
|
143
|
-
const renderLines = frame.lines.slice(0, frame.height);
|
|
144
|
-
for (let i = 0; i < frame.height; i++) {
|
|
145
|
-
const line = renderLines[i] || { runs: [] };
|
|
146
|
-
const lineAnsi = renderLineToAnsi(line, frame.width, capabilities);
|
|
147
|
-
output += lineAnsi;
|
|
148
|
-
if (i < frame.height - 1) {
|
|
149
|
-
output += "\n";
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (frame.cursor && frame.cursor.visible) {
|
|
154
|
-
const row = Math.min(frame.height, Math.max(1, frame.cursor.row));
|
|
155
|
-
const col = Math.min(frame.width, Math.max(1, frame.cursor.column));
|
|
156
|
-
output += `\u001b[${row};${col}H\u001b[?25h`;
|
|
157
|
-
} else {
|
|
158
|
-
output += "\u001b[?25l";
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return output;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Render frame into plain string lines for non-TTY or testing assertions.
|
|
166
|
-
* @param {RenderFrame} frame
|
|
167
|
-
* @returns {string}
|
|
168
|
-
*/
|
|
169
|
-
export function renderFrameToString(frame) {
|
|
170
|
-
const lines = [];
|
|
171
|
-
for (let i = 0; i < frame.height; i++) {
|
|
172
|
-
const line = frame.lines[i] || { runs: [] };
|
|
173
|
-
let lineStr = "";
|
|
174
|
-
let width = 0;
|
|
175
|
-
for (const run of line.runs || []) {
|
|
176
|
-
const clean = sanitizeControlChars(run.text);
|
|
177
|
-
lineStr += clean;
|
|
178
|
-
width += getStringWidth(clean);
|
|
179
|
-
}
|
|
180
|
-
if (width < frame.width) {
|
|
181
|
-
lineStr += " ".repeat(frame.width - width);
|
|
182
|
-
} else if (width > frame.width) {
|
|
183
|
-
lineStr = clipText(lineStr, frame.width, "");
|
|
184
|
-
}
|
|
185
|
-
lines.push(lineStr);
|
|
186
|
-
}
|
|
187
|
-
return lines.join("\n");
|
|
188
|
-
}
|