jeo-code 0.6.4 → 0.6.6
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 +25 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/commands/launch/flags.ts +242 -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 +176 -967
- package/src/tui/components/input-box.ts +56 -0
- package/src/tui/components/welcome.ts +28 -6
- package/src/tui/renderer.ts +6 -2
- package/src/tui/terminal.ts +7 -0
package/src/commands/launch.ts
CHANGED
|
@@ -60,9 +60,10 @@ import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } fro
|
|
|
60
60
|
import { providerPicker, renderProviderPicker } from "../tui/components/provider-picker";
|
|
61
61
|
import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff, sanitizeForTerminal } from "../tui/components/code-view";
|
|
62
62
|
import { categoryBadge } from "../tui/components/category-index";
|
|
63
|
-
import { renderInputFrame } from "../tui/components/input-box";
|
|
63
|
+
import { renderInputFrame, verticalCursorOffset } from "../tui/components/input-box";
|
|
64
|
+
|
|
64
65
|
import { renderStatusBar } from "../tui/components/status";
|
|
65
|
-
import { detectColorLevel, ColorLevel } from "../tui/components/color";
|
|
66
|
+
import { detectColorLevel, ColorLevel, visibleWidth } from "../tui/components/color";
|
|
66
67
|
import { readClipboardImage } from "../util/clipboard-image";
|
|
67
68
|
import { formatTranscript } from "../tui/components/transcript";
|
|
68
69
|
import { loadInputHistory, appendInputHistory } from "../agent/input-history";
|
|
@@ -92,438 +93,100 @@ import {
|
|
|
92
93
|
sessionPath,
|
|
93
94
|
appendCompaction,
|
|
94
95
|
} from "../agent/session";
|
|
95
|
-
import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking } from "../tui/terminal";
|
|
96
|
-
|
|
97
|
-
export interface LaunchFlags {
|
|
98
|
-
list: boolean;
|
|
99
|
-
resume: boolean;
|
|
100
|
-
resumeId?: string;
|
|
101
|
-
noSession: boolean;
|
|
102
|
-
noTui: boolean;
|
|
103
|
-
/** Explicit step cap from --max-steps; 0 = dynamic (process-driven budget that
|
|
104
|
-
* keeps extending while the turn shows progress — no hardcoded step ceiling). */
|
|
105
|
-
maxSteps: number;
|
|
106
|
-
message: string;
|
|
107
|
-
tmux: boolean;
|
|
108
|
-
worktree?: string;
|
|
109
|
-
model?: string;
|
|
110
|
-
provider?: ProviderName;
|
|
111
|
-
modelRole?: ModelRole;
|
|
112
|
-
thinking?: ThinkLevel;
|
|
113
|
-
errors: string[];
|
|
114
|
-
print?: boolean;
|
|
115
|
-
appendSystemPromptRaw?: string;
|
|
116
|
-
appendSystemPrompt?: string;
|
|
117
|
-
noSkills: boolean;
|
|
118
|
-
skills?: string;
|
|
119
|
-
noTools: boolean;
|
|
120
|
-
tools?: string;
|
|
121
|
-
systemPromptRaw?: string;
|
|
122
|
-
systemPrompt?: string;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const PROVIDER_DEFAULT: Record<ProviderName, string> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast" };
|
|
126
|
-
|
|
127
|
-
function takeValue(args: string[], index: number, inlinePrefix: string): { value?: string; nextIndex: number } {
|
|
128
|
-
const current = args[index]!;
|
|
129
|
-
if (current.startsWith(inlinePrefix)) return { value: current.slice(inlinePrefix.length), nextIndex: index };
|
|
130
|
-
const next = args[index + 1];
|
|
131
|
-
if (next && !next.startsWith("-")) return { value: next, nextIndex: index + 1 };
|
|
132
|
-
return { nextIndex: index };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function isProviderName(input: string | undefined): input is ProviderName {
|
|
136
|
-
return input === "anthropic" || input === "openai" || input === "gemini" || input === "antigravity" || input === "ollama";
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function isThinkingLevel(input: string | undefined): input is ThinkLevel {
|
|
140
|
-
return input === "minimal" || input === "low" || input === "medium" || input === "high" || input === "xhigh";
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function fastThinkingLevelForModel(modelId: string): ThinkLevel | undefined {
|
|
144
|
-
const supported = catalogMetadata(modelId)?.thinking ?? [];
|
|
145
|
-
if (supported.includes("minimal")) return "minimal";
|
|
146
|
-
if (supported.includes("low")) return "low";
|
|
147
|
-
// Fallback for the one thinking-capable family that misses a catalog entry in practice:
|
|
148
|
-
// the dynamic antigravity `gemini-3.x` variants (gemini-3.5-flash-low/-extra-low, …) the
|
|
149
|
-
// CCA backend serves but the static catalog can't enumerate. They apply a thinking budget,
|
|
150
|
-
// so /fast offers `minimal` as the fast level instead of reporting "unsupported". (openai
|
|
151
|
-
// reasoning models and *-thinking/-high/-low antigravity ids are already catalogued.)
|
|
152
|
-
if (/gemini-(2\.5|[3-9])/.test(modelId.toLowerCase())) return "minimal";
|
|
153
|
-
return undefined;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function hashString(input: string): string {
|
|
157
|
-
let hash = 2166136261;
|
|
158
|
-
for (let i = 0; i < input.length; i++) {
|
|
159
|
-
hash ^= input.charCodeAt(i);
|
|
160
|
-
hash = Math.imul(hash, 16777619);
|
|
161
|
-
}
|
|
162
|
-
return (hash >>> 0).toString(36).padStart(6, "0").slice(0, 6);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function tmuxSafeNamePart(input: string, max = 32): string {
|
|
166
|
-
const safe = input.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "value";
|
|
167
|
-
if (safe.length <= max) return safe;
|
|
168
|
-
return `${safe.slice(0, Math.max(1, max - 7))}-${hashString(input)}`;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function tmuxRuntimeSuffix(flags: LaunchFlags): string {
|
|
172
|
-
const parts: string[] = [];
|
|
173
|
-
if (flags.provider) parts.push(`provider-${flags.provider}`);
|
|
174
|
-
if (flags.model) parts.push(`model-${tmuxSafeNamePart(flags.model)}`);
|
|
175
|
-
else if (flags.modelRole) parts.push(flags.modelRole);
|
|
176
|
-
if (flags.thinking) parts.push(`think-${flags.thinking}`);
|
|
177
|
-
// Only an EXPLICIT --max-steps cap names the session; the dynamic default (0) adds nothing.
|
|
178
|
-
if (flags.maxSteps > 0) parts.push(`steps-${flags.maxSteps}`);
|
|
179
|
-
if (parts.length === 0) return "";
|
|
180
|
-
const joined = parts.join("-");
|
|
181
|
-
const suffix = joined.length <= 72 ? joined : `${joined.slice(0, 65)}-${hashString(joined)}`;
|
|
182
|
-
return `-${suffix}`;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Base tmux session name for `jeo --tmux`. Keyed on the working DIRECTORY (not just the
|
|
187
|
-
* git branch) so two different projects/worktrees on the same branch (e.g. `main`)
|
|
188
|
-
* never share a base. {@link uniqueTmuxSessionName} then makes each concurrent invocation
|
|
189
|
-
* fully independent, so a second `jeo --tmux` never attaches to (and mirrors) the first.
|
|
190
|
-
*/
|
|
191
|
-
export function tmuxSessionName(cwd: string, branch: string, flags: LaunchFlags): string {
|
|
192
|
-
const dirTag = `${tmuxSafeNamePart(path.basename(cwd) || "root", 16)}-${hashString(cwd)}`;
|
|
193
|
-
const base = branch ? `jeo-${branch}-${dirTag}` : `jeo-${dirTag}`;
|
|
194
|
-
return base + tmuxRuntimeSuffix(flags);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Count uncommitted git entries for the `⑂ <branch> ?N` footer dirty flag (gjc parity).
|
|
199
|
-
* One `git status --porcelain` spawn per CALL; callers invoke it once per turn start, not
|
|
200
|
-
* per render. Returns undefined when not a repo / git absent / clean.
|
|
201
|
-
*/
|
|
202
|
-
export function gitDirtyCount(cwd: string): number | undefined {
|
|
203
|
-
try {
|
|
204
|
-
const res = Bun.spawnSync(["git", "status", "--porcelain"], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
205
|
-
if (res.exitCode !== 0) return undefined;
|
|
206
|
-
const n = res.stdout.toString().split("\n").filter(l => l.trim().length > 0).length;
|
|
207
|
-
return n || undefined;
|
|
208
|
-
} catch {
|
|
209
|
-
return undefined;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
96
|
+
import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking, clearScreen, clearToEnd } from "../tui/terminal";
|
|
212
97
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
// `=name`: tmux 3.6 rejects bare `=name` for set-option/show-options with
|
|
306
|
-
// "no such session" even while the session is live (has-session/attach accept
|
|
307
|
-
// it). The trailing colon makes cmd-find parse `=name` as the session part —
|
|
308
|
-
// exact-matched, never prefix-matched — and resolves the session's current
|
|
309
|
-
// window for set-window-option. This was the silent failure that left
|
|
310
|
-
// `mouse on` unset, killing wheel-up scrollback in jeo-owned sessions.
|
|
311
|
-
const t = `=${target}:`;
|
|
312
|
-
const commands: TmuxProfileCommand[] = [];
|
|
313
|
-
if ((env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0") {
|
|
314
|
-
commands.push({
|
|
315
|
-
description: "enable tmux mouse scrolling (wheel-up → copy-mode over real history)",
|
|
316
|
-
args: ["set-option", "-t", t, "mouse", "on"],
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
commands.push({
|
|
320
|
-
description: "mark jeo tmux ownership",
|
|
321
|
-
args: ["set-option", "-t", t, "@jeo-profile", "1"],
|
|
322
|
-
});
|
|
323
|
-
if (meta.branch) {
|
|
324
|
-
commands.push({
|
|
325
|
-
description: "record jeo branch identity",
|
|
326
|
-
args: ["set-option", "-t", t, "@jeo-branch", meta.branch],
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
if (meta.project) {
|
|
330
|
-
commands.push({
|
|
331
|
-
description: "record jeo project identity",
|
|
332
|
-
args: ["set-option", "-t", t, "@jeo-project", meta.project],
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
if ((env.JEO_TMUX_PROFILE ?? env.JEO_TMUX_PROFILE) !== "0") {
|
|
336
|
-
commands.push(
|
|
337
|
-
{
|
|
338
|
-
description: "enable tmux clipboard integration",
|
|
339
|
-
args: ["set-option", "-t", t, "set-clipboard", "on"],
|
|
340
|
-
},
|
|
341
|
-
{
|
|
342
|
-
description: "make copy-mode selection readable",
|
|
343
|
-
args: ["set-window-option", "-t", t, "mode-style", "fg=colour231,bg=colour60"],
|
|
344
|
-
},
|
|
345
|
-
);
|
|
346
|
-
}
|
|
347
|
-
return commands;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* A `process.stdout` view whose visible-output methods become no-ops while `gated()` is
|
|
352
|
-
* true. Used as readline's `output` so that, while the boxed slash-preview footer is armed,
|
|
353
|
-
* readline's OWN prompt/echo is suppressed and only our box is visible — no duplicated raw
|
|
354
|
-
* `jeo>` line. The previous approach monkeypatched `rl._writeToOutput`, a Node internal Bun
|
|
355
|
-
* does not expose (so on Bun both inputs showed at once). Gating the shared `output` stream
|
|
356
|
-
* works on both runtimes. Our footer is written straight to `process.stdout`, never through
|
|
357
|
-
* this proxy, so it always renders. Geometry/everything else is forwarded unchanged.
|
|
358
|
-
*/
|
|
359
|
-
const GATED_OUTPUT_METHODS = new Set(["write", "cursorTo", "moveCursor", "clearLine", "clearScreenDown", "_write", "_writev"]);
|
|
360
|
-
export function gatedStdout(real: NodeJS.WriteStream, gated: () => boolean): NodeJS.WriteStream {
|
|
361
|
-
return new Proxy(real, {
|
|
362
|
-
get(target, prop, _receiver) {
|
|
363
|
-
if (typeof prop === "string" && GATED_OUTPUT_METHODS.has(prop)) {
|
|
364
|
-
return (...args: any[]) => {
|
|
365
|
-
if (gated()) {
|
|
366
|
-
const cb = args[args.length - 1]; // honor readline's write callback so it never stalls
|
|
367
|
-
if (typeof cb === "function") cb();
|
|
368
|
-
return true;
|
|
369
|
-
}
|
|
370
|
-
return (target as any)[prop](...args);
|
|
371
|
-
};
|
|
372
|
-
}
|
|
373
|
-
const value = Reflect.get(target, prop, target);
|
|
374
|
-
return typeof value === "function" ? value.bind(target) : value;
|
|
375
|
-
},
|
|
376
|
-
}) as unknown as NodeJS.WriteStream;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function firstOutputLine(output: string | undefined): string {
|
|
380
|
-
if (!output) return "";
|
|
381
|
-
const line = String(output)
|
|
382
|
-
.split("\n")
|
|
383
|
-
.map(l => l.trim())
|
|
384
|
-
.find(l => l.length > 0);
|
|
385
|
-
return line ? line.replace(/\s+/g, " ").slice(0, 140) : "";
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function streamResultSuffix(tool: string, ok: boolean, output: string | undefined): string {
|
|
389
|
-
const summary = firstOutputLine(output);
|
|
390
|
-
if (!summary) return "";
|
|
391
|
-
if (!ok || tool === "task") return ` — ${summary}`;
|
|
392
|
-
return "";
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
export function formatTaskSubEvent(e: TaskSubEvent): string {
|
|
396
|
-
const role = e.role || "subagent";
|
|
397
|
-
const roleLabel = e.index && e.total ? `${role.toUpperCase()}[${e.index}/${e.total}]` : role.toUpperCase();
|
|
398
|
-
const tokTag = e.tokens ? ` (${e.tokens.input + e.tokens.output} tok)` : "";
|
|
399
|
-
const detail = firstOutputLine(e.detail);
|
|
400
|
-
const summary = e.summary ? ` — ${e.summary}` : "";
|
|
401
|
-
// No ` step N/M` marker — step counters carry no meaning under the dynamic
|
|
402
|
-
// budget (user feedback); a tree prefix makes nested subagent activity scan as
|
|
403
|
-
// one readable branch in plain logs and TUI scrollback.
|
|
404
|
-
const badge = categoryBadge("subagent");
|
|
405
|
-
if (e.kind === "start") return `${badge} ${chalk.magenta(`▸ ${roleLabel}`)} · ${detail}`.slice(0, 240);
|
|
406
|
-
if (e.kind === "step") return ` ${badge} ${chalk.cyan(`├─ ${roleLabel}`)} · ${detail || "working"}`;
|
|
407
|
-
if (e.kind === "tool") return ` ${badge} ${e.success === false ? chalk.red("├─") : chalk.green("├─")} ${roleLabel} ${e.success === false ? chalk.red("✗") : chalk.green("✓")} ${detail || "tool"}${summary}`;
|
|
408
|
-
if (e.kind === "error") return ` ${badge} ${chalk.red("├─")} ${roleLabel} ${chalk.red("✗")} ${detail || "error"}`;
|
|
409
|
-
return `${badge} ${e.success === false ? chalk.red("└─") : chalk.green("└─")} ${roleLabel} done${tokTag}${e.success === false ? " (incomplete)" : ""}${detail ? `: ${detail}` : ""}`;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function logTaskSubEvent(e: TaskSubEvent, log: (line: string) => void = (s: string) => console.log(s)): void {
|
|
413
|
-
log(formatTaskSubEvent(e));
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Plain (non-TTY / `--no-tui`) progress sink — the cmd-mode equivalent of the live TUI, and
|
|
419
|
-
* the gjc-parity fix for "I typed a request but saw no steps/results". The old sink only
|
|
420
|
-
* logged tool RESULTS, so a turn that finished without a tool call (or before the first
|
|
421
|
-
* result) printed nothing but the final reply. This surfaces every STEP, the tool it is about
|
|
422
|
-
* to run (with the real file/command target via `summarizeForgeInvocation`), and each result —
|
|
423
|
-
* tracking the current step + pending invocation across the engine's
|
|
424
|
-
* onStep → onAssistant → onToolResult sequence.
|
|
425
|
-
*/
|
|
426
|
-
export function createStreamEvents(
|
|
427
|
-
_maxSteps: number,
|
|
428
|
-
log: (line: string) => void = (s: string) => console.log(s),
|
|
429
|
-
now: () => number = Date.now,
|
|
430
|
-
): AgentLoopEvents {
|
|
431
|
-
let pending = "";
|
|
432
|
-
let latestUsage: { inputTokens: number; outputTokens: number } | undefined;
|
|
433
|
-
const startTime = now();
|
|
434
|
-
|
|
435
|
-
return {
|
|
436
|
-
// Lazy header: nothing is printed until the tool call is known (onAssistant).
|
|
437
|
-
// A `done` / invalid reply therefore emits no progress line at all. The step
|
|
438
|
-
// NUMBER itself is never shown — meaningless under the dynamic budget.
|
|
439
|
-
onStep: () => {},
|
|
440
|
-
onAssistant: (_raw: string, invocation: { tool?: string; arguments?: unknown } | null) => {
|
|
441
|
-
const tool = typeof invocation?.tool === "string" ? invocation.tool.trim() : "";
|
|
442
|
-
if (!tool || tool === "done") return;
|
|
443
|
-
pending = summarizeForgeInvocation(tool, invocation?.arguments).title;
|
|
444
|
-
// gjc-style live status unit: step header + tool target + elapsed + token usage.
|
|
445
|
-
const elapsedMs = now() - startTime;
|
|
446
|
-
let suffix = "";
|
|
447
|
-
if (elapsedMs >= 1000) suffix += ` · ${formatDuration(elapsedMs)}`;
|
|
448
|
-
if (latestUsage) suffix += ` · ${formatUsage(latestUsage)}`;
|
|
449
|
-
log(`${categoryBadge("progress")} ${pending}${suffix ? chalk.dim(suffix) : ""}`);
|
|
450
|
-
},
|
|
451
|
-
onToolResult: (tool: string, ok: boolean, output?: string) => {
|
|
452
|
-
const label = pending || tool;
|
|
453
|
-
const mark = ok ? chalk.green("✓") : chalk.red("✗");
|
|
454
|
-
log(` ${categoryBadge(ok ? "done" : "error")} ${mark} ${label}${streamResultSuffix(tool, ok, output)}`);
|
|
455
|
-
pending = "";
|
|
456
|
-
},
|
|
457
|
-
onNotice: (msg: string) => log(` ${categoryBadge("progress")} ${chalk.yellow(msg)}`),
|
|
458
|
-
onBudget: (_limit: number, reason: string) => {
|
|
459
|
-
log(` ${categoryBadge("progress")} ${chalk.yellow(reason)}`);
|
|
460
|
-
},
|
|
461
|
-
onUsage: (usage: { inputTokens: number; outputTokens: number }) => {
|
|
462
|
-
latestUsage = usage;
|
|
463
|
-
},
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
export function shouldUseOneShotTui(noTui: boolean): boolean {
|
|
468
|
-
return LaunchTui.usable(noTui);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
export interface InFlightAbortHarness {
|
|
472
|
-
controller: AbortController;
|
|
473
|
-
handleSigint(): void;
|
|
474
|
-
handleData(chunk: string | Uint8Array): void;
|
|
475
|
-
dispose(): void;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
interface AbortHarnessOptions {
|
|
479
|
-
controller?: AbortController;
|
|
480
|
-
captureEsc?: boolean;
|
|
481
|
-
stdin?: {
|
|
482
|
-
isTTY?: boolean;
|
|
483
|
-
isRaw?: boolean;
|
|
484
|
-
setRawMode?(raw: boolean): void;
|
|
485
|
-
resume?(): void;
|
|
486
|
-
on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
|
|
487
|
-
off(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
|
|
488
|
-
};
|
|
489
|
-
onAbortNotice?: (message: string) => void;
|
|
490
|
-
onHardExit?: () => void;
|
|
491
|
-
/** Invoked when stray escape-sequence noise (wheel scroll etc.) arrives mid-turn. */
|
|
492
|
-
onNoise?: () => void;
|
|
493
|
-
/** Invoked when Ctrl+O (\u000f) is pressed mid-turn — the detail-view binding.
|
|
494
|
-
* Without this hook the byte would be swallowed into the buffered input queue,
|
|
495
|
-
* which is why Ctrl+O historically "did nothing" while the TUI owned stdin. */
|
|
496
|
-
onDetailKey?: () => void;
|
|
497
|
-
/** Invoked when an arrow / PageUp / PageDown key arrives mid-turn — scrolls the
|
|
498
|
-
* open Ctrl+O detail panel. dir -1 = up/back, +1 = down/forward; page = full jump. */
|
|
499
|
-
onScrollKey?: (dir: -1 | 1, page: boolean) => void;
|
|
500
|
-
/** Invoked with printable keyboard input received while the live turn owns stdin. */
|
|
501
|
-
onBufferedInput?: (chunk: string) => void;
|
|
502
|
-
/** True while the input queue is inside a bracketed paste (mid-paste chunks
|
|
503
|
-
* carry no marker and must keep routing to the queue, not the noise path). */
|
|
504
|
-
pasteActive?: () => boolean;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/** Bracketed-paste markers (DECSET 2004): terminals wrap pasted text in these so
|
|
508
|
-
* an app can treat the paste as DATA instead of keystrokes — the prompt_toolkit
|
|
509
|
-
* paste contract. jeo enables the mode for the REPL TTY so a multi-line paste
|
|
510
|
-
* arrives atomically and executes one command per line, in order. */
|
|
511
|
-
export const PASTE_START = "\u001b[200~";
|
|
512
|
-
export const PASTE_END = "\u001b[201~";
|
|
513
|
-
|
|
514
|
-
/** True when a stdin chunk is ONLY backspace bytes (DEL 0x7f or BS 0x08) — i.e. a
|
|
515
|
-
* standalone Backspace keystroke with nothing else. A backspace on an EMPTY input
|
|
516
|
-
* line is a no-op edit, but some Bun readline builds turn it into a spurious `close`
|
|
517
|
-
* event, which the REPL would treat as a hard exit ("Backspace quits jeo"). The
|
|
518
|
-
* input filter swallows these when the line buffer is already empty so the byte never
|
|
519
|
-
* reaches readline and the close can't fire. */
|
|
520
|
-
export function isStandaloneBackspace(chunk: string): boolean {
|
|
521
|
-
return chunk.length > 0 && /^[\x7f\b]+$/.test(chunk);
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/** gjc-parity slash-command aliases, applied once before dispatch so each real command
|
|
525
|
-
* keeps a SINGLE handler: `/login`→`/provider login`, `/settings`→`/config`,
|
|
526
|
-
* `/subagent(s)`→`/agents`. Pure rewrite that preserves any trailing args. */
|
|
98
|
+
import {
|
|
99
|
+
type LaunchFlags,
|
|
100
|
+
parseFlags,
|
|
101
|
+
matchSkillGlob,
|
|
102
|
+
filterToolMap,
|
|
103
|
+
buildToolProtocol,
|
|
104
|
+
TOOL_DESCRIPTIONS,
|
|
105
|
+
fastThinkingLevelForModel,
|
|
106
|
+
isProviderName,
|
|
107
|
+
isThinkingLevel,
|
|
108
|
+
} from "./launch/flags";
|
|
109
|
+
import {
|
|
110
|
+
tmuxSessionName,
|
|
111
|
+
gitDirtyCount,
|
|
112
|
+
allocateTmuxSession,
|
|
113
|
+
shouldEnableCurrentTmuxMouse,
|
|
114
|
+
tmuxLaunchCommand,
|
|
115
|
+
tmuxProfileCommands,
|
|
116
|
+
resolveWorktree,
|
|
117
|
+
shellQuote,
|
|
118
|
+
type TmuxCreateResult,
|
|
119
|
+
type TmuxProfileCommand,
|
|
120
|
+
} from "./launch/tmux";
|
|
121
|
+
import {
|
|
122
|
+
type InFlightAbortHarness,
|
|
123
|
+
type PromptInputQueue,
|
|
124
|
+
PASTE_START,
|
|
125
|
+
PASTE_END,
|
|
126
|
+
isStandaloneBackspace,
|
|
127
|
+
CURSOR_COMBO_REWRITES,
|
|
128
|
+
matchCursorCombo,
|
|
129
|
+
rewriteCursorCombos,
|
|
130
|
+
queuePromptInputChunk,
|
|
131
|
+
captureLivePromptInputChunk,
|
|
132
|
+
restoreQueuedLinesToPrefill,
|
|
133
|
+
createInFlightAbortHarness,
|
|
134
|
+
} from "./launch/input";
|
|
135
|
+
import {
|
|
136
|
+
gatedStdout,
|
|
137
|
+
formatTaskSubEvent,
|
|
138
|
+
logTaskSubEvent,
|
|
139
|
+
createStreamEvents,
|
|
140
|
+
shouldUseOneShotTui,
|
|
141
|
+
} from "./launch/stream";
|
|
142
|
+
import {
|
|
143
|
+
WORKFLOW_NAMES,
|
|
144
|
+
isWorkflowSkill,
|
|
145
|
+
runWorkflowEngine,
|
|
146
|
+
} from "./launch/workflow";
|
|
147
|
+
|
|
148
|
+
export {
|
|
149
|
+
type LaunchFlags,
|
|
150
|
+
parseFlags,
|
|
151
|
+
matchSkillGlob,
|
|
152
|
+
filterToolMap,
|
|
153
|
+
buildToolProtocol,
|
|
154
|
+
TOOL_DESCRIPTIONS,
|
|
155
|
+
fastThinkingLevelForModel,
|
|
156
|
+
isProviderName,
|
|
157
|
+
isThinkingLevel,
|
|
158
|
+
tmuxSessionName,
|
|
159
|
+
gitDirtyCount,
|
|
160
|
+
allocateTmuxSession,
|
|
161
|
+
shouldEnableCurrentTmuxMouse,
|
|
162
|
+
tmuxLaunchCommand,
|
|
163
|
+
tmuxProfileCommands,
|
|
164
|
+
resolveWorktree,
|
|
165
|
+
shellQuote,
|
|
166
|
+
type TmuxCreateResult,
|
|
167
|
+
type TmuxProfileCommand,
|
|
168
|
+
type InFlightAbortHarness,
|
|
169
|
+
type PromptInputQueue,
|
|
170
|
+
PASTE_START,
|
|
171
|
+
PASTE_END,
|
|
172
|
+
isStandaloneBackspace,
|
|
173
|
+
CURSOR_COMBO_REWRITES,
|
|
174
|
+
matchCursorCombo,
|
|
175
|
+
rewriteCursorCombos,
|
|
176
|
+
queuePromptInputChunk,
|
|
177
|
+
captureLivePromptInputChunk,
|
|
178
|
+
restoreQueuedLinesToPrefill,
|
|
179
|
+
createInFlightAbortHarness,
|
|
180
|
+
|
|
181
|
+
gatedStdout,
|
|
182
|
+
formatTaskSubEvent,
|
|
183
|
+
logTaskSubEvent,
|
|
184
|
+
createStreamEvents,
|
|
185
|
+
shouldUseOneShotTui,
|
|
186
|
+
WORKFLOW_NAMES,
|
|
187
|
+
isWorkflowSkill,
|
|
188
|
+
runWorkflowEngine,
|
|
189
|
+
};
|
|
527
190
|
export function normalizeSlashAlias(input: string): string {
|
|
528
191
|
if (input === "/login" || input.startsWith("/login ")) return `/provider login${input.slice("/login".length)}`;
|
|
529
192
|
if (input === "/settings") return "/config";
|
|
@@ -533,539 +196,12 @@ export function normalizeSlashAlias(input: string): string {
|
|
|
533
196
|
return input;
|
|
534
197
|
}
|
|
535
198
|
|
|
536
|
-
|
|
537
|
-
pendingLines: string[];
|
|
538
|
-
partial: string;
|
|
539
|
-
/** Complete lines that arrived inside a bracketed PASTE: intentional batch
|
|
540
|
-
* commands, served one per prompt in order. Never folded into the typed-line
|
|
541
|
-
* prefill (that contract is for keystrokes typed during a live turn). */
|
|
542
|
-
pastedLines: string[];
|
|
543
|
-
/** True while a bracketed paste spans chunks (between \x1b[200~ and \x1b[201~). */
|
|
544
|
-
inPaste: boolean;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/** Typed (non-paste) keystrokes: printable chars build the partial, Enter promotes
|
|
548
|
-
* it to pendingLines, backspace edits — ESC/ctrl noise segments are rejected. */
|
|
549
|
-
function feedTypedSegment(state: PromptInputQueue, segment: string): boolean {
|
|
550
|
-
if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
|
|
551
|
-
let accepted = false;
|
|
552
|
-
const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
553
|
-
for (const ch of Array.from(normalized)) {
|
|
554
|
-
if (ch === "\n") {
|
|
555
|
-
if (state.partial.length > 0) accepted = true;
|
|
556
|
-
state.pendingLines.push(state.partial);
|
|
557
|
-
state.partial = "";
|
|
558
|
-
} else if (ch === "\u007f" || ch === "\b") {
|
|
559
|
-
const chars = Array.from(state.partial);
|
|
560
|
-
chars.pop();
|
|
561
|
-
state.partial = chars.join("");
|
|
562
|
-
accepted = true;
|
|
563
|
-
} else if (ch === "\t" || ch >= " ") {
|
|
564
|
-
state.partial += ch;
|
|
565
|
-
accepted = true;
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
return accepted;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/** Pasted body: pure DATA — newlines split commands into pastedLines, the trailing
|
|
572
|
-
* partial stays editable, and control bytes (incl. any stray ESC from copied ANSI
|
|
573
|
-
* text) are dropped instead of being interpreted as keystrokes. */
|
|
574
|
-
function feedPasteBody(state: PromptInputQueue, body: string): boolean {
|
|
575
|
-
if (!body) return false;
|
|
576
|
-
let accepted = false;
|
|
577
|
-
const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
578
|
-
for (const ch of Array.from(normalized)) {
|
|
579
|
-
if (ch === "\n") {
|
|
580
|
-
state.pastedLines.push(state.partial);
|
|
581
|
-
state.partial = "";
|
|
582
|
-
accepted = true;
|
|
583
|
-
} else if (ch === "\t" || ch >= " ") {
|
|
584
|
-
state.partial += ch;
|
|
585
|
-
accepted = true;
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
return accepted;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
export function queuePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
|
|
592
|
-
if (!chunk) return false;
|
|
593
|
-
let accepted = false;
|
|
594
|
-
let rest = chunk;
|
|
595
|
-
while (rest.length > 0) {
|
|
596
|
-
if (state.inPaste) {
|
|
597
|
-
const end = rest.indexOf(PASTE_END);
|
|
598
|
-
const body = end === -1 ? rest : rest.slice(0, end);
|
|
599
|
-
if (end !== -1) state.inPaste = false;
|
|
600
|
-
rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
|
|
601
|
-
if (feedPasteBody(state, body)) accepted = true;
|
|
602
|
-
} else {
|
|
603
|
-
const start = rest.indexOf(PASTE_START);
|
|
604
|
-
const plain = start === -1 ? rest : rest.slice(0, start);
|
|
605
|
-
if (start !== -1) state.inPaste = true;
|
|
606
|
-
rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
|
|
607
|
-
if (feedTypedSegment(state, plain)) accepted = true;
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
return accepted;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
/** Live-turn prompt capture: printable input edits the SAME next-prompt line the
|
|
614
|
-
* idle footer will show after the turn finishes. Enter does NOT promote a hidden
|
|
615
|
-
* queue entry; it merely marks the current line as ready, so the existing input
|
|
616
|
-
* box stays the single source of truth and the user presses Enter once more at
|
|
617
|
-
* the real prompt to run it. */
|
|
618
|
-
function feedLivePromptSegment(state: PromptInputQueue, segment: string): boolean {
|
|
619
|
-
if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
|
|
620
|
-
let accepted = false;
|
|
621
|
-
const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
622
|
-
for (const ch of Array.from(normalized)) {
|
|
623
|
-
if (ch === "\n") {
|
|
624
|
-
accepted = true;
|
|
625
|
-
} else if (ch === "\u007f" || ch === "\b") {
|
|
626
|
-
const chars = Array.from(state.partial);
|
|
627
|
-
chars.pop();
|
|
628
|
-
state.partial = chars.join("");
|
|
629
|
-
accepted = true;
|
|
630
|
-
} else if (ch === "\t" || ch >= " ") {
|
|
631
|
-
state.partial += ch;
|
|
632
|
-
accepted = true;
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
return accepted;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
function feedLivePromptPasteBody(state: PromptInputQueue, body: string): boolean {
|
|
639
|
-
if (!body) return false;
|
|
640
|
-
const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
641
|
-
const flattened = normalized.split("\n").map(part => part.trim()).filter(Boolean).join(" ");
|
|
642
|
-
if (!flattened) return false;
|
|
643
|
-
state.partial = state.partial ? `${state.partial} ${flattened}` : flattened;
|
|
644
|
-
return true;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
export function captureLivePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
|
|
648
|
-
if (!chunk) return false;
|
|
649
|
-
let accepted = false;
|
|
650
|
-
let rest = chunk;
|
|
651
|
-
while (rest.length > 0) {
|
|
652
|
-
if (state.inPaste) {
|
|
653
|
-
const end = rest.indexOf(PASTE_END);
|
|
654
|
-
const body = end === -1 ? rest : rest.slice(0, end);
|
|
655
|
-
if (end !== -1) state.inPaste = false;
|
|
656
|
-
rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
|
|
657
|
-
if (feedLivePromptPasteBody(state, body)) accepted = true;
|
|
658
|
-
} else {
|
|
659
|
-
const start = rest.indexOf(PASTE_START);
|
|
660
|
-
const plain = start === -1 ? rest : rest.slice(0, start);
|
|
661
|
-
if (start !== -1) state.inPaste = true;
|
|
662
|
-
rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
|
|
663
|
-
if (feedLivePromptSegment(state, plain)) accepted = true;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
return accepted;
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
/**
|
|
670
|
-
* TTY "new input first" contract: fold any queued FULL lines (stray
|
|
671
|
-
* Enter-terminated buffer noise, or older persisted queues) into the editable
|
|
672
|
-
* prompt prefill instead of leaving them to auto-execute as the next prompt.
|
|
673
|
-
* Without this, stale queued lines ran BEFORE the user's fresh input — jeo
|
|
674
|
-
* appeared to "continue the previous work first". Returns the number of lines
|
|
675
|
-
* folded. Pure over the queue object — piped/non-TTY callers must NOT use this
|
|
676
|
-
* (scripted stdin relies on in-order line execution).
|
|
677
|
-
*/
|
|
678
|
-
export function restoreQueuedLinesToPrefill(state: PromptInputQueue): number {
|
|
679
|
-
const lines = state.pendingLines.splice(0, state.pendingLines.length).map(l => l.trim()).filter(Boolean);
|
|
680
|
-
if (lines.length === 0) return 0;
|
|
681
|
-
const restored = lines.join(" ");
|
|
682
|
-
state.partial = state.partial ? `${restored} ${state.partial}`.trim() : restored;
|
|
683
|
-
return lines.length;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFlightAbortHarness {
|
|
687
|
-
const controller = opts.controller ?? new AbortController();
|
|
688
|
-
const stdin = opts.stdin ?? process.stdin;
|
|
689
|
-
const captureEsc = opts.captureEsc === true && !!stdin.isTTY;
|
|
690
|
-
const wasRaw = !!stdin.isRaw;
|
|
691
|
-
let rawChanged = false;
|
|
692
|
-
|
|
693
|
-
const abortNow = (message: string) => {
|
|
694
|
-
if (controller.signal.aborted) return false;
|
|
695
|
-
opts.onAbortNotice?.(message);
|
|
696
|
-
controller.abort();
|
|
697
|
-
return true;
|
|
698
|
-
};
|
|
699
|
-
|
|
700
|
-
const handleSigint = () => {
|
|
701
|
-
// Ctrl+C is a hard terminal break. Older jeo softened the first press into
|
|
702
|
-
// "abort current run; press again to exit", which left users trapped in raw
|
|
703
|
-
// TTY/TUI states when they expected the terminal to stop. Abort the controller
|
|
704
|
-
// for cleanup observers, then invoke the hard-exit hook immediately.
|
|
705
|
-
if (!controller.signal.aborted) controller.abort();
|
|
706
|
-
opts.onHardExit?.();
|
|
707
|
-
};
|
|
708
|
-
|
|
709
|
-
const handleData = (chunk: string | Uint8Array) => {
|
|
710
|
-
if (!captureEsc || controller.signal.aborted) return;
|
|
711
|
-
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
712
|
-
// Bracketed paste is DATA, not keystrokes (the prompt_toolkit contract):
|
|
713
|
-
// marker-carrying and mid-paste chunks go straight to the input queue BEFORE
|
|
714
|
-
// any ESC interpretation — the markers themselves contain ESC, and a paste
|
|
715
|
-
// must never be classified as cancel/noise.
|
|
716
|
-
if (text.includes(PASTE_START) || opts.pasteActive?.()) {
|
|
717
|
-
opts.onBufferedInput?.(text);
|
|
718
|
-
return;
|
|
719
|
-
}
|
|
720
|
-
// Ctrl+O — detail view. Exact-match like the lone-ESC cancel: a raw \u000f
|
|
721
|
-
// keystroke arrives as its own chunk; embedded \u000f inside pasted/streamed
|
|
722
|
-
// data must NOT trigger the view.
|
|
723
|
-
if (text === "\u000f") {
|
|
724
|
-
opts.onDetailKey?.();
|
|
725
|
-
return;
|
|
726
|
-
}
|
|
727
|
-
// Arrow / PageUp / PageDown — scroll the open Ctrl+O detail panel. Exact-match
|
|
728
|
-
// (whole chunk) like Ctrl+O so embedded sequences in pasted/streamed data don't
|
|
729
|
-
// trigger; bracketed paste already returned above. When the panel is closed the
|
|
730
|
-
// hook is a no-op, so these keys stay inert mid-turn as before.
|
|
731
|
-
if (text === "\u001b[A") { opts.onScrollKey?.(-1, false); return; }
|
|
732
|
-
if (text === "\u001b[B") { opts.onScrollKey?.(1, false); return; }
|
|
733
|
-
if (text === "\u001b[5~") { opts.onScrollKey?.(-1, true); return; }
|
|
734
|
-
if (text === "\u001b[6~") { opts.onScrollKey?.(1, true); return; }
|
|
735
|
-
const escAt = text.indexOf("\u001b");
|
|
736
|
-
const sigintAt = text.indexOf("\u0003");
|
|
737
|
-
const controlAt =
|
|
738
|
-
escAt === -1 ? sigintAt :
|
|
739
|
-
sigintAt === -1 ? escAt :
|
|
740
|
-
Math.min(escAt, sigintAt);
|
|
741
|
-
if (controlAt >= 0) {
|
|
742
|
-
const printablePrefix = text.slice(0, controlAt);
|
|
743
|
-
if (printablePrefix) opts.onBufferedInput?.(printablePrefix);
|
|
744
|
-
if (text[controlAt] === "\u0003") {
|
|
745
|
-
handleSigint();
|
|
746
|
-
return;
|
|
747
|
-
}
|
|
748
|
-
if (text === "\u001b") {
|
|
749
|
-
abortNow("ESC pressed — cancelling current run…");
|
|
750
|
-
return;
|
|
751
|
-
}
|
|
752
|
-
opts.onNoise?.();
|
|
753
|
-
return;
|
|
754
|
-
}
|
|
755
|
-
opts.onBufferedInput?.(text);
|
|
756
|
-
};
|
|
199
|
+
const PROVIDER_DEFAULT: Record<ProviderName, string> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast" };
|
|
757
200
|
|
|
758
|
-
process.on("SIGINT", handleSigint);
|
|
759
|
-
if (captureEsc) {
|
|
760
|
-
stdin.on("data", handleData);
|
|
761
|
-
if (stdin.setRawMode && !wasRaw) {
|
|
762
|
-
stdin.setRawMode(true);
|
|
763
|
-
rawChanged = true;
|
|
764
|
-
}
|
|
765
|
-
stdin.resume?.();
|
|
766
|
-
}
|
|
767
201
|
|
|
768
|
-
return {
|
|
769
|
-
controller,
|
|
770
|
-
handleSigint,
|
|
771
|
-
handleData,
|
|
772
|
-
dispose() {
|
|
773
|
-
process.removeListener("SIGINT", handleSigint);
|
|
774
|
-
if (captureEsc) {
|
|
775
|
-
stdin.off("data", handleData);
|
|
776
|
-
if (rawChanged) stdin.setRawMode?.(false);
|
|
777
|
-
}
|
|
778
|
-
},
|
|
779
|
-
};
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
/** The exact resume command printed on REPL exit (and testable in isolation) —
|
|
783
|
-
* same convention as the `--list` handler's hint. */
|
|
784
202
|
export function formatResumeHint(sessionId: string): string {
|
|
785
203
|
return `Resume with: jeo launch --resume ${sessionId}`;
|
|
786
204
|
}
|
|
787
|
-
export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchFlags {
|
|
788
|
-
// maxSteps 0 = dynamic: the engine's process-driven budget extends itself while the
|
|
789
|
-
// turn shows progress instead of stopping at a hardcoded count (old default: 100).
|
|
790
|
-
const flags: LaunchFlags = { list: false, resume: false, noSession: false, noTui: false, maxSteps: 0, message: "", tmux: false, errors: [], print: false, noSkills: false, noTools: false };
|
|
791
|
-
const rest: string[] = [];
|
|
792
|
-
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
793
|
-
for (let i = 0; i < args.length; i++) {
|
|
794
|
-
const a = args[i];
|
|
795
|
-
if (a === "--") {
|
|
796
|
-
rest.push(...args.slice(i + 1));
|
|
797
|
-
break;
|
|
798
|
-
}
|
|
799
|
-
if (a === "--list") {
|
|
800
|
-
flags.list = true;
|
|
801
|
-
} else if (a === "-p" || a === "--print") {
|
|
802
|
-
flags.print = true;
|
|
803
|
-
flags.noTui = true;
|
|
804
|
-
} else if (a === "--tmux") {
|
|
805
|
-
flags.tmux = true;
|
|
806
|
-
} else if (a === "--worktree") {
|
|
807
|
-
const next = args[i + 1];
|
|
808
|
-
if (next && !next.startsWith("-")) {
|
|
809
|
-
flags.worktree = next;
|
|
810
|
-
i++;
|
|
811
|
-
}
|
|
812
|
-
} else if (a.startsWith("--worktree=")) {
|
|
813
|
-
flags.worktree = a.slice("--worktree=".length);
|
|
814
|
-
} else if (a === "--no-session") {
|
|
815
|
-
flags.noSession = true;
|
|
816
|
-
} else if (a === "--no-tui") {
|
|
817
|
-
flags.noTui = true;
|
|
818
|
-
} else if (a === "--max-steps") {
|
|
819
|
-
const n = parseInt(args[i + 1] ?? "", 10);
|
|
820
|
-
if (Number.isFinite(n) && n > 0) {
|
|
821
|
-
flags.maxSteps = n;
|
|
822
|
-
i++;
|
|
823
|
-
}
|
|
824
|
-
} else if (a.startsWith("--max-steps=")) {
|
|
825
|
-
const n = parseInt(a.slice(12), 10);
|
|
826
|
-
if (Number.isFinite(n) && n > 0) flags.maxSteps = n;
|
|
827
|
-
} else if (a === "--model") {
|
|
828
|
-
const { value, nextIndex } = takeValue(args, i, "--model=");
|
|
829
|
-
if (value) flags.model = value;
|
|
830
|
-
else flags.errors.push("--model requires a value");
|
|
831
|
-
i = nextIndex;
|
|
832
|
-
} else if (a.startsWith("--model=")) {
|
|
833
|
-
const { value } = takeValue(args, i, "--model=");
|
|
834
|
-
if (value) flags.model = value;
|
|
835
|
-
else flags.errors.push("--model requires a value");
|
|
836
|
-
} else if (a === "--provider") {
|
|
837
|
-
const { value, nextIndex } = takeValue(args, i, "--provider=");
|
|
838
|
-
const normalized = value?.toLowerCase();
|
|
839
|
-
if (isProviderName(normalized)) flags.provider = normalized;
|
|
840
|
-
else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
|
|
841
|
-
i = nextIndex;
|
|
842
|
-
} else if (a.startsWith("--provider=")) {
|
|
843
|
-
const { value } = takeValue(args, i, "--provider=");
|
|
844
|
-
const normalized = value?.toLowerCase();
|
|
845
|
-
if (isProviderName(normalized)) flags.provider = normalized;
|
|
846
|
-
else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
|
|
847
|
-
} else if (a === "--thinking") {
|
|
848
|
-
const { value, nextIndex } = takeValue(args, i, "--thinking=");
|
|
849
|
-
const normalized = value?.toLowerCase();
|
|
850
|
-
if (isThinkingLevel(normalized)) flags.thinking = normalized;
|
|
851
|
-
else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
|
|
852
|
-
i = nextIndex;
|
|
853
|
-
} else if (a.startsWith("--thinking=")) {
|
|
854
|
-
const { value } = takeValue(args, i, "--thinking=");
|
|
855
|
-
const normalized = value?.toLowerCase();
|
|
856
|
-
if (isThinkingLevel(normalized)) flags.thinking = normalized;
|
|
857
|
-
else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
|
|
858
|
-
} else if (a === "--smol" || a === "--slow" || a === "--plan") {
|
|
859
|
-
flags.modelRole = a.slice(2) as ModelRole;
|
|
860
|
-
} else if (a === "--resume" || a === "--continue" || a === "-c") {
|
|
861
|
-
flags.resume = true;
|
|
862
|
-
const next = args[i + 1];
|
|
863
|
-
if (next && UUID_REGEX.test(next)) {
|
|
864
|
-
flags.resumeId = next;
|
|
865
|
-
i++;
|
|
866
|
-
}
|
|
867
|
-
} else if (a.startsWith("--resume=") || a.startsWith("--continue=") || a.startsWith("-c=")) {
|
|
868
|
-
flags.resume = true;
|
|
869
|
-
const eqIdx = a.indexOf("=");
|
|
870
|
-
const val = a.slice(eqIdx + 1);
|
|
871
|
-
if (UUID_REGEX.test(val)) {
|
|
872
|
-
flags.resumeId = val;
|
|
873
|
-
} else {
|
|
874
|
-
rest.push(val);
|
|
875
|
-
}
|
|
876
|
-
} else if (a === "--append-system-prompt") {
|
|
877
|
-
const { value, nextIndex } = takeValue(args, i, "--append-system-prompt=");
|
|
878
|
-
if (value) {
|
|
879
|
-
flags.appendSystemPromptRaw = value;
|
|
880
|
-
} else {
|
|
881
|
-
flags.errors.push("--append-system-prompt requires a value");
|
|
882
|
-
}
|
|
883
|
-
i = nextIndex;
|
|
884
|
-
} else if (a.startsWith("--append-system-prompt=")) {
|
|
885
|
-
const { value } = takeValue(args, i, "--append-system-prompt=");
|
|
886
|
-
if (value) {
|
|
887
|
-
flags.appendSystemPromptRaw = value;
|
|
888
|
-
} else {
|
|
889
|
-
flags.errors.push("--append-system-prompt requires a value");
|
|
890
|
-
}
|
|
891
|
-
} else if (a === "--no-skills") {
|
|
892
|
-
flags.noSkills = true;
|
|
893
|
-
} else if (a === "--skills") {
|
|
894
|
-
const { value, nextIndex } = takeValue(args, i, "--skills=");
|
|
895
|
-
if (value) flags.skills = value;
|
|
896
|
-
else flags.errors.push("--skills requires a value");
|
|
897
|
-
i = nextIndex;
|
|
898
|
-
} else if (a.startsWith("--skills=")) {
|
|
899
|
-
const { value } = takeValue(args, i, "--skills=");
|
|
900
|
-
if (value) flags.skills = value;
|
|
901
|
-
else flags.errors.push("--skills requires a value");
|
|
902
|
-
} else if (a === "--no-tools") {
|
|
903
|
-
flags.noTools = true;
|
|
904
|
-
} else if (a === "--tools") {
|
|
905
|
-
const { value, nextIndex } = takeValue(args, i, "--tools=");
|
|
906
|
-
if (value) flags.tools = value;
|
|
907
|
-
else flags.errors.push("--tools requires a value");
|
|
908
|
-
i = nextIndex;
|
|
909
|
-
} else if (a.startsWith("--tools=")) {
|
|
910
|
-
const { value } = takeValue(args, i, "--tools=");
|
|
911
|
-
if (value) flags.tools = value;
|
|
912
|
-
else flags.errors.push("--tools requires a value");
|
|
913
|
-
} else if (a === "--system-prompt") {
|
|
914
|
-
const { value, nextIndex } = takeValue(args, i, "--system-prompt=");
|
|
915
|
-
if (value) flags.systemPromptRaw = value;
|
|
916
|
-
else flags.errors.push("--system-prompt requires a value");
|
|
917
|
-
i = nextIndex;
|
|
918
|
-
} else if (a.startsWith("--system-prompt=")) {
|
|
919
|
-
const { value } = takeValue(args, i, "--system-prompt=");
|
|
920
|
-
if (value) flags.systemPromptRaw = value;
|
|
921
|
-
else flags.errors.push("--system-prompt requires a value");
|
|
922
|
-
} else {
|
|
923
|
-
rest.push(a);
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
flags.message = rest.join(" ").trim();
|
|
927
|
-
|
|
928
|
-
if (flags.print && !flags.message) {
|
|
929
|
-
flags.errors.push("-p/--print requires a message argument");
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
if (flags.appendSystemPromptRaw) {
|
|
933
|
-
if (flags.appendSystemPromptRaw.startsWith("@")) {
|
|
934
|
-
const filePath = flags.appendSystemPromptRaw.slice(1);
|
|
935
|
-
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
936
|
-
try {
|
|
937
|
-
flags.appendSystemPrompt = fs.readFileSync(absPath, "utf8");
|
|
938
|
-
} catch (err) {
|
|
939
|
-
flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
|
|
940
|
-
}
|
|
941
|
-
} else {
|
|
942
|
-
flags.appendSystemPrompt = flags.appendSystemPromptRaw;
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
if (flags.systemPromptRaw) {
|
|
946
|
-
if (flags.systemPromptRaw.startsWith("@")) {
|
|
947
|
-
const filePath = flags.systemPromptRaw.slice(1);
|
|
948
|
-
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
949
|
-
try {
|
|
950
|
-
flags.systemPrompt = fs.readFileSync(absPath, "utf8");
|
|
951
|
-
} catch (err) {
|
|
952
|
-
flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
|
|
953
|
-
}
|
|
954
|
-
} else {
|
|
955
|
-
flags.systemPrompt = flags.systemPromptRaw;
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
return flags;
|
|
960
|
-
}
|
|
961
|
-
export function matchSkillGlob(pattern: string, name: string): boolean {
|
|
962
|
-
const p = pattern.toLowerCase();
|
|
963
|
-
const n = name.toLowerCase();
|
|
964
|
-
if (!p.includes("*")) {
|
|
965
|
-
return p === n;
|
|
966
|
-
}
|
|
967
|
-
const escaped = p.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
968
|
-
const regexStr = "^" + escaped.replace(/\*/g, ".*") + "$";
|
|
969
|
-
const regex = new RegExp(regexStr);
|
|
970
|
-
return regex.test(n);
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
export function filterToolMap(
|
|
974
|
-
tools: Record<string, any>,
|
|
975
|
-
allowlist: string[]
|
|
976
|
-
): Record<string, any> {
|
|
977
|
-
const result: Record<string, any> = {};
|
|
978
|
-
for (const name of allowlist) {
|
|
979
|
-
if (name in tools) {
|
|
980
|
-
result[name] = tools[name];
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
return result;
|
|
984
|
-
}
|
|
985
|
-
export const TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
986
|
-
read: "read {filePath, lineRange?, raw?} — read a file; lines are prefixed `LINEhh|` (hh = 2-char content anchor; the | is a separator, not file bytes)",
|
|
987
|
-
write: "write {filePath, content} — create/overwrite a file",
|
|
988
|
-
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",
|
|
989
|
-
bash: "bash {command, timeoutMs?, cwd?, env?} — run a shell command (cwd: subdir; env: extra vars)",
|
|
990
|
-
find: "find {globPattern} — find files by name",
|
|
991
|
-
search: "search {pattern, globPattern?, ignoreCase?, context?, maxMatches?} — grep (context: N lines around each match)",
|
|
992
|
-
ls: "ls {dirPath} — list a directory's entries (dirs first)",
|
|
993
|
-
};
|
|
994
|
-
|
|
995
|
-
export function buildToolProtocol(allowedTools: Set<string>): string {
|
|
996
|
-
const lines: string[] = ["You have these tools (call exactly ONE per step):"];
|
|
997
|
-
let num = 1;
|
|
998
|
-
for (const name of ["read", "write", "edit", "bash", "find", "search", "ls"]) {
|
|
999
|
-
if (allowedTools.has(name)) {
|
|
1000
|
-
lines.push(`${num}. ${TOOL_DESCRIPTIONS[name]}`);
|
|
1001
|
-
num++;
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
lines.push(`${num}. done {reason?} — call when the task is fully implemented AND verified`);
|
|
1005
|
-
lines.push("");
|
|
1006
|
-
lines.push("Reply with STRICT JSON only — no code fences. You MAY include an optional leading");
|
|
1007
|
-
lines.push('"reasoning" string (one short sentence on your plan, shown live to the user) before "tool":');
|
|
1008
|
-
lines.push('{ "reasoning": "<one short sentence>", "tool": "<name>", "arguments": { ... } }');
|
|
1009
|
-
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.");
|
|
1010
|
-
return lines.join("\n");
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
/**
|
|
1014
|
-
* Resolve a git worktree path (gjc `--worktree <path>` parity). If the path
|
|
1015
|
-
* already exists it is reused as-is; otherwise a new worktree is created on a
|
|
1016
|
-
* branch derived from the path basename. Returns the absolute worktree path.
|
|
1017
|
-
*/
|
|
1018
|
-
function resolveWorktree(cwd: string, wt: string): string {
|
|
1019
|
-
const abs = path.isAbsolute(wt) ? wt : path.resolve(cwd, wt);
|
|
1020
|
-
if (fs.existsSync(abs)) return abs;
|
|
1021
|
-
if (!Bun.which("git")) {
|
|
1022
|
-
console.error("error: --worktree requires git on PATH");
|
|
1023
|
-
process.exit(1);
|
|
1024
|
-
}
|
|
1025
|
-
const branch = (path.basename(abs).replace(/[^a-zA-Z0-9_-]/g, "-") || "jeo-wt");
|
|
1026
|
-
const withBranch = Bun.spawnSync(["git", "worktree", "add", "-b", branch, abs], {
|
|
1027
|
-
cwd,
|
|
1028
|
-
stdout: "pipe",
|
|
1029
|
-
stderr: "pipe",
|
|
1030
|
-
});
|
|
1031
|
-
if (withBranch.exitCode !== 0) {
|
|
1032
|
-
// Branch may already exist; retry attaching the existing branch.
|
|
1033
|
-
const plain = Bun.spawnSync(["git", "worktree", "add", abs], {
|
|
1034
|
-
cwd,
|
|
1035
|
-
stdout: "pipe",
|
|
1036
|
-
stderr: "pipe",
|
|
1037
|
-
});
|
|
1038
|
-
if (plain.exitCode !== 0) {
|
|
1039
|
-
console.error(
|
|
1040
|
-
`error: failed to create git worktree at ${abs}: ${withBranch.stderr.toString().trim()}`,
|
|
1041
|
-
);
|
|
1042
|
-
process.exit(1);
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
return abs;
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
/** The bundled workflow skills that run through a dedicated engine (deep-interview /
|
|
1049
|
-
* ralplan / team / ultragoal), not the ordinary agent loop. Single source of truth —
|
|
1050
|
-
* the menu listing, the dispatch guards, and the engine switch all read from here. */
|
|
1051
|
-
export const WORKFLOW_NAMES = ["deep-interview", "ralplan", "team", "ultragoal"] as const;
|
|
1052
|
-
|
|
1053
|
-
/** True when a skill name is one of the bundled workflow engines. */
|
|
1054
|
-
export function isWorkflowSkill(name: string): boolean {
|
|
1055
|
-
return (WORKFLOW_NAMES as readonly string[]).includes(name);
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
/** Dispatch a bundled workflow by name to its engine. Keeps the name→engine mapping in
|
|
1059
|
-
* ONE place so the one-shot and interactive skill runners can't drift apart. */
|
|
1060
|
-
export function runWorkflowEngine(
|
|
1061
|
-
name: string,
|
|
1062
|
-
opts: DeepInterviewEngineOptions & RalplanEngineOptions & TeamEngineOptions & UltragoalEngineOptions,
|
|
1063
|
-
): Promise<{ ok: boolean; reason?: string }> {
|
|
1064
|
-
if (name === "deep-interview") return runDeepInterviewEngine(opts);
|
|
1065
|
-
if (name === "ralplan") return runRalplanEngine(opts);
|
|
1066
|
-
if (name === "team") return runTeamEngine(opts);
|
|
1067
|
-
return runUltragoalEngine(opts);
|
|
1068
|
-
}
|
|
1069
205
|
|
|
1070
206
|
export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
1071
207
|
let cwd = process.cwd();
|
|
@@ -1868,9 +1004,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1868
1004
|
cols: terminalSize().cols,
|
|
1869
1005
|
unicode: supportsUnicode(),
|
|
1870
1006
|
color: welcomeTheme.color,
|
|
1007
|
+
center: true,
|
|
1871
1008
|
accent: accentPaint(welcomeTheme),
|
|
1872
1009
|
accentShadow: accentShadowPaint(welcomeTheme),
|
|
1873
1010
|
};
|
|
1011
|
+
// gjc-style fresh-start clear so the banner opens atop a clean screen. TTY only,
|
|
1012
|
+
// never mid-turn (scrollback flood). ponytail: add an opt-out env if anyone misses their scrollback.
|
|
1013
|
+
if (process.stdout.isTTY) process.stdout.write(clearScreen());
|
|
1874
1014
|
// Launch sweep: the DNA Claw's gradient loops seamlessly (default 2 full
|
|
1875
1015
|
// cycles, JEO_WELCOME_ANIM_CYCLES overrides), ending on the static banner.
|
|
1876
1016
|
// Truecolor TTYs only; JEO_NO_WELCOME_ANIM=1 opts out.
|
|
@@ -2083,7 +1223,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2083
1223
|
let keyFilter: PassThrough | undefined;
|
|
2084
1224
|
// Holder for the active readline so the input filter can see the current line
|
|
2085
1225
|
// buffer (used by the empty-line backspace guard below). Set after rl is created.
|
|
2086
|
-
let activeRl: { line?: string } | undefined;
|
|
1226
|
+
let activeRl: { line?: string; cursor?: number } | undefined;
|
|
1227
|
+
|
|
2087
1228
|
if (multilineInput) {
|
|
2088
1229
|
const kf = new PassThrough();
|
|
2089
1230
|
(kf as unknown as { isTTY: boolean }).isTTY = true;
|
|
@@ -2125,6 +1266,26 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2125
1266
|
if (data.startsWith(seq, i)) { out += SENTINEL; i += seq.length; matched = true; break; }
|
|
2126
1267
|
}
|
|
2127
1268
|
if (matched) continue;
|
|
1269
|
+
// Normalize macOS/fixterms combo cursor keys readline ignores (Option/Cmd+arrow,
|
|
1270
|
+
// Option/Cmd+Backspace) into the canonical control bytes it DOES act on.
|
|
1271
|
+
const combo = matchCursorCombo(data, i);
|
|
1272
|
+
if (combo) { out += combo[1]; i += combo[0].length; continue; }
|
|
1273
|
+
// Up/Down inside a multi-line / wrapped draft move the caret between the box's
|
|
1274
|
+
// visual rows (textarea feel). Only when no slash list or history panel owns ↑/↓,
|
|
1275
|
+
// and only away from the top/bottom edge — at the edge the keys fall through to
|
|
1276
|
+
// readline so ↑/↓ still recalls input history.
|
|
1277
|
+
if ((data.startsWith("\u001b[", i) || data.startsWith("\u001bO", i)) && (data[i + 2] === "A" || data[i + 2] === "B")) {
|
|
1278
|
+
const dir = data[i + 2] === "A" ? "up" : "down";
|
|
1279
|
+
const line = activeRl?.line ?? "";
|
|
1280
|
+
if (line.length > 0 && navMatches.length === 0 && promptHistoryLines == null && activeRl) {
|
|
1281
|
+
const winCols = Math.max(24, (process.stdout.columns ?? 80) - 1);
|
|
1282
|
+
const textWidth = Math.max(1, Math.max(24, Math.min(120, winCols)) - 6);
|
|
1283
|
+
const cur = typeof activeRl.cursor === "number" ? activeRl.cursor : line.length;
|
|
1284
|
+
const next = verticalCursorOffset(expandSentinel(line), cur, textWidth, dir);
|
|
1285
|
+
if (next != null) { activeRl.cursor = next; i += 3; continue; }
|
|
1286
|
+
}
|
|
1287
|
+
out += data.slice(i, i + 3); i += 3; continue;
|
|
1288
|
+
}
|
|
2128
1289
|
if (loneLfShiftEnter && data[i] === "\n") { out += SENTINEL; i += 1; continue; } // lone LF = Shift+Enter (opt-in)
|
|
2129
1290
|
out += data[i]; i += 1;
|
|
2130
1291
|
}
|
|
@@ -2225,7 +1386,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2225
1386
|
pasteLineFired = true;
|
|
2226
1387
|
return;
|
|
2227
1388
|
}
|
|
2228
|
-
|
|
1389
|
+
// A select picker (/model, /agents, …) reads keypresses directly while NO
|
|
1390
|
+
// rl.question is active, so its filter-text + selecting Enter fire an ORPHAN
|
|
1391
|
+
// `line` event here. Queuing that would auto-serve the leftover filter as the
|
|
1392
|
+
// next prompt (the "/model · /agents leaves typed text behind" bug). Drop it:
|
|
1393
|
+
// the picker owns those keystrokes and clears the readline buffer on exit.
|
|
1394
|
+
if (!interactiveTurnActive && !pickerActive) pendingStdinLines.push(l);
|
|
2229
1395
|
});
|
|
2230
1396
|
let stdinClosed = false;
|
|
2231
1397
|
let notifyStdinClosed: (() => void) | undefined;
|
|
@@ -2402,6 +1568,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2402
1568
|
// Row (within the reservation) where the real cursor was last parked; the next
|
|
2403
1569
|
// drawFooter/disarmPreview must hop back to the top from here before painting.
|
|
2404
1570
|
let footerParkedRow = 0;
|
|
1571
|
+
// The footer's last painted lines (padded to the reservation height). The resize
|
|
1572
|
+
// relayout uses these to find the frame's physical top at the live width — a full-width
|
|
1573
|
+
// line painted at an older, wider geometry reflows onto extra rows after a width shrink.
|
|
1574
|
+
let lastDrawnLines: string[] = [];
|
|
2405
1575
|
const padToFooter = (lines: string[]): string[] => {
|
|
2406
1576
|
if (lines.length >= footerRows) return lines.slice(0, footerRows);
|
|
2407
1577
|
return [...lines, ...new Array(footerRows - lines.length).fill("")];
|
|
@@ -2439,6 +1609,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2439
1609
|
s += toColumn(1) + "\x1b[?25h";
|
|
2440
1610
|
out.write(s);
|
|
2441
1611
|
footerRendered = 0;
|
|
1612
|
+
lastDrawnLines = [];
|
|
2442
1613
|
} else {
|
|
2443
1614
|
out.write("\x1b[?25h");
|
|
2444
1615
|
}
|
|
@@ -2581,6 +1752,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2581
1752
|
// and no row can spill past it — the bug fix that kept `@folder<more text>`
|
|
2582
1753
|
// typing from scrolling the input box (and prior output) off the top.
|
|
2583
1754
|
const padded = padToFooter(lines);
|
|
1755
|
+
// Remember the exact painted frame so resize/disarm can clear its real physical
|
|
1756
|
+
// footprint at the (possibly changed) live width instead of trusting logical rows.
|
|
1757
|
+
lastDrawnLines = padded;
|
|
2584
1758
|
// Pure caret moves (arrow keys) change no content — include the caret cell in
|
|
2585
1759
|
// the repaint key so they still reposition the terminal cursor.
|
|
2586
1760
|
const tRow = lines.length ? Math.min(footerCursor.row, footerRendered - 1) : 0;
|
|
@@ -3176,8 +2350,43 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3176
2350
|
lastIdleCols = cols;
|
|
3177
2351
|
lastIdleRows = rows;
|
|
3178
2352
|
try {
|
|
3179
|
-
|
|
3180
|
-
|
|
2353
|
+
// Resolution-safe relayout, anchored to the CURSOR (not the screen bottom). The
|
|
2354
|
+
// previous frame was painted at the OLD width; on resize the terminal reflows its
|
|
2355
|
+
// full-width rows AND repositions the frame — a width shrink wraps lines and floats
|
|
2356
|
+
// the frame UP by its blank tail, while a height grow leaves the frame high while
|
|
2357
|
+
// adding rows at the bottom. In every case the terminal keeps the REAL cursor on
|
|
2358
|
+
// the input caret's cell, so the caret is the one stable anchor. The old logical
|
|
2359
|
+
// disarm→arm cycle ignored this, clearing the wrong rows and scrolling the stray
|
|
2360
|
+
// frame into scrollback (the stacked status-bar corruption).
|
|
2361
|
+
//
|
|
2362
|
+
// Hop UP from the caret to the frame's physical top — `footerParkedRow` logical
|
|
2363
|
+
// rows of content sit above the caret; recompute their PHYSICAL height at the new
|
|
2364
|
+
// width (wrapped lines count for more) — then clear-to-end-of-display in one op.
|
|
2365
|
+
// This wipes the whole stray frame regardless of which way it drifted, and is
|
|
2366
|
+
// banner-safe: the hop stops exactly at the frame top (just below the static
|
|
2367
|
+
// content). ED is safe here — this path emits no bottom-margin scroll, so (unlike
|
|
2368
|
+
// the mid-turn ledger flush) tmux never copies the erased rows into history.
|
|
2369
|
+
const c = Math.max(1, cols);
|
|
2370
|
+
let abovePhysical = 0;
|
|
2371
|
+
for (let i = 0; i < footerParkedRow && i < lastDrawnLines.length; i++) {
|
|
2372
|
+
abovePhysical += Math.max(1, Math.ceil(Math.max(1, visibleWidth(lastDrawnLines[i]!)) / c));
|
|
2373
|
+
}
|
|
2374
|
+
// The caret may sit on a LOWER physical sub-row of its own (now-wrapped) line: a
|
|
2375
|
+
// long input wraps and the terminal keeps the cursor on its cell, so add the
|
|
2376
|
+
// caret's sub-row offset (caret column / width) or the hop stops below the top.
|
|
2377
|
+
const caretSubRow = Math.floor(Math.max(0, footerCursor.col - 1) / c);
|
|
2378
|
+
const hopUp = abovePhysical + caretSubRow;
|
|
2379
|
+
let s = (hopUp > 0 ? cursorUp(hopUp) : "") + toColumn(1) + clearToEnd();
|
|
2380
|
+
// Re-pin a clean reservation to the screen bottom and repaint. ED already blanked
|
|
2381
|
+
// from the frame top down to the bottom, so just position at the bottom region.
|
|
2382
|
+
footerRows = previewRowsFor(rows);
|
|
2383
|
+
s += `\x1b[${Math.max(1, rows)};1H`;
|
|
2384
|
+
if (footerRows > 1) s += cursorUp(footerRows - 1);
|
|
2385
|
+
s += toColumn(1);
|
|
2386
|
+
out.write(s);
|
|
2387
|
+
footerRendered = footerRows;
|
|
2388
|
+
footerParkedRow = 0;
|
|
2389
|
+
lastFooterKey = "";
|
|
3181
2390
|
drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
|
|
3182
2391
|
} catch { /* ignore resize render races */ }
|
|
3183
2392
|
};
|
|
@@ -3314,7 +2523,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3314
2523
|
// scrollback, and re-render the welcome banner so /clear looks like a fresh launch.
|
|
3315
2524
|
if (process.stdout.isTTY) {
|
|
3316
2525
|
disarmPreview();
|
|
3317
|
-
process.stdout.write(
|
|
2526
|
+
process.stdout.write(clearScreen()); // clear screen + scrollback + cursor home
|
|
3318
2527
|
console.log(renderWelcome(welcomeData).join("\n"));
|
|
3319
2528
|
}
|
|
3320
2529
|
console.log("(history cleared — back to the start screen)");
|
|
@@ -3419,7 +2628,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3419
2628
|
// so the resumed view reads like a fresh, intact screen.
|
|
3420
2629
|
if (process.stdout.isTTY) {
|
|
3421
2630
|
disarmPreview();
|
|
3422
|
-
process.stdout.write(
|
|
2631
|
+
process.stdout.write(clearScreen());
|
|
3423
2632
|
console.log(renderWelcome(welcomeData).join("\n"));
|
|
3424
2633
|
}
|
|
3425
2634
|
const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
|