jeo-code 0.6.3 → 0.6.5
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/CHANGELOG.md +31 -0
- package/README.ja.md +6 -2
- package/README.ko.md +6 -2
- package/README.md +6 -2
- package/README.zh.md +6 -2
- package/package.json +1 -1
- package/src/agent/engine.ts +82 -26
- package/src/agent/goal-verifier.ts +115 -0
- package/src/agent/model-recency.ts +1 -1
- package/src/agent/tools.ts +77 -17
- package/src/commands/launch/flags.ts +282 -0
- package/src/commands/launch/input.ts +330 -0
- package/src/commands/launch/stream.ts +102 -0
- package/src/commands/launch/tmux.ts +227 -0
- package/src/commands/launch/workflow.ts +26 -0
- package/src/commands/launch.ts +373 -1101
- package/src/tui/app.ts +87 -25
- package/src/tui/components/autocomplete.ts +6 -4
- package/src/tui/components/config-panel.ts +2 -2
- package/src/tui/components/markdown-text.ts +19 -5
- package/src/tui/components/slash.ts +25 -2
- package/src/tui/components/welcome.ts +17 -5
- package/src/tui/renderer.ts +6 -2
- package/src/tui/terminal.ts +7 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { type ProviderName, type ModelRole, type ThinkLevel, catalogMetadata } from "../../ai";
|
|
4
|
+
|
|
5
|
+
export interface LaunchFlags {
|
|
6
|
+
list: boolean;
|
|
7
|
+
resume: boolean;
|
|
8
|
+
resumeId?: string;
|
|
9
|
+
noSession: boolean;
|
|
10
|
+
noTui: boolean;
|
|
11
|
+
/** Explicit step cap from --max-steps; 0 = dynamic (process-driven budget that
|
|
12
|
+
* keeps extending while the turn shows progress — no hardcoded step ceiling). */
|
|
13
|
+
maxSteps: number;
|
|
14
|
+
message: string;
|
|
15
|
+
tmux: boolean;
|
|
16
|
+
worktree?: string;
|
|
17
|
+
model?: string;
|
|
18
|
+
provider?: ProviderName;
|
|
19
|
+
modelRole?: ModelRole;
|
|
20
|
+
thinking?: ThinkLevel;
|
|
21
|
+
errors: string[];
|
|
22
|
+
print?: boolean;
|
|
23
|
+
appendSystemPromptRaw?: string;
|
|
24
|
+
appendSystemPrompt?: string;
|
|
25
|
+
noSkills: boolean;
|
|
26
|
+
skills?: string;
|
|
27
|
+
noTools: boolean;
|
|
28
|
+
tools?: string;
|
|
29
|
+
systemPromptRaw?: string;
|
|
30
|
+
systemPrompt?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function takeValue(args: string[], index: number, inlinePrefix: string): { value?: string; nextIndex: number } {
|
|
34
|
+
const current = args[index]!;
|
|
35
|
+
if (current.startsWith(inlinePrefix)) return { value: current.slice(inlinePrefix.length), nextIndex: index };
|
|
36
|
+
const next = args[index + 1];
|
|
37
|
+
if (next && !next.startsWith("-")) return { value: next, nextIndex: index + 1 };
|
|
38
|
+
return { nextIndex: index };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isProviderName(input: string | undefined): input is ProviderName {
|
|
42
|
+
return input === "anthropic" || input === "openai" || input === "gemini" || input === "antigravity" || input === "ollama";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isThinkingLevel(input: string | undefined): input is ThinkLevel {
|
|
46
|
+
return input === "minimal" || input === "low" || input === "medium" || input === "high" || input === "xhigh";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
export function fastThinkingLevelForModel(modelId: string): ThinkLevel | undefined {
|
|
51
|
+
const supported = catalogMetadata(modelId)?.thinking ?? [];
|
|
52
|
+
if (supported.includes("minimal")) return "minimal";
|
|
53
|
+
if (supported.includes("low")) return "low";
|
|
54
|
+
if (/gemini-(2\.5|[3-9])/.test(modelId.toLowerCase())) return "minimal";
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchFlags {
|
|
59
|
+
const flags: LaunchFlags = { list: false, resume: false, noSession: false, noTui: false, maxSteps: 0, message: "", tmux: false, errors: [], print: false, noSkills: false, noTools: false };
|
|
60
|
+
const rest: string[] = [];
|
|
61
|
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
62
|
+
for (let i = 0; i < args.length; i++) {
|
|
63
|
+
const a = args[i];
|
|
64
|
+
if (a === "--") {
|
|
65
|
+
rest.push(...args.slice(i + 1));
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
if (a === "--list") {
|
|
69
|
+
flags.list = true;
|
|
70
|
+
} else if (a === "-p" || a === "--print") {
|
|
71
|
+
flags.print = true;
|
|
72
|
+
flags.noTui = true;
|
|
73
|
+
} else if (a === "--tmux") {
|
|
74
|
+
flags.tmux = true;
|
|
75
|
+
} else if (a === "--worktree") {
|
|
76
|
+
const next = args[i + 1];
|
|
77
|
+
if (next && !next.startsWith("-")) {
|
|
78
|
+
flags.worktree = next;
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
} else if (a.startsWith("--worktree=")) {
|
|
82
|
+
flags.worktree = a.slice("--worktree=".length);
|
|
83
|
+
} else if (a === "--no-session") {
|
|
84
|
+
flags.noSession = true;
|
|
85
|
+
} else if (a === "--no-tui") {
|
|
86
|
+
flags.noTui = true;
|
|
87
|
+
} else if (a === "--max-steps") {
|
|
88
|
+
const n = parseInt(args[i + 1] ?? "", 10);
|
|
89
|
+
if (Number.isFinite(n) && n > 0) {
|
|
90
|
+
flags.maxSteps = n;
|
|
91
|
+
i++;
|
|
92
|
+
}
|
|
93
|
+
} else if (a.startsWith("--max-steps=")) {
|
|
94
|
+
const n = parseInt(a.slice(12), 10);
|
|
95
|
+
if (Number.isFinite(n) && n > 0) flags.maxSteps = n;
|
|
96
|
+
} else if (a === "--model") {
|
|
97
|
+
const { value, nextIndex } = takeValue(args, i, "--model=");
|
|
98
|
+
if (value) flags.model = value;
|
|
99
|
+
else flags.errors.push("--model requires a value");
|
|
100
|
+
i = nextIndex;
|
|
101
|
+
} else if (a.startsWith("--model=")) {
|
|
102
|
+
const { value } = takeValue(args, i, "--model=");
|
|
103
|
+
if (value) flags.model = value;
|
|
104
|
+
else flags.errors.push("--model requires a value");
|
|
105
|
+
} else if (a === "--provider") {
|
|
106
|
+
const { value, nextIndex } = takeValue(args, i, "--provider=");
|
|
107
|
+
const normalized = value?.toLowerCase();
|
|
108
|
+
if (isProviderName(normalized)) flags.provider = normalized;
|
|
109
|
+
else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
|
|
110
|
+
i = nextIndex;
|
|
111
|
+
} else if (a.startsWith("--provider=")) {
|
|
112
|
+
const { value } = takeValue(args, i, "--provider=");
|
|
113
|
+
const normalized = value?.toLowerCase();
|
|
114
|
+
if (isProviderName(normalized)) flags.provider = normalized;
|
|
115
|
+
else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
|
|
116
|
+
} else if (a === "--thinking") {
|
|
117
|
+
const { value, nextIndex } = takeValue(args, i, "--thinking=");
|
|
118
|
+
const normalized = value?.toLowerCase();
|
|
119
|
+
if (isThinkingLevel(normalized)) flags.thinking = normalized;
|
|
120
|
+
else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
|
|
121
|
+
i = nextIndex;
|
|
122
|
+
} else if (a.startsWith("--thinking=")) {
|
|
123
|
+
const { value } = takeValue(args, i, "--thinking=");
|
|
124
|
+
const normalized = value?.toLowerCase();
|
|
125
|
+
if (isThinkingLevel(normalized)) flags.thinking = normalized;
|
|
126
|
+
else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
|
|
127
|
+
} else if (a === "--smol" || a === "--slow" || a === "--plan") {
|
|
128
|
+
flags.modelRole = a.slice(2) as ModelRole;
|
|
129
|
+
} else if (a === "--resume" || a === "--continue" || a === "-c") {
|
|
130
|
+
flags.resume = true;
|
|
131
|
+
const next = args[i + 1];
|
|
132
|
+
if (next && UUID_REGEX.test(next)) {
|
|
133
|
+
flags.resumeId = next;
|
|
134
|
+
i++;
|
|
135
|
+
}
|
|
136
|
+
} else if (a.startsWith("--resume=") || a.startsWith("--continue=") || a.startsWith("-c=")) {
|
|
137
|
+
flags.resume = true;
|
|
138
|
+
const eqIdx = a.indexOf("=");
|
|
139
|
+
const val = a.slice(eqIdx + 1);
|
|
140
|
+
if (UUID_REGEX.test(val)) {
|
|
141
|
+
flags.resumeId = val;
|
|
142
|
+
} else {
|
|
143
|
+
rest.push(val);
|
|
144
|
+
}
|
|
145
|
+
} else if (a === "--append-system-prompt") {
|
|
146
|
+
const { value, nextIndex } = takeValue(args, i, "--append-system-prompt=");
|
|
147
|
+
if (value) {
|
|
148
|
+
flags.appendSystemPromptRaw = value;
|
|
149
|
+
} else {
|
|
150
|
+
flags.errors.push("--append-system-prompt requires a value");
|
|
151
|
+
}
|
|
152
|
+
i = nextIndex;
|
|
153
|
+
} else if (a.startsWith("--append-system-prompt=")) {
|
|
154
|
+
const { value } = takeValue(args, i, "--append-system-prompt=");
|
|
155
|
+
if (value) {
|
|
156
|
+
flags.appendSystemPromptRaw = value;
|
|
157
|
+
} else {
|
|
158
|
+
flags.errors.push("--append-system-prompt requires a value");
|
|
159
|
+
}
|
|
160
|
+
} else if (a === "--no-skills") {
|
|
161
|
+
flags.noSkills = true;
|
|
162
|
+
} else if (a === "--skills") {
|
|
163
|
+
const { value, nextIndex } = takeValue(args, i, "--skills=");
|
|
164
|
+
if (value) flags.skills = value;
|
|
165
|
+
else flags.errors.push("--skills requires a value");
|
|
166
|
+
i = nextIndex;
|
|
167
|
+
} else if (a.startsWith("--skills=")) {
|
|
168
|
+
const { value } = takeValue(args, i, "--skills=");
|
|
169
|
+
if (value) flags.skills = value;
|
|
170
|
+
else flags.errors.push("--skills requires a value");
|
|
171
|
+
} else if (a === "--no-tools") {
|
|
172
|
+
flags.noTools = true;
|
|
173
|
+
} else if (a === "--tools") {
|
|
174
|
+
const { value, nextIndex } = takeValue(args, i, "--tools=");
|
|
175
|
+
if (value) flags.tools = value;
|
|
176
|
+
else flags.errors.push("--tools requires a value");
|
|
177
|
+
i = nextIndex;
|
|
178
|
+
} else if (a.startsWith("--tools=")) {
|
|
179
|
+
const { value } = takeValue(args, i, "--tools=");
|
|
180
|
+
if (value) flags.tools = value;
|
|
181
|
+
else flags.errors.push("--tools requires a value");
|
|
182
|
+
} else if (a === "--system-prompt") {
|
|
183
|
+
const { value, nextIndex } = takeValue(args, i, "--system-prompt=");
|
|
184
|
+
if (value) flags.systemPromptRaw = value;
|
|
185
|
+
else flags.errors.push("--system-prompt requires a value");
|
|
186
|
+
i = nextIndex;
|
|
187
|
+
} else if (a.startsWith("--system-prompt=")) {
|
|
188
|
+
const { value } = takeValue(args, i, "--system-prompt=");
|
|
189
|
+
if (value) flags.systemPromptRaw = value;
|
|
190
|
+
else flags.errors.push("--system-prompt requires a value");
|
|
191
|
+
} else {
|
|
192
|
+
rest.push(a);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
flags.message = rest.join(" ").trim();
|
|
196
|
+
|
|
197
|
+
if (flags.print && !flags.message) {
|
|
198
|
+
flags.errors.push("-p/--print requires a message argument");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (flags.appendSystemPromptRaw) {
|
|
202
|
+
if (flags.appendSystemPromptRaw.startsWith("@")) {
|
|
203
|
+
const filePath = flags.appendSystemPromptRaw.slice(1);
|
|
204
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
205
|
+
try {
|
|
206
|
+
flags.appendSystemPrompt = fs.readFileSync(absPath, "utf8");
|
|
207
|
+
} catch (err) {
|
|
208
|
+
flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
flags.appendSystemPrompt = flags.appendSystemPromptRaw;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (flags.systemPromptRaw) {
|
|
215
|
+
if (flags.systemPromptRaw.startsWith("@")) {
|
|
216
|
+
const filePath = flags.systemPromptRaw.slice(1);
|
|
217
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
218
|
+
try {
|
|
219
|
+
flags.systemPrompt = fs.readFileSync(absPath, "utf8");
|
|
220
|
+
} catch (err) {
|
|
221
|
+
flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
flags.systemPrompt = flags.systemPromptRaw;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return flags;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function matchSkillGlob(pattern: string, name: string): boolean {
|
|
232
|
+
const p = pattern.toLowerCase();
|
|
233
|
+
const n = name.toLowerCase();
|
|
234
|
+
if (!p.includes("*")) {
|
|
235
|
+
return p === n;
|
|
236
|
+
}
|
|
237
|
+
const escaped = p.replace(/[.+^${}()|[\\\]]/g, "\\$&");
|
|
238
|
+
const regexStr = "^" + escaped.replace(/\*/g, ".*") + "$";
|
|
239
|
+
const regex = new RegExp(regexStr);
|
|
240
|
+
return regex.test(n);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function filterToolMap(
|
|
244
|
+
tools: Record<string, any>,
|
|
245
|
+
allowlist: string[]
|
|
246
|
+
): Record<string, any> {
|
|
247
|
+
const result: Record<string, any> = {};
|
|
248
|
+
for (const name of allowlist) {
|
|
249
|
+
if (name in tools) {
|
|
250
|
+
result[name] = tools[name];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export const TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
257
|
+
read: "read {filePath, lineRange?, raw?} — read a file; lines are prefixed `LINEhh|` (hh = 2-char content anchor; the | is a separator, not file bytes)",
|
|
258
|
+
write: "write {filePath, content} — create/overwrite a file",
|
|
259
|
+
edit: "edit {filePath, editBlock} — ≔A..B replace lines (append read anchors for safety: ≔12ab..15cd — rejected with fresh content if the lines changed); ≔A+ insert after line A; ≔$ append EOF (payload on next line). NEVER copy the `LINEhh|` prefixes into SEARCH blocks or payloads",
|
|
260
|
+
bash: "bash {command, timeoutMs?, cwd?, env?} — run a shell command (cwd: subdir; env: extra vars)",
|
|
261
|
+
find: "find {globPattern} — find files by name",
|
|
262
|
+
search: "search {pattern, globPattern?, ignoreCase?, context?, maxMatches?} — grep (context: N lines around each match)",
|
|
263
|
+
ls: "ls {dirPath} — list a directory's entries (dirs first)",
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export function buildToolProtocol(allowedTools: Set<string>): string {
|
|
267
|
+
const lines: string[] = ["You have these tools (call exactly ONE per step):"];
|
|
268
|
+
let num = 1;
|
|
269
|
+
for (const name of ["read", "write", "edit", "bash", "find", "search", "ls"]) {
|
|
270
|
+
if (allowedTools.has(name)) {
|
|
271
|
+
lines.push(`${num}. ${TOOL_DESCRIPTIONS[name]}`);
|
|
272
|
+
num++;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
lines.push(`${num}. done {reason?} — call when the task is fully implemented AND verified`);
|
|
276
|
+
lines.push("");
|
|
277
|
+
lines.push("Reply with STRICT JSON only — no code fences. You MAY include an optional leading");
|
|
278
|
+
lines.push('"reasoning" string (one short sentence on your plan, shown live to the user) before "tool":');
|
|
279
|
+
lines.push('{ "reasoning": "<one short sentence>", "tool": "<name>", "arguments": { ... } }');
|
|
280
|
+
lines.push("Tool calibration: scale calls to difficulty — one for a known fact, a few for a normal task, more only when evidence is genuinely missing. Locate before you open: search/find first, then read the hit, instead of guessing paths.");
|
|
281
|
+
return lines.join("\n");
|
|
282
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
export interface InFlightAbortHarness {
|
|
2
|
+
controller: AbortController;
|
|
3
|
+
handleSigint(): void;
|
|
4
|
+
handleData(chunk: string | Uint8Array): void;
|
|
5
|
+
dispose(): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface AbortHarnessOptions {
|
|
9
|
+
controller?: AbortController;
|
|
10
|
+
captureEsc?: boolean;
|
|
11
|
+
stdin?: {
|
|
12
|
+
isTTY?: boolean;
|
|
13
|
+
isRaw?: boolean;
|
|
14
|
+
setRawMode?(raw: boolean): void;
|
|
15
|
+
resume?(): void;
|
|
16
|
+
on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
|
|
17
|
+
off(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
|
|
18
|
+
};
|
|
19
|
+
onAbortNotice?: (message: string) => void;
|
|
20
|
+
onHardExit?: () => void;
|
|
21
|
+
/** Invoked when stray escape-sequence noise (wheel scroll etc.) arrives mid-turn. */
|
|
22
|
+
onNoise?: () => void;
|
|
23
|
+
/** Invoked when Ctrl+O (\u000f) is pressed mid-turn — the detail-view binding.
|
|
24
|
+
* Without this hook the byte would be swallowed into the buffered input queue,
|
|
25
|
+
* which is why Ctrl+O historically "did nothing" while the TUI owned stdin. */
|
|
26
|
+
onDetailKey?: () => void;
|
|
27
|
+
/** Invoked when an arrow / PageUp / PageDown key arrives mid-turn — scrolls the
|
|
28
|
+
* open Ctrl+O detail panel. dir -1 = up/back, +1 = down/forward; page = full jump. */
|
|
29
|
+
onScrollKey?: (dir: -1 | 1, page: boolean) => void;
|
|
30
|
+
/** Invoked with printable keyboard input received while the live turn owns stdin. */
|
|
31
|
+
onBufferedInput?: (chunk: string) => void;
|
|
32
|
+
/** True while the input queue is inside a bracketed paste (mid-paste chunks
|
|
33
|
+
* carry no marker and must keep routing to the queue, not the noise path). */
|
|
34
|
+
pasteActive?: () => boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Bracketed-paste markers (DECSET 2004): terminals wrap pasted text in these so
|
|
38
|
+
* an app can treat the paste as DATA instead of keystrokes — the prompt_toolkit
|
|
39
|
+
* paste contract. jeo enables the mode for the REPL TTY so a multi-line paste
|
|
40
|
+
* arrives atomically and executes one command per line, in order. */
|
|
41
|
+
export const PASTE_START = "\u001b[200~";
|
|
42
|
+
export const PASTE_END = "\u001b[201~";
|
|
43
|
+
|
|
44
|
+
/** True when a stdin chunk is ONLY backspace bytes (DEL 0x7f or BS 0x08) — i.e. a
|
|
45
|
+
* standalone Backspace keystroke with nothing else. A backspace on an EMPTY input
|
|
46
|
+
* line is a no-op edit, but some Bun readline builds turn it into a spurious `close`
|
|
47
|
+
* event, which the REPL would treat as a hard exit ("Backspace quits jeo"). The
|
|
48
|
+
* input filter swallows these when the line buffer is already empty so the byte never
|
|
49
|
+
* reaches readline and the close can't fire. */
|
|
50
|
+
export function isStandaloneBackspace(chunk: string): boolean {
|
|
51
|
+
return chunk.length > 0 && /^[\x7f\b]+$/.test(chunk);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* macOS / fixterms combo-key normalization for the boxed prompt's line editor.
|
|
56
|
+
*
|
|
57
|
+
* Bun's readline acts on Ctrl+arrow (CSI `1;5D`/`1;5C` → word jump), Home/End, and the
|
|
58
|
+
* Emacs control bytes (Ctrl+A/E/W/U/K, Meta+b/f/d, Meta+DEL) — but it does NOT act on
|
|
59
|
+
* the modifier-flagged cursor keys macOS users reach for most: Option+Left/Right (word
|
|
60
|
+
* jump, CSI `1;3D`/`1;3C`) and Cmd+Left/Right (line start/end, CSI `1;9D`/`1;9C`) are
|
|
61
|
+
* inert. Rather than racing readline for cursor state in a keypress handler, we rewrite
|
|
62
|
+
* each inert combo to the canonical control byte readline DOES act on, BEFORE it reaches
|
|
63
|
+
* readline. readline stays the single owner of `rl.line`/`rl.cursor`; the box just reads
|
|
64
|
+
* and repaints. Replacement targets are empirically verified against Bun's readline.
|
|
65
|
+
*
|
|
66
|
+
* Modifier digit in `CSI 1;<m><dir>`: 3=Alt/Option, 5=Ctrl (already handled), 9=Cmd/Super.
|
|
67
|
+
* Both the CSI form and the ESC-prefixed alt-as-meta form (`ESC ESC [ D`) are covered. */
|
|
68
|
+
export const CURSOR_COMBO_REWRITES: ReadonlyArray<readonly [string, string]> = [
|
|
69
|
+
["\u001b[1;3D", "\u001bb"], // Option+Left → word left
|
|
70
|
+
["\u001b[1;3C", "\u001bf"], // Option+Right → word right
|
|
71
|
+
["\u001b\u001b[D", "\u001bb"], // Option+Left (ESC-prefixed alt-as-meta)
|
|
72
|
+
["\u001b\u001b[C", "\u001bf"], // Option+Right (ESC-prefixed alt-as-meta)
|
|
73
|
+
["\u001b[1;9D", "\u0001"], // Cmd+Left → line start (Ctrl+A)
|
|
74
|
+
["\u001b[1;9C", "\u0005"], // Cmd+Right → line end (Ctrl+E)
|
|
75
|
+
["\u001b[127;3u", "\u0017"], // Option+Backspace (kitty CSI-u) → delete word left (Ctrl+W)
|
|
76
|
+
["\u001b[127;9u", "\u0015"], // Cmd+Backspace (kitty CSI-u) → delete to line start (Ctrl+U)
|
|
77
|
+
["\u001b[3;3~", "\u001bd"], // Option+Delete (forward) → delete word right (Meta+d)
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** First combo-key rewrite whose source sequence begins at `data[i]`, else undefined. */
|
|
81
|
+
export function matchCursorCombo(data: string, i: number): readonly [string, string] | undefined {
|
|
82
|
+
for (const pair of CURSOR_COMBO_REWRITES) if (data.startsWith(pair[0], i)) return pair;
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Apply combo-key rewrites across a plain (non-paste) input segment. Shares
|
|
87
|
+
* `matchCursorCombo` with the live input filter, so the filter and this exported
|
|
88
|
+
* helper can never diverge. */
|
|
89
|
+
export function rewriteCursorCombos(plain: string): string {
|
|
90
|
+
let out = "";
|
|
91
|
+
let i = 0;
|
|
92
|
+
while (i < plain.length) {
|
|
93
|
+
const combo = matchCursorCombo(plain, i);
|
|
94
|
+
if (combo) { out += combo[1]; i += combo[0].length; continue; }
|
|
95
|
+
out += plain[i];
|
|
96
|
+
i += 1;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface PromptInputQueue {
|
|
102
|
+
pendingLines: string[];
|
|
103
|
+
partial: string;
|
|
104
|
+
/** Complete lines that arrived inside a bracketed PASTE: intentional batch
|
|
105
|
+
* commands, served one per prompt in order. Never folded into the typed-line
|
|
106
|
+
* prefill (that contract is for keystrokes typed during a live turn). */
|
|
107
|
+
pastedLines: string[];
|
|
108
|
+
/** True while a bracketed paste spans chunks (between \x1b[200~ and \x1b[201~). */
|
|
109
|
+
inPaste: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Typed (non-paste) keystrokes: printable chars build the partial, Enter promotes
|
|
113
|
+
* it to pendingLines, backspace edits — ESC/ctrl noise segments are rejected. */
|
|
114
|
+
function feedTypedSegment(state: PromptInputQueue, segment: string): boolean {
|
|
115
|
+
if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
|
|
116
|
+
let accepted = false;
|
|
117
|
+
const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
118
|
+
for (const ch of Array.from(normalized)) {
|
|
119
|
+
if (ch === "\n") {
|
|
120
|
+
if (state.partial.length > 0) accepted = true;
|
|
121
|
+
state.pendingLines.push(state.partial);
|
|
122
|
+
state.partial = "";
|
|
123
|
+
} else if (ch === "\u007f" || ch === "\b") {
|
|
124
|
+
const chars = Array.from(state.partial);
|
|
125
|
+
chars.pop();
|
|
126
|
+
state.partial = chars.join("");
|
|
127
|
+
accepted = true;
|
|
128
|
+
} else if (ch === "\t" || ch >= " ") {
|
|
129
|
+
state.partial += ch;
|
|
130
|
+
accepted = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return accepted;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Pasted body: pure DATA — newlines split commands into pastedLines, the trailing
|
|
137
|
+
* partial stays editable, and control bytes (incl. any stray ESC from copied ANSI
|
|
138
|
+
* text) are dropped instead of being interpreted as keystrokes. */
|
|
139
|
+
function feedPasteBody(state: PromptInputQueue, body: string): boolean {
|
|
140
|
+
if (!body) return false;
|
|
141
|
+
let accepted = false;
|
|
142
|
+
const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
143
|
+
for (const ch of Array.from(normalized)) {
|
|
144
|
+
if (ch === "\n") {
|
|
145
|
+
state.pastedLines.push(state.partial);
|
|
146
|
+
state.partial = "";
|
|
147
|
+
accepted = true;
|
|
148
|
+
} else if (ch === "\t" || ch >= " ") {
|
|
149
|
+
state.partial += ch;
|
|
150
|
+
accepted = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return accepted;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function queuePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
|
|
157
|
+
if (!chunk) return false;
|
|
158
|
+
let accepted = false;
|
|
159
|
+
let rest = chunk;
|
|
160
|
+
while (rest.length > 0) {
|
|
161
|
+
if (state.inPaste) {
|
|
162
|
+
const end = rest.indexOf(PASTE_END);
|
|
163
|
+
const body = end === -1 ? rest : rest.slice(0, end);
|
|
164
|
+
if (end !== -1) state.inPaste = false;
|
|
165
|
+
rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
|
|
166
|
+
if (feedPasteBody(state, body)) accepted = true;
|
|
167
|
+
} else {
|
|
168
|
+
const start = rest.indexOf(PASTE_START);
|
|
169
|
+
const plain = start === -1 ? rest : rest.slice(0, start);
|
|
170
|
+
if (start !== -1) state.inPaste = true;
|
|
171
|
+
rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
|
|
172
|
+
if (feedTypedSegment(state, plain)) accepted = true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return accepted;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Live-turn prompt capture: printable input edits the SAME next-prompt line the
|
|
179
|
+
* idle footer will show after the turn finishes. Enter does NOT promote a hidden
|
|
180
|
+
* queue entry; it merely marks the current line as ready, so the existing input
|
|
181
|
+
* box stays the single source of truth and the user presses Enter once more at
|
|
182
|
+
* the real prompt to run it. */
|
|
183
|
+
function feedLivePromptSegment(state: PromptInputQueue, segment: string): boolean {
|
|
184
|
+
if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
|
|
185
|
+
let accepted = false;
|
|
186
|
+
const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
187
|
+
for (const ch of Array.from(normalized)) {
|
|
188
|
+
if (ch === "\n") {
|
|
189
|
+
accepted = true;
|
|
190
|
+
} else if (ch === "\u007f" || ch === "\b") {
|
|
191
|
+
const chars = Array.from(state.partial);
|
|
192
|
+
chars.pop();
|
|
193
|
+
state.partial = chars.join("");
|
|
194
|
+
accepted = true;
|
|
195
|
+
} else if (ch === "\t" || ch >= " ") {
|
|
196
|
+
state.partial += ch;
|
|
197
|
+
accepted = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return accepted;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function feedLivePromptPasteBody(state: PromptInputQueue, body: string): boolean {
|
|
204
|
+
if (!body) return false;
|
|
205
|
+
const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
206
|
+
const flattened = normalized.split("\n").map(part => part.trim()).filter(Boolean).join(" ");
|
|
207
|
+
if (!flattened) return false;
|
|
208
|
+
state.partial = state.partial ? `${state.partial} ${flattened}` : flattened;
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function captureLivePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
|
|
213
|
+
if (!chunk) return false;
|
|
214
|
+
let accepted = false;
|
|
215
|
+
let rest = chunk;
|
|
216
|
+
while (rest.length > 0) {
|
|
217
|
+
if (state.inPaste) {
|
|
218
|
+
const end = rest.indexOf(PASTE_END);
|
|
219
|
+
const body = end === -1 ? rest : rest.slice(0, end);
|
|
220
|
+
if (end !== -1) state.inPaste = false;
|
|
221
|
+
rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
|
|
222
|
+
if (feedLivePromptPasteBody(state, body)) accepted = true;
|
|
223
|
+
} else {
|
|
224
|
+
const start = rest.indexOf(PASTE_START);
|
|
225
|
+
const plain = start === -1 ? rest : rest.slice(0, start);
|
|
226
|
+
if (start !== -1) state.inPaste = true;
|
|
227
|
+
rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
|
|
228
|
+
if (feedLivePromptSegment(state, plain)) accepted = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return accepted;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* TTY "new input first" contract: fold any queued FULL lines (stray
|
|
236
|
+
* Enter-terminated buffer noise, or older persisted queues) into the editable
|
|
237
|
+
* prompt prefill instead of leaving them to auto-execute as the next prompt.
|
|
238
|
+
* Without this, stale queued lines ran BEFORE the user's fresh input — jeo
|
|
239
|
+
* appeared to "continue the previous work first". Returns the number of lines
|
|
240
|
+
* folded. Pure over the queue object — piped/non-TTY callers must NOT use this
|
|
241
|
+
* (scripted stdin relies on in-order line execution).
|
|
242
|
+
*/
|
|
243
|
+
export function restoreQueuedLinesToPrefill(state: PromptInputQueue): number {
|
|
244
|
+
const lines = state.pendingLines.splice(0, state.pendingLines.length).map(l => l.trim()).filter(Boolean);
|
|
245
|
+
if (lines.length === 0) return 0;
|
|
246
|
+
const restored = lines.join(" ");
|
|
247
|
+
state.partial = state.partial ? `${restored} ${state.partial}`.trim() : restored;
|
|
248
|
+
return lines.length;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFlightAbortHarness {
|
|
252
|
+
const controller = opts.controller ?? new AbortController();
|
|
253
|
+
const stdin = opts.stdin ?? process.stdin;
|
|
254
|
+
const captureEsc = opts.captureEsc === true && !!stdin.isTTY;
|
|
255
|
+
const wasRaw = !!stdin.isRaw;
|
|
256
|
+
let rawChanged = false;
|
|
257
|
+
|
|
258
|
+
const abortNow = (message: string) => {
|
|
259
|
+
if (controller.signal.aborted) return false;
|
|
260
|
+
opts.onAbortNotice?.(message);
|
|
261
|
+
controller.abort();
|
|
262
|
+
return true;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const handleSigint = () => {
|
|
266
|
+
if (!controller.signal.aborted) controller.abort();
|
|
267
|
+
opts.onHardExit?.();
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const handleData = (chunk: string | Uint8Array) => {
|
|
271
|
+
if (!captureEsc || controller.signal.aborted) return;
|
|
272
|
+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
273
|
+
if (text.includes(PASTE_START) || opts.pasteActive?.()) {
|
|
274
|
+
opts.onBufferedInput?.(text);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (text === "\u000f") {
|
|
278
|
+
opts.onDetailKey?.();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (text === "\u001b[A") { opts.onScrollKey?.(-1, false); return; }
|
|
282
|
+
if (text === "\u001b[B") { opts.onScrollKey?.(1, false); return; }
|
|
283
|
+
if (text === "\u001b[5~") { opts.onScrollKey?.(-1, true); return; }
|
|
284
|
+
if (text === "\u001b[6~") { opts.onScrollKey?.(1, true); return; }
|
|
285
|
+
const escAt = text.indexOf("\u001b");
|
|
286
|
+
const sigintAt = text.indexOf("\u0003");
|
|
287
|
+
const controlAt =
|
|
288
|
+
escAt === -1 ? sigintAt :
|
|
289
|
+
sigintAt === -1 ? escAt :
|
|
290
|
+
Math.min(escAt, sigintAt);
|
|
291
|
+
if (controlAt >= 0) {
|
|
292
|
+
const printablePrefix = text.slice(0, controlAt);
|
|
293
|
+
if (printablePrefix) opts.onBufferedInput?.(printablePrefix);
|
|
294
|
+
if (text[controlAt] === "\u0003") {
|
|
295
|
+
handleSigint();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (text === "\u001b") {
|
|
299
|
+
abortNow("ESC pressed — cancelling current run…");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
opts.onNoise?.();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
opts.onBufferedInput?.(text);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
process.on("SIGINT", handleSigint);
|
|
309
|
+
if (captureEsc) {
|
|
310
|
+
stdin.on("data", handleData);
|
|
311
|
+
if (stdin.setRawMode && !wasRaw) {
|
|
312
|
+
stdin.setRawMode(true);
|
|
313
|
+
rawChanged = true;
|
|
314
|
+
}
|
|
315
|
+
stdin.resume?.();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
controller,
|
|
320
|
+
handleSigint,
|
|
321
|
+
handleData,
|
|
322
|
+
dispose() {
|
|
323
|
+
process.removeListener("SIGINT", handleSigint);
|
|
324
|
+
if (captureEsc) {
|
|
325
|
+
stdin.off("data", handleData);
|
|
326
|
+
if (rawChanged) stdin.setRawMode?.(false);
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|