jules-orchestrator-kit 0.52.7 → 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 +53 -7
- 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/swarm-model.mjs
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* @typedef {"idle" | "reserved" | "preparing" | "running" | "verifying" | "draining" | "failed" | "stale"} SlotState
|
|
7
|
-
*
|
|
8
|
-
* @typedef {Object} SwarmSlot
|
|
9
|
-
* @property {string} id
|
|
10
|
-
* @property {SlotState} state
|
|
11
|
-
* @property {string} [taskId]
|
|
12
|
-
* @property {number} [attempt]
|
|
13
|
-
* @property {string} [worktree]
|
|
14
|
-
* @property {string} [branch]
|
|
15
|
-
* @property {number} [pid]
|
|
16
|
-
* @property {string} [processStartTime]
|
|
17
|
-
* @property {string} [startedAt]
|
|
18
|
-
* @property {number} [durationMs]
|
|
19
|
-
* @property {string[]} lockIds
|
|
20
|
-
* @property {string[]} writeScope
|
|
21
|
-
* @property {number} [cpuPercent]
|
|
22
|
-
* @property {number} [rssBytes]
|
|
23
|
-
* @property {"full" | "partial" | "unavailable"} resourceSupport
|
|
24
|
-
* @property {string} [lastHeartbeat]
|
|
25
|
-
* @property {string} [warning]
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Check if PID is currently alive on current system.
|
|
30
|
-
* @param {number} pid
|
|
31
|
-
* @returns {boolean}
|
|
32
|
-
*/
|
|
33
|
-
function isPidAlive(pid) {
|
|
34
|
-
if (!pid || pid <= 0) return false;
|
|
35
|
-
try {
|
|
36
|
-
process.kill(pid, 0);
|
|
37
|
-
return true;
|
|
38
|
-
} catch (err) {
|
|
39
|
-
return err.code === "EPERM"; // EPERM means process exists but we lack permission to signal
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Build immutable SwarmSnapshot probing active worker slots.
|
|
45
|
-
* @param {string} root
|
|
46
|
-
* @param {Object} [options]
|
|
47
|
-
* @param {boolean} [options.sampleResources=false]
|
|
48
|
-
* @returns {Promise<{ slots: SwarmSlot[], warnings: string[], revision: string }>}
|
|
49
|
-
*/
|
|
50
|
-
export async function buildSwarmSnapshot(root, _options = {}) {
|
|
51
|
-
const repoRoot = resolve(root || process.cwd());
|
|
52
|
-
const swarmDir = join(repoRoot, ".agent", "state", "swarm");
|
|
53
|
-
const warnings = [];
|
|
54
|
-
/** @type {SwarmSlot[]} */
|
|
55
|
-
const slots = [];
|
|
56
|
-
|
|
57
|
-
// Default 4 concurrency slots if no swarm config files exist
|
|
58
|
-
const defaultSlotIds = ["slot-01", "slot-02", "slot-03", "slot-04"];
|
|
59
|
-
|
|
60
|
-
if (existsSync(swarmDir)) {
|
|
61
|
-
try {
|
|
62
|
-
const slotFiles = readdirSync(swarmDir).filter((f) => f.startsWith("slot-") && f.endsWith(".json"));
|
|
63
|
-
for (const file of slotFiles) {
|
|
64
|
-
const slotPath = join(swarmDir, file);
|
|
65
|
-
try {
|
|
66
|
-
const content = readFileSync(slotPath, "utf-8");
|
|
67
|
-
/** @type {SwarmSlot} */
|
|
68
|
-
const slotData = JSON.parse(content);
|
|
69
|
-
|
|
70
|
-
let state = slotData.state || "idle";
|
|
71
|
-
let warning = slotData.warning;
|
|
72
|
-
|
|
73
|
-
if (slotData.pid && (state === "running" || state === "verifying")) {
|
|
74
|
-
if (!isPidAlive(slotData.pid)) {
|
|
75
|
-
state = "stale";
|
|
76
|
-
warning = `Process PID ${slotData.pid} is no longer alive`;
|
|
77
|
-
warnings.push(`Slot ${slotData.id} has dead PID ${slotData.pid}`);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
slots.push({
|
|
82
|
-
id: slotData.id || file.replace(/\.json$/, ""),
|
|
83
|
-
state,
|
|
84
|
-
taskId: slotData.taskId,
|
|
85
|
-
attempt: slotData.attempt,
|
|
86
|
-
worktree: slotData.worktree,
|
|
87
|
-
branch: slotData.branch,
|
|
88
|
-
pid: slotData.pid,
|
|
89
|
-
processStartTime: slotData.processStartTime,
|
|
90
|
-
startedAt: slotData.startedAt,
|
|
91
|
-
durationMs: slotData.durationMs,
|
|
92
|
-
lockIds: slotData.lockIds || [],
|
|
93
|
-
writeScope: slotData.writeScope || [],
|
|
94
|
-
resourceSupport: "partial",
|
|
95
|
-
warning,
|
|
96
|
-
});
|
|
97
|
-
} catch (err) {
|
|
98
|
-
warnings.push(`Failed to parse slot file ${file}: ${err.message}`);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
} catch (err) {
|
|
102
|
-
warnings.push(`Failed to read swarm state directory: ${err.message}`);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Populate default idle slots if fewer than 4 slots exist
|
|
107
|
-
if (slots.length < 4) {
|
|
108
|
-
for (const defaultId of defaultSlotIds) {
|
|
109
|
-
if (!slots.some((s) => s.id === defaultId)) {
|
|
110
|
-
slots.push({
|
|
111
|
-
id: defaultId,
|
|
112
|
-
state: "idle",
|
|
113
|
-
lockIds: [],
|
|
114
|
-
writeScope: [],
|
|
115
|
-
resourceSupport: "partial",
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
slots.sort((a, b) => a.id.localeCompare(b.id));
|
|
122
|
-
|
|
123
|
-
const revisionPayload = JSON.stringify({ slots: slots.map((s) => ({ id: s.id, state: s.state, pid: s.pid })) });
|
|
124
|
-
const revision = "sha256:" + createHash("sha256").update(revisionPayload).digest("hex");
|
|
125
|
-
|
|
126
|
-
return { slots, warnings, revision };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Swarm state reducer for updating view navigation state.
|
|
131
|
-
* @param {Record<string, any>} state
|
|
132
|
-
* @param {import("../key-decoder.mjs").KeyEvent | { type: string, payload?: any }} event
|
|
133
|
-
* @returns {Record<string, any>}
|
|
134
|
-
*/
|
|
135
|
-
export function reduceSwarmState(state, event) {
|
|
136
|
-
const nextState = { ...state };
|
|
137
|
-
if (!event) return nextState;
|
|
138
|
-
|
|
139
|
-
if (typeof event === "object" && "type" in event) {
|
|
140
|
-
if (event.type === "SELECT_SLOT") {
|
|
141
|
-
nextState.selectedSlotId = String(event.payload || "");
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return nextState;
|
|
146
|
-
}
|
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
import { detectTerminalCapabilities } from "./capabilities.mjs";
|
|
2
|
-
import { createKeyDecoder } from "./key-decoder.mjs";
|
|
3
|
-
|
|
4
|
-
export class WizardCancelledError extends Error {
|
|
5
|
-
/**
|
|
6
|
-
* @param {string} [message]
|
|
7
|
-
* @param {"escape" | "ctrl-c" | "quit" | "stream-closed"} [reason="ctrl-c"]
|
|
8
|
-
*/
|
|
9
|
-
constructor(message = "Wizard operation cancelled by user", reason = "ctrl-c") {
|
|
10
|
-
super(message);
|
|
11
|
-
this.name = "WizardCancelledError";
|
|
12
|
-
this.code = 130;
|
|
13
|
-
this.reason = reason;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @typedef {import("./capabilities.mjs").TerminalCapabilities} TerminalCapabilities
|
|
19
|
-
* @typedef {import("./capabilities.mjs").TerminalSessionOptions} TerminalSessionOptions
|
|
20
|
-
* @typedef {import("./key-decoder.mjs").KeyEvent} KeyEvent
|
|
21
|
-
* @typedef {import("./renderer.mjs").RenderFrame} RenderFrame
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* @typedef {Object} TerminalSession
|
|
26
|
-
* @property {TerminalCapabilities} capabilities
|
|
27
|
-
* @property {() => Promise<void>} enter
|
|
28
|
-
* @property {(frame: RenderFrame) => Promise<void>} render
|
|
29
|
-
* @property {<T>(operation: () => Promise<T>) => Promise<T>} suspend
|
|
30
|
-
* @property {() => Promise<void>} restore
|
|
31
|
-
* @property {() => Promise<void>} close
|
|
32
|
-
* @property {(listener: (key: KeyEvent) => void) => () => void} onKey
|
|
33
|
-
* @property {(listener: (size: { columns: number, rows: number }) => void) => () => void} onResize
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Create terminal raw-mode session manager.
|
|
38
|
-
* @param {TerminalSessionOptions} [options]
|
|
39
|
-
* @returns {TerminalSession}
|
|
40
|
-
*/
|
|
41
|
-
export function createTerminalSession(options = {}) {
|
|
42
|
-
const input = options.input || process.stdin;
|
|
43
|
-
const output = options.output || process.stdout;
|
|
44
|
-
|
|
45
|
-
const capabilities = detectTerminalCapabilities(options);
|
|
46
|
-
const keyDecoder = createKeyDecoder();
|
|
47
|
-
|
|
48
|
-
let isEntered = false;
|
|
49
|
-
let isClosed = false;
|
|
50
|
-
let wasRaw = Boolean(input && input.isRaw);
|
|
51
|
-
|
|
52
|
-
const keyListeners = new Set();
|
|
53
|
-
const resizeListeners = new Set();
|
|
54
|
-
|
|
55
|
-
let winchScheduled = false;
|
|
56
|
-
|
|
57
|
-
const handleData = (chunk) => {
|
|
58
|
-
if (isClosed) return;
|
|
59
|
-
const events = keyDecoder.push(chunk);
|
|
60
|
-
for (const event of events) {
|
|
61
|
-
if (event.name === "ctrl-c") {
|
|
62
|
-
session.close().catch(() => {});
|
|
63
|
-
throw new WizardCancelledError("Wizard operation cancelled by user (SIGINT).", "ctrl-c");
|
|
64
|
-
}
|
|
65
|
-
for (const listener of keyListeners) {
|
|
66
|
-
try {
|
|
67
|
-
listener(event);
|
|
68
|
-
} catch (err) {
|
|
69
|
-
if (err instanceof WizardCancelledError) {
|
|
70
|
-
session.close().catch(() => {});
|
|
71
|
-
throw err;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
const handleSigwinch = () => {
|
|
79
|
-
if (winchScheduled || isClosed) return;
|
|
80
|
-
winchScheduled = true;
|
|
81
|
-
queueMicrotask(() => {
|
|
82
|
-
winchScheduled = false;
|
|
83
|
-
if (isClosed) return;
|
|
84
|
-
capabilities.columns = Math.max(10, output.columns || input.columns || process.stdout.columns || 80);
|
|
85
|
-
capabilities.rows = Math.max(5, output.rows || input.rows || process.stdout.rows || 24);
|
|
86
|
-
for (const listener of resizeListeners) {
|
|
87
|
-
listener({ columns: capabilities.columns, rows: capabilities.rows });
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
const handleSigint = () => {
|
|
93
|
-
session.close().catch(() => {});
|
|
94
|
-
throw new WizardCancelledError("Wizard operation cancelled by user (SIGINT).", "ctrl-c");
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const session = {
|
|
98
|
-
capabilities,
|
|
99
|
-
|
|
100
|
-
async enter() {
|
|
101
|
-
if (isEntered || isClosed) return;
|
|
102
|
-
isEntered = true;
|
|
103
|
-
|
|
104
|
-
if (capabilities.inputIsTTY && typeof input.setRawMode === "function") {
|
|
105
|
-
wasRaw = Boolean(input.isRaw);
|
|
106
|
-
input.setRawMode(true);
|
|
107
|
-
input.resume();
|
|
108
|
-
if (typeof input.setEncoding === "function") {
|
|
109
|
-
input.setEncoding("utf-8");
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (capabilities.ansi && capabilities.outputIsTTY) {
|
|
114
|
-
if (capabilities.alternateScreen) {
|
|
115
|
-
output.write("\u001b[?1049h"); // Enter alternate screen
|
|
116
|
-
}
|
|
117
|
-
output.write("\u001b[?25l"); // Hide cursor
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
input.on("data", handleData);
|
|
121
|
-
process.on("SIGWINCH", handleSigwinch);
|
|
122
|
-
process.on("SIGINT", handleSigint);
|
|
123
|
-
},
|
|
124
|
-
|
|
125
|
-
async render(frame) {
|
|
126
|
-
if (isClosed || !capabilities.ansi) return;
|
|
127
|
-
// Delegated to renderer ANSI builder
|
|
128
|
-
const { renderFrameToAnsi } = await import("./renderer.mjs");
|
|
129
|
-
const ansiText = renderFrameToAnsi(frame, capabilities);
|
|
130
|
-
output.write(ansiText);
|
|
131
|
-
},
|
|
132
|
-
|
|
133
|
-
async suspend(operation) {
|
|
134
|
-
if (!isEntered || isClosed) return operation();
|
|
135
|
-
|
|
136
|
-
// Temporarily restore cooked mode
|
|
137
|
-
input.removeListener("data", handleData);
|
|
138
|
-
process.removeListener("SIGWINCH", handleSigwinch);
|
|
139
|
-
process.removeListener("SIGINT", handleSigint);
|
|
140
|
-
|
|
141
|
-
if (capabilities.ansi && capabilities.outputIsTTY) {
|
|
142
|
-
output.write("\u001b[?25h"); // Show cursor
|
|
143
|
-
if (capabilities.alternateScreen) {
|
|
144
|
-
output.write("\u001b[?1049l"); // Leave alternate screen
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (capabilities.inputIsTTY && typeof input.setRawMode === "function") {
|
|
149
|
-
input.setRawMode(wasRaw);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
try {
|
|
153
|
-
return await operation();
|
|
154
|
-
} finally {
|
|
155
|
-
// Re-enter raw mode
|
|
156
|
-
if (capabilities.inputIsTTY && typeof input.setRawMode === "function") {
|
|
157
|
-
input.setRawMode(true);
|
|
158
|
-
}
|
|
159
|
-
if (capabilities.ansi && capabilities.outputIsTTY) {
|
|
160
|
-
if (capabilities.alternateScreen) {
|
|
161
|
-
output.write("\u001b[?1049h");
|
|
162
|
-
}
|
|
163
|
-
output.write("\u001b[?25l");
|
|
164
|
-
}
|
|
165
|
-
input.on("data", handleData);
|
|
166
|
-
process.on("SIGWINCH", handleSigwinch);
|
|
167
|
-
process.on("SIGINT", handleSigint);
|
|
168
|
-
}
|
|
169
|
-
},
|
|
170
|
-
|
|
171
|
-
async restore() {
|
|
172
|
-
await session.close();
|
|
173
|
-
},
|
|
174
|
-
|
|
175
|
-
async close() {
|
|
176
|
-
if (isClosed) return;
|
|
177
|
-
isClosed = true;
|
|
178
|
-
isEntered = false;
|
|
179
|
-
|
|
180
|
-
input.removeListener("data", handleData);
|
|
181
|
-
process.removeListener("SIGWINCH", handleSigwinch);
|
|
182
|
-
process.removeListener("SIGINT", handleSigint);
|
|
183
|
-
|
|
184
|
-
if (capabilities.ansi && capabilities.outputIsTTY) {
|
|
185
|
-
output.write("\u001b[?25h"); // Show cursor
|
|
186
|
-
if (capabilities.alternateScreen) {
|
|
187
|
-
output.write("\u001b[?1049l"); // Leave alternate screen
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (capabilities.inputIsTTY && typeof input.setRawMode === "function") {
|
|
192
|
-
try {
|
|
193
|
-
input.setRawMode(wasRaw);
|
|
194
|
-
} catch {
|
|
195
|
-
// Ignore state restoration failures on teardown
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
keyListeners.clear();
|
|
200
|
-
resizeListeners.clear();
|
|
201
|
-
},
|
|
202
|
-
|
|
203
|
-
onKey(listener) {
|
|
204
|
-
keyListeners.add(listener);
|
|
205
|
-
return () => keyListeners.delete(listener);
|
|
206
|
-
},
|
|
207
|
-
|
|
208
|
-
onResize(listener) {
|
|
209
|
-
resizeListeners.add(listener);
|
|
210
|
-
return () => resizeListeners.delete(listener);
|
|
211
|
-
},
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
return session;
|
|
215
|
-
}
|
package/src/ux/widgets.mjs
DELETED
|
@@ -1,323 +0,0 @@
|
|
|
1
|
-
import { getStringWidth, clipText } from "./layout.mjs";
|
|
2
|
-
import { getSymbols } from "./capabilities.mjs";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Filter items by string filter using token subsequence matching.
|
|
6
|
-
* @template T
|
|
7
|
-
* @param {T[]} items
|
|
8
|
-
* @param {string} filterText
|
|
9
|
-
* @param {(item: T) => string} getText
|
|
10
|
-
* @returns {T[]}
|
|
11
|
-
*/
|
|
12
|
-
export function filterItems(items, filterText, getText) {
|
|
13
|
-
if (!filterText || !filterText.trim()) return items;
|
|
14
|
-
const tokens = filterText.trim().toLowerCase().split(/\s+/);
|
|
15
|
-
|
|
16
|
-
return items.filter((item) => {
|
|
17
|
-
const text = getText(item).toLowerCase();
|
|
18
|
-
return tokens.every((token) => text.includes(token));
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Create list widget state and reducers.
|
|
24
|
-
* @template T
|
|
25
|
-
* @param {Object} opts
|
|
26
|
-
* @param {T[]} opts.items
|
|
27
|
-
* @param {(item: T) => string} opts.getId
|
|
28
|
-
* @param {(item: T) => string} opts.getText
|
|
29
|
-
* @param {number} [opts.pageSize=10]
|
|
30
|
-
* @param {boolean} [opts.multiSelect=false]
|
|
31
|
-
*/
|
|
32
|
-
export function createListState(opts) {
|
|
33
|
-
const { items, getId, getText, pageSize = 10, multiSelect = false } = opts;
|
|
34
|
-
|
|
35
|
-
let filter = "";
|
|
36
|
-
let selectedIndex = 0;
|
|
37
|
-
let offset = 0;
|
|
38
|
-
const selectedIds = new Set();
|
|
39
|
-
|
|
40
|
-
function getFilteredItems() {
|
|
41
|
-
return filterItems(items, filter, getText);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function clampSelection() {
|
|
45
|
-
const filtered = getFilteredItems();
|
|
46
|
-
if (filtered.length === 0) {
|
|
47
|
-
selectedIndex = 0;
|
|
48
|
-
offset = 0;
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
if (selectedIndex < 0) selectedIndex = 0;
|
|
52
|
-
if (selectedIndex >= filtered.length) selectedIndex = filtered.length - 1;
|
|
53
|
-
|
|
54
|
-
// Adjust scroll offset viewport
|
|
55
|
-
if (selectedIndex < offset) {
|
|
56
|
-
offset = selectedIndex;
|
|
57
|
-
} else if (selectedIndex >= offset + pageSize) {
|
|
58
|
-
offset = selectedIndex - pageSize + 1;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
clampSelection();
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
get filter() {
|
|
66
|
-
return filter;
|
|
67
|
-
},
|
|
68
|
-
set filter(val) {
|
|
69
|
-
filter = val;
|
|
70
|
-
selectedIndex = 0;
|
|
71
|
-
offset = 0;
|
|
72
|
-
clampSelection();
|
|
73
|
-
},
|
|
74
|
-
get selectedIndex() {
|
|
75
|
-
return selectedIndex;
|
|
76
|
-
},
|
|
77
|
-
get offset() {
|
|
78
|
-
return offset;
|
|
79
|
-
},
|
|
80
|
-
get selectedIds() {
|
|
81
|
-
return Array.from(selectedIds);
|
|
82
|
-
},
|
|
83
|
-
|
|
84
|
-
setItems(newItems) {
|
|
85
|
-
opts.items = newItems;
|
|
86
|
-
clampSelection();
|
|
87
|
-
},
|
|
88
|
-
|
|
89
|
-
move(delta) {
|
|
90
|
-
const filtered = getFilteredItems();
|
|
91
|
-
if (filtered.length === 0) return;
|
|
92
|
-
selectedIndex += delta;
|
|
93
|
-
clampSelection();
|
|
94
|
-
},
|
|
95
|
-
|
|
96
|
-
page(direction) {
|
|
97
|
-
const step = Math.max(1, pageSize - 2);
|
|
98
|
-
this.move(direction * step);
|
|
99
|
-
},
|
|
100
|
-
|
|
101
|
-
toStart() {
|
|
102
|
-
selectedIndex = 0;
|
|
103
|
-
clampSelection();
|
|
104
|
-
},
|
|
105
|
-
|
|
106
|
-
toEnd() {
|
|
107
|
-
const filtered = getFilteredItems();
|
|
108
|
-
selectedIndex = Math.max(0, filtered.length - 1);
|
|
109
|
-
clampSelection();
|
|
110
|
-
},
|
|
111
|
-
|
|
112
|
-
toggleCurrent() {
|
|
113
|
-
if (!multiSelect) return;
|
|
114
|
-
const filtered = getFilteredItems();
|
|
115
|
-
const current = filtered[selectedIndex];
|
|
116
|
-
if (!current) return;
|
|
117
|
-
const id = getId(current);
|
|
118
|
-
if (selectedIds.has(id)) {
|
|
119
|
-
selectedIds.delete(id);
|
|
120
|
-
} else {
|
|
121
|
-
selectedIds.add(id);
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
|
|
125
|
-
selectAll() {
|
|
126
|
-
if (!multiSelect) return;
|
|
127
|
-
for (const item of getFilteredItems()) {
|
|
128
|
-
selectedIds.add(getId(item));
|
|
129
|
-
}
|
|
130
|
-
},
|
|
131
|
-
|
|
132
|
-
clearSelection() {
|
|
133
|
-
selectedIds.clear();
|
|
134
|
-
},
|
|
135
|
-
|
|
136
|
-
getSelectedItem() {
|
|
137
|
-
const filtered = getFilteredItems();
|
|
138
|
-
return filtered[selectedIndex] || null;
|
|
139
|
-
},
|
|
140
|
-
|
|
141
|
-
getFilteredItems,
|
|
142
|
-
getId,
|
|
143
|
-
getText,
|
|
144
|
-
pageSize,
|
|
145
|
-
multiSelect,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Render paginated list lines.
|
|
151
|
-
* @template T
|
|
152
|
-
* @param {ReturnType<typeof createListState<T>>} state
|
|
153
|
-
* @param {import("./layout.mjs").computeResponsiveLayout extends (...args: any[]) => infer R ? R : never} layout
|
|
154
|
-
* @param {import("./capabilities.mjs").TerminalCapabilities} caps
|
|
155
|
-
* @returns {import("./renderer.mjs").ScreenLine[]}
|
|
156
|
-
*/
|
|
157
|
-
export function renderListLines(state, layout, caps) {
|
|
158
|
-
const symbols = getSymbols(caps);
|
|
159
|
-
const filtered = state.getFilteredItems();
|
|
160
|
-
const lines = [];
|
|
161
|
-
|
|
162
|
-
const total = filtered.length;
|
|
163
|
-
const startIdx = state.offset;
|
|
164
|
-
const endIdx = Math.min(total, startIdx + state.pageSize);
|
|
165
|
-
|
|
166
|
-
// Scroll indicator top
|
|
167
|
-
if (startIdx > 0) {
|
|
168
|
-
const aboveCount = startIdx;
|
|
169
|
-
lines.push({
|
|
170
|
-
runs: [
|
|
171
|
-
{
|
|
172
|
-
text: `${symbols.arrowUp} ${aboveCount} more items above`,
|
|
173
|
-
style: { fg: "cyan", dim: true },
|
|
174
|
-
},
|
|
175
|
-
],
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
for (let i = startIdx; i < endIdx; i++) {
|
|
180
|
-
const item = filtered[i];
|
|
181
|
-
const isSelected = i === state.selectedIndex;
|
|
182
|
-
const itemText = state.getText(item);
|
|
183
|
-
const itemId = state.getId(item);
|
|
184
|
-
const isChecked = state.selectedIds.includes(itemId);
|
|
185
|
-
|
|
186
|
-
let prefix = " ";
|
|
187
|
-
if (state.multiSelect) {
|
|
188
|
-
prefix = isChecked ? `${symbols.checked} ` : `${symbols.unchecked} `;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
let pointer = isSelected ? `${symbols.select} ` : " ";
|
|
192
|
-
let lineText = `${pointer}${prefix}${itemText}`;
|
|
193
|
-
|
|
194
|
-
/** @type {import("./renderer.mjs").TextStyle} */
|
|
195
|
-
let style = { fg: "default" };
|
|
196
|
-
if (isSelected) {
|
|
197
|
-
style = { fg: "cyan", bold: true };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
lines.push({
|
|
201
|
-
runs: [{ text: clipText(lineText, layout.mainWidth || layout.width), style }],
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Scroll indicator bottom
|
|
206
|
-
if (endIdx < total) {
|
|
207
|
-
const belowCount = total - endIdx;
|
|
208
|
-
lines.push({
|
|
209
|
-
runs: [
|
|
210
|
-
{
|
|
211
|
-
text: `${symbols.arrowDown} ${belowCount} more items below`,
|
|
212
|
-
style: { fg: "cyan", dim: true },
|
|
213
|
-
},
|
|
214
|
-
],
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
return lines;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Create text input state and cursor manager.
|
|
223
|
-
* @param {Object} [opts]
|
|
224
|
-
* @param {string} [opts.initialValue=""]
|
|
225
|
-
* @param {boolean} [opts.secret=false]
|
|
226
|
-
*/
|
|
227
|
-
export function createTextInputState(opts = {}) {
|
|
228
|
-
let value = opts.initialValue || "";
|
|
229
|
-
let cursor = value.length;
|
|
230
|
-
let visible = !opts.secret;
|
|
231
|
-
|
|
232
|
-
return {
|
|
233
|
-
get value() {
|
|
234
|
-
return value;
|
|
235
|
-
},
|
|
236
|
-
set value(v) {
|
|
237
|
-
value = String(v);
|
|
238
|
-
cursor = value.length;
|
|
239
|
-
},
|
|
240
|
-
get cursor() {
|
|
241
|
-
return cursor;
|
|
242
|
-
},
|
|
243
|
-
get visible() {
|
|
244
|
-
return visible;
|
|
245
|
-
},
|
|
246
|
-
toggleVisible() {
|
|
247
|
-
visible = !visible;
|
|
248
|
-
},
|
|
249
|
-
|
|
250
|
-
insert(text) {
|
|
251
|
-
if (!text) return;
|
|
252
|
-
value = value.slice(0, cursor) + text + value.slice(cursor);
|
|
253
|
-
cursor += text.length;
|
|
254
|
-
},
|
|
255
|
-
|
|
256
|
-
backspace() {
|
|
257
|
-
if (cursor > 0) {
|
|
258
|
-
value = value.slice(0, cursor - 1) + value.slice(cursor);
|
|
259
|
-
cursor--;
|
|
260
|
-
}
|
|
261
|
-
},
|
|
262
|
-
|
|
263
|
-
delete() {
|
|
264
|
-
if (cursor < value.length) {
|
|
265
|
-
value = value.slice(0, cursor) + value.slice(cursor + 1);
|
|
266
|
-
}
|
|
267
|
-
},
|
|
268
|
-
|
|
269
|
-
moveLeft() {
|
|
270
|
-
if (cursor > 0) cursor--;
|
|
271
|
-
},
|
|
272
|
-
|
|
273
|
-
moveRight() {
|
|
274
|
-
if (cursor < value.length) cursor++;
|
|
275
|
-
},
|
|
276
|
-
|
|
277
|
-
toStart() {
|
|
278
|
-
cursor = 0;
|
|
279
|
-
},
|
|
280
|
-
|
|
281
|
-
toEnd() {
|
|
282
|
-
cursor = value.length;
|
|
283
|
-
},
|
|
284
|
-
|
|
285
|
-
clear() {
|
|
286
|
-
value = "";
|
|
287
|
-
cursor = 0;
|
|
288
|
-
},
|
|
289
|
-
|
|
290
|
-
getDisplayValue(caps) {
|
|
291
|
-
if (!visible && opts.secret) {
|
|
292
|
-
const maskChar = caps.unicode ? "•" : "*";
|
|
293
|
-
return maskChar.repeat(value.length);
|
|
294
|
-
}
|
|
295
|
-
return value;
|
|
296
|
-
},
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Render sticky status bar footer.
|
|
302
|
-
* @param {Array<{ key: string, label: string }>} bindings
|
|
303
|
-
* @param {number} width
|
|
304
|
-
* @param {import("./capabilities.mjs").TerminalCapabilities} caps
|
|
305
|
-
* @returns {import("./renderer.mjs").ScreenLine}
|
|
306
|
-
*/
|
|
307
|
-
export function renderStatusBar(bindings, width, _caps) {
|
|
308
|
-
const runs = [];
|
|
309
|
-
let currentWidth = 0;
|
|
310
|
-
|
|
311
|
-
for (const b of bindings) {
|
|
312
|
-
const itemStr = `${b.key} ${b.label} `;
|
|
313
|
-
const itemWidth = getStringWidth(itemStr);
|
|
314
|
-
|
|
315
|
-
if (currentWidth + itemWidth > width) break;
|
|
316
|
-
|
|
317
|
-
runs.push({ text: `${b.key} `, style: { fg: "cyan", bold: true } });
|
|
318
|
-
runs.push({ text: `${b.label} `, style: { fg: "white", dim: true } });
|
|
319
|
-
currentWidth += itemWidth;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
return { runs };
|
|
323
|
-
}
|
|
File without changes
|