jeo-code 0.9.10 → 0.9.12
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 +30 -0
- package/README.ja.md +5 -3
- package/README.ko.md +5 -3
- package/README.md +5 -3
- package/README.zh.md +5 -3
- package/package.json +2 -1
- package/src/agent/gjc-session-import.ts +1632 -0
- package/src/agent/session.ts +48 -7
- package/src/ai/model-cache.ts +57 -20
- package/src/ai/model-catalog.ts +93 -36
- package/src/ai/model-discovery.ts +31 -4
- package/src/commands/launch/input.ts +38 -6
- package/src/commands/launch/jobs-slash.ts +66 -0
- package/src/commands/launch/session-slash.ts +146 -2
- package/src/commands/launch.ts +29 -4
- package/src/tui/components/input-box.ts +47 -10
- package/src/tui/components/slash.ts +3 -1
- package/src/tui/components/welcome.ts +2 -2
- package/src/types/playwriter.d.ts +15 -0
- package/src/util/file-attachment.ts +26 -6
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createJobTool } from "../../agent/job-tool";
|
|
2
|
+
import type { JobRegistry } from "../../agent/job-registry";
|
|
3
|
+
|
|
4
|
+
export type JobsSlashLogger = (lines: string[]) => void;
|
|
5
|
+
|
|
6
|
+
/** Run the interactive `/jobs` view over the launch session's existing registry. */
|
|
7
|
+
export async function runJobsSlash(
|
|
8
|
+
input: string,
|
|
9
|
+
registry: JobRegistry,
|
|
10
|
+
cwd: string,
|
|
11
|
+
logLines: JobsSlashLogger = lines => console.log(lines.join("\n")),
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const tokens = input.trim().split(/\s+/).filter(Boolean).slice(1);
|
|
14
|
+
const action = (tokens.shift() ?? "list").toLowerCase();
|
|
15
|
+
const args: Record<string, unknown> = { action };
|
|
16
|
+
|
|
17
|
+
if (action === "start") {
|
|
18
|
+
const command = tokens.join(" ").trim();
|
|
19
|
+
if (!command) {
|
|
20
|
+
logLines(["Usage: /jobs start <command>"]);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
args.command = command;
|
|
24
|
+
} else if (action === "list") {
|
|
25
|
+
if (tokens.length > 0) {
|
|
26
|
+
logLines(["Usage: /jobs [list|tail|await|cancel] [job-id ...]"]);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
} else if (action === "tail" || action === "cancel" || action === "await") {
|
|
30
|
+
const ids: string[] = [];
|
|
31
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
32
|
+
const token = tokens[index]!;
|
|
33
|
+
if (token === "--timeout-ms" || token === "--timeout") {
|
|
34
|
+
const value = Number(tokens[++index]);
|
|
35
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
36
|
+
logLines([`Invalid timeout '${tokens[index] ?? ""}'. Use a positive number of milliseconds.`]);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
args.timeoutMs = value;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (token.startsWith("--timeout-ms=") || token.startsWith("--timeout=")) {
|
|
43
|
+
const value = Number(token.slice(token.indexOf("=") + 1));
|
|
44
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
45
|
+
logLines([`Invalid timeout '${token}'. Use a positive number of milliseconds.`]);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
args.timeoutMs = value;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (token.startsWith("--")) {
|
|
52
|
+
logLines([`Unknown /jobs option '${token}'.`]);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
ids.push(token);
|
|
56
|
+
}
|
|
57
|
+
if (ids.length > 0) args.ids = ids;
|
|
58
|
+
} else {
|
|
59
|
+
logLines([`Unknown /jobs action '${action}'. Use list | tail | await | cancel.`]);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const result = await createJobTool(registry)(args, cwd);
|
|
64
|
+
if (result.output) logLines(result.output.split("\n"));
|
|
65
|
+
if (!result.success && result.error) logLines([`! ${result.error}`]);
|
|
66
|
+
}
|
|
@@ -13,6 +13,8 @@ import type { Message } from "../../agent/loop";
|
|
|
13
13
|
import { createSession, deleteSession, renameSession, loadSession, listSessions, sessionPath, resolveSessionRef } from "../../agent/session";
|
|
14
14
|
import { SessionPicker, renderSessionPicker } from "../../tui/components/session-picker";
|
|
15
15
|
import { formatTranscript } from "../../tui/components/transcript";
|
|
16
|
+
import { importGjcSession, listGjcSessions, type GjcSessionLeaf } from "../../agent/gjc-session-import";
|
|
17
|
+
import { SelectList, renderSelectList, type SelectItem } from "../../tui/components/select-list";
|
|
16
18
|
|
|
17
19
|
export interface SessionSlashCtx {
|
|
18
20
|
cwd: string;
|
|
@@ -54,6 +56,56 @@ export interface SessionSlashResult {
|
|
|
54
56
|
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
interface GjcResumeRequest {
|
|
60
|
+
sessionId?: string;
|
|
61
|
+
leafId?: string;
|
|
62
|
+
anyCwd: boolean;
|
|
63
|
+
error?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseGjcResumeArgument(arg: string): GjcResumeRequest | undefined {
|
|
67
|
+
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
|
68
|
+
if (!tokens.some(token => token.toLowerCase().startsWith("gajae:"))) return undefined;
|
|
69
|
+
|
|
70
|
+
let refToken: string | undefined;
|
|
71
|
+
let anyCwd = false;
|
|
72
|
+
for (const token of tokens) {
|
|
73
|
+
if (token === "--any-cwd") {
|
|
74
|
+
anyCwd = true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (refToken !== undefined) {
|
|
78
|
+
return { anyCwd, error: `Unexpected argument '${token}'.` };
|
|
79
|
+
}
|
|
80
|
+
refToken = token;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!refToken || !refToken.toLowerCase().startsWith("gajae:")) {
|
|
84
|
+
return { anyCwd, error: "Use /resume gajae:<session-id>[#<leaf-entry-id>] [--any-cwd]." };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const reference = refToken.slice("gajae:".length);
|
|
88
|
+
if (!reference) return { anyCwd };
|
|
89
|
+
const pieces = reference.split("#");
|
|
90
|
+
if (pieces.length > 2 || !pieces[0]) {
|
|
91
|
+
return { anyCwd, error: "Malformed GJC reference. Use gajae:<session-id>[#<leaf-entry-id>]." };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
sessionId: pieces[0],
|
|
96
|
+
...(pieces[1] ? { leafId: pieces[1] } : {}),
|
|
97
|
+
anyCwd,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function gjcResumeItems(pool: GjcSessionLeaf[]): SelectItem<GjcSessionLeaf>[] {
|
|
102
|
+
return pool.map(item => ({
|
|
103
|
+
value: item,
|
|
104
|
+
label: `${item.sessionId}#${item.leafId || "(no-leaf)"}`,
|
|
105
|
+
hint: item.selected ? "selected leaf" : item.cwd,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
57
109
|
/**
|
|
58
110
|
* Handle `/session [new|drop|delete|rename <title>|resume [id]|list|info]`
|
|
59
111
|
* (default: list + current-session summary). Extracted verbatim from
|
|
@@ -126,9 +178,13 @@ export async function runSessionSlash(input: string, ctx: SessionSlashCtx): Prom
|
|
|
126
178
|
}
|
|
127
179
|
if (sub === "resume") {
|
|
128
180
|
const arg = tokens.slice(1).join(" ").trim();
|
|
129
|
-
const applyResume = async (
|
|
181
|
+
const applyResume = async (
|
|
182
|
+
rid: string,
|
|
183
|
+
loaded?: Awaited<ReturnType<typeof loadSession>>,
|
|
184
|
+
): Promise<void> => {
|
|
130
185
|
try {
|
|
131
|
-
const
|
|
186
|
+
const loadedSession = loaded ?? await loadSession(rid, cwd);
|
|
187
|
+
const { header, messages } = loadedSession;
|
|
132
188
|
history.length = 1;
|
|
133
189
|
for (const m of messages) history.push(m);
|
|
134
190
|
sessionId = rid;
|
|
@@ -181,6 +237,94 @@ export async function runSessionSlash(input: string, ctx: SessionSlashCtx): Prom
|
|
|
181
237
|
console.log(`! ${(err as Error).message}`);
|
|
182
238
|
}
|
|
183
239
|
};
|
|
240
|
+
const gjcRequest = parseGjcResumeArgument(arg);
|
|
241
|
+
if (gjcRequest) {
|
|
242
|
+
if (gjcRequest.error) {
|
|
243
|
+
console.log(`! ${gjcRequest.error}`);
|
|
244
|
+
return result();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const importOne = async (source: GjcSessionLeaf | undefined): Promise<void> => {
|
|
248
|
+
if (!source) return;
|
|
249
|
+
try {
|
|
250
|
+
const imported = await importGjcSession({
|
|
251
|
+
sessionId: gjcRequest.sessionId ?? source.sessionId,
|
|
252
|
+
...(gjcRequest.leafId || source.leafId ? { leafId: gjcRequest.leafId ?? source.leafId } : {}),
|
|
253
|
+
cwd,
|
|
254
|
+
anyCwd: gjcRequest.anyCwd,
|
|
255
|
+
});
|
|
256
|
+
console.log(
|
|
257
|
+
`(imported GJC v5 session ${imported.sourceSessionId}#${imported.sourceLeafId} as Jeo session ${imported.sessionId}${imported.reused ? " (reused)" : ""})`,
|
|
258
|
+
);
|
|
259
|
+
await applyResume(imported.sessionId);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
console.log(`! GJC /resume failed: ${(err as Error).message}`);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
if (gjcRequest.sessionId) {
|
|
266
|
+
await importOne({
|
|
267
|
+
rootPath: "",
|
|
268
|
+
sourcePath: "",
|
|
269
|
+
sessionId: gjcRequest.sessionId,
|
|
270
|
+
leafId: gjcRequest.leafId ?? "",
|
|
271
|
+
cwd,
|
|
272
|
+
selected: false,
|
|
273
|
+
});
|
|
274
|
+
return result();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let sourcePool: GjcSessionLeaf[];
|
|
278
|
+
try {
|
|
279
|
+
sourcePool = await listGjcSessions({ cwd, anyCwd: gjcRequest.anyCwd });
|
|
280
|
+
} catch (err) {
|
|
281
|
+
console.log(`! GJC /resume listing failed: ${(err as Error).message}`);
|
|
282
|
+
return result();
|
|
283
|
+
}
|
|
284
|
+
if (sourcePool.length === 0) {
|
|
285
|
+
console.log("(no GJC v5 sessions for this workspace)");
|
|
286
|
+
console.log("Usage: /resume gajae:<session-id>[#<leaf-entry-id>] [--any-cwd]");
|
|
287
|
+
return result();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
291
|
+
const picker = new SelectList(gjcResumeItems(sourcePool));
|
|
292
|
+
let selected: SelectItem<GjcSessionLeaf> | undefined;
|
|
293
|
+
await runSelectPicker(
|
|
294
|
+
(cols, rows) => renderSelectList(picker, {
|
|
295
|
+
title: "Import a GJC v5 session",
|
|
296
|
+
cols,
|
|
297
|
+
rows: Math.max(8, rows),
|
|
298
|
+
unicode: true,
|
|
299
|
+
color: true,
|
|
300
|
+
}),
|
|
301
|
+
(ch, key) => {
|
|
302
|
+
if (key?.name === "up") { picker.up(); return false; }
|
|
303
|
+
if (key?.name === "down") { picker.down(); return false; }
|
|
304
|
+
if (key?.name === "pageup") { picker.page(-1); return false; }
|
|
305
|
+
if (key?.name === "pagedown") { picker.page(1); return false; }
|
|
306
|
+
if (key?.name === "backspace") { picker.backspace(); return false; }
|
|
307
|
+
if (key?.name === "escape" || (key?.ctrl && key.name === "c")) return true;
|
|
308
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
309
|
+
selected = picker.selected();
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
if (ch && ch >= " " && !key?.ctrl && !key?.meta) picker.typeChar(ch);
|
|
313
|
+
return false;
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
await importOne(selected?.value);
|
|
317
|
+
return result();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
console.log("GJC v5 sessions — resume with /resume gajae:<session-id>[#<leaf-entry-id>]:");
|
|
321
|
+
for (const source of sourcePool.slice(0, 20)) {
|
|
322
|
+
const selected = source.selected ? " *" : "";
|
|
323
|
+
console.log(` ${source.sessionId}#${source.leafId || "(no-leaf)"}${selected} ${source.cwd}`);
|
|
324
|
+
}
|
|
325
|
+
console.log("Add --any-cwd to include sessions from other workspaces.");
|
|
326
|
+
return result();
|
|
327
|
+
}
|
|
184
328
|
if (arg) {
|
|
185
329
|
// gjc-parity: resolve `arg` as a full id OR a short id prefix before resuming.
|
|
186
330
|
const resolved = await resolveSessionRef(arg, cwd);
|
package/src/commands/launch.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { renderWelcome, playWelcomeSweep } from "../tui/components/welcome";
|
|
|
46
46
|
import { jeoEnv } from "../util/env";
|
|
47
47
|
import { renderUpdateBox } from "../tui/components/update-box";
|
|
48
48
|
import { consumeLaunchWhatsNew } from "../util/whats-new";
|
|
49
|
+
import { runWhatsNewCommand } from "./whats-new";
|
|
49
50
|
import { maybeBell } from "../util/notify";
|
|
50
51
|
import { supportsUnicode } from "../tui/components/capability";
|
|
51
52
|
import pkg from "../../package.json";
|
|
@@ -225,6 +226,7 @@ import {
|
|
|
225
226
|
} from "./launch/code-slash";
|
|
226
227
|
import { handleUndoSlash } from "./launch/git-slash";
|
|
227
228
|
import { runSessionSlash } from "./launch/session-slash";
|
|
229
|
+
import { runJobsSlash } from "./launch/jobs-slash";
|
|
228
230
|
import { runModelSlash } from "./launch/model-slash";
|
|
229
231
|
import { runRouteSlash } from "./launch/route-slash";
|
|
230
232
|
import { runComputerSlash } from "./launch/computer-slash";
|
|
@@ -1396,7 +1398,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1396
1398
|
!chunk.includes(PASTE_END);
|
|
1397
1399
|
const captured = queueBusyInput?.(chunk) ?? false;
|
|
1398
1400
|
if (typedEnter) {
|
|
1399
|
-
|
|
1401
|
+
// Expand the sentinel here too: a mid-turn paste keeps its line breaks in the
|
|
1402
|
+
// draft, and the steering inbox / command dispatcher both take REAL text — a
|
|
1403
|
+
// raw private-use sentinel would otherwise reach the model verbatim.
|
|
1404
|
+
const line = expandSentinel(queueBusySnapshot?.().text ?? "").trim();
|
|
1400
1405
|
if (line) {
|
|
1401
1406
|
// A mid-turn /command or $skill is NOT a query for the model — steering it
|
|
1402
1407
|
// would send the literal "/model" / "$skill" text to the LLM. Recognize it
|
|
@@ -1438,7 +1443,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1438
1443
|
// inbox and surfaces as a `user` card (above). JEO_NO_LIVE_DRAFT=1 opts out.
|
|
1439
1444
|
if (captured && jeoEnv("NO_LIVE_DRAFT") !== "1") {
|
|
1440
1445
|
const draft = queueBusySnapshot?.().text ?? "";
|
|
1441
|
-
|
|
1446
|
+
// The live box renders REAL text: expand the sentinel (as the idle box and the
|
|
1447
|
+
// highlight pass below already do) so a mid-turn pasted block shows as separate
|
|
1448
|
+
// rows instead of a run of unprintable private-use glyphs.
|
|
1449
|
+
tui.setLivePromptInput(expandSentinel(draft));
|
|
1442
1450
|
// Mid-turn command preview: as you type a /command or $skill DURING a turn,
|
|
1443
1451
|
// show its matches above the input box so command input visibly reacts
|
|
1444
1452
|
// (idle-prompt parity). Cleared the moment the draft stops being command-shaped.
|
|
@@ -2416,7 +2424,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2416
2424
|
// protocol nor xterm modifyOtherKeys. JEO_NO_MULTILINE=1 fully disables the
|
|
2417
2425
|
// filter (reads stdin directly).
|
|
2418
2426
|
const multilineInput = !!process.stdin.isTTY && jeoEnv("NO_MULTILINE") !== "1";
|
|
2419
|
-
|
|
2427
|
+
// tmux-owned panes can translate the plain Enter key to LF after the launcher's
|
|
2428
|
+
// keyboard modes are enabled. In that mode LF must remain readline's submit key;
|
|
2429
|
+
// explicit Shift+Enter escape sequences still become MULTILINE_SENTINEL below.
|
|
2430
|
+
const loneLfShiftEnter =
|
|
2431
|
+
jeoEnv("MULTILINE") !== "0" && jeoEnv("TMUX_LAUNCHED") !== "1";
|
|
2420
2432
|
const expandSentinel = (s: string): string => (multilineInput ? s.split(SENTINEL).join("\n") : s);
|
|
2421
2433
|
// Prompt-scoped process listeners (stdin data/keypress, stdout resize). Registered
|
|
2422
2434
|
// once per launch but previously anonymous and never removed — benign for a single
|
|
@@ -2551,7 +2563,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2551
2563
|
// (the user explicitly invoked them — no second Enter).
|
|
2552
2564
|
const pendingMidTurnCommands: string[] = [];
|
|
2553
2565
|
const queuedPromptInput: PromptInputQueue = { pendingLines: pendingStdinLines, partial: startupDraft ?? "", pastedLines: [], inPaste: false };
|
|
2554
|
-
|
|
2566
|
+
// `multilineInput` gates the SENTINEL contract end to end: a mid-turn paste keeps its
|
|
2567
|
+
// line breaks only when the submit path expands them again (it always does unless
|
|
2568
|
+
// JEO_NO_MULTILINE=1, which degrades the same paste to space-joined text).
|
|
2569
|
+
queueBusyInput = (chunk: string) => captureLivePromptInputChunk(queuedPromptInput, chunk, { multiline: multilineInput });
|
|
2555
2570
|
queueBusyPasteActive = () => queuedPromptInput.inPaste;
|
|
2556
2571
|
queueBusySnapshot = () => ({
|
|
2557
2572
|
text: queuedPromptInput.partial,
|
|
@@ -4323,6 +4338,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4323
4338
|
logLines(handoffLines(res, process.stdout.columns));
|
|
4324
4339
|
continue;
|
|
4325
4340
|
}
|
|
4341
|
+
if (input === "/changelog" || input.startsWith("/changelog ")) {
|
|
4342
|
+
const args = input.slice("/changelog".length).trim().split(/\s+/).filter(Boolean)
|
|
4343
|
+
.map(arg => arg === "--full" ? "--all" : arg);
|
|
4344
|
+
await runWhatsNewCommand(args);
|
|
4345
|
+
continue;
|
|
4346
|
+
}
|
|
4347
|
+
if (input === "/jobs" || input.startsWith("/jobs ")) {
|
|
4348
|
+
await runJobsSlash(input, jobRegistry, cwd, lines => logLines(lines));
|
|
4349
|
+
continue;
|
|
4350
|
+
}
|
|
4326
4351
|
if (input === "/session" || input.startsWith("/session ")) {
|
|
4327
4352
|
const result = await runSessionSlash(input, {
|
|
4328
4353
|
cwd, history, noSession: flags.noSession, sessionId, sessionModel, rl,
|
|
@@ -11,7 +11,8 @@ export interface InputBoxOptions {
|
|
|
11
11
|
attachmentLabel?: string;
|
|
12
12
|
placeholder?: string;
|
|
13
13
|
maxBodyRows?: number;
|
|
14
|
-
/** Caret offset
|
|
14
|
+
/** Caret offset into `line` in UTF-16 CODE UNITS — exactly `rl.cursor`'s unit, so an
|
|
15
|
+
* emoji/astral character before the caret no longer shifts it. Defaults to end. */
|
|
15
16
|
cursor?: number;
|
|
16
17
|
/** Accent painter for the border + `>` prompt mark (theme accent); default red/blue. */
|
|
17
18
|
accent?: (s: string) => string;
|
|
@@ -47,6 +48,13 @@ export interface InputFrame {
|
|
|
47
48
|
* caret's character offset to its (row, columnWidth) cell in the same pass — so the
|
|
48
49
|
* box prompt can place the real terminal cursor exactly where the next glyph lands,
|
|
49
50
|
* and arrow-key movement (readline updates rl.cursor) repositions it visibly.
|
|
51
|
+
*
|
|
52
|
+
* `cursor` is a UTF-16 CODE-UNIT offset — the same unit `rl.cursor` uses. Iteration is
|
|
53
|
+
* per CODE POINT (so an astral char/emoji is one glyph of its own display width), and
|
|
54
|
+
* the caret is matched against a parallel code-unit counter: without that, every
|
|
55
|
+
* surrogate pair BEFORE the caret shifted the painted caret one column right of the
|
|
56
|
+
* real insertion point (the "이모지가 있으면 입력 포인트가 밀려 보임" bug).
|
|
57
|
+
* `highlights` ranges stay CODE-POINT indexed (see `triggerHighlight` in launch.ts).
|
|
50
58
|
*/
|
|
51
59
|
function wrapWithCursor(
|
|
52
60
|
text: string,
|
|
@@ -59,8 +67,11 @@ function wrapWithCursor(
|
|
|
59
67
|
let curW = 0;
|
|
60
68
|
let row = 0;
|
|
61
69
|
let col = 0;
|
|
62
|
-
const
|
|
63
|
-
const
|
|
70
|
+
const src = text.replace(/\r/g, "");
|
|
71
|
+
const chars = Array.from(src);
|
|
72
|
+
const pos = Math.max(0, Math.min(cursor, src.length));
|
|
73
|
+
let unit = 0; // UTF-16 offset of chars[i], matching `pos`
|
|
74
|
+
let placed = false;
|
|
64
75
|
for (let i = 0; i <= chars.length; i++) {
|
|
65
76
|
const ch = i < chars.length ? chars[i]! : "";
|
|
66
77
|
const rendered = ch === "\t" ? " " : ch;
|
|
@@ -71,7 +82,10 @@ function wrapWithCursor(
|
|
|
71
82
|
cur = "";
|
|
72
83
|
curW = 0;
|
|
73
84
|
}
|
|
74
|
-
|
|
85
|
+
// `>=` (not `===`) so a caret offset that lands INSIDE a surrogate pair — readline
|
|
86
|
+
// never parks there, but a resized/derived offset can — still resolves to a cell.
|
|
87
|
+
if (!placed && unit >= pos) {
|
|
88
|
+
placed = true;
|
|
75
89
|
row = rows.length;
|
|
76
90
|
col = curW;
|
|
77
91
|
}
|
|
@@ -79,14 +93,20 @@ function wrapWithCursor(
|
|
|
79
93
|
rows.push(cur);
|
|
80
94
|
cur = "";
|
|
81
95
|
curW = 0;
|
|
96
|
+
unit += 1;
|
|
82
97
|
continue;
|
|
83
98
|
}
|
|
84
99
|
if (ch !== "") {
|
|
85
100
|
const hl = highlights?.find(r => i >= r.start && i < r.end);
|
|
86
101
|
cur += hl ? hl.paint(rendered) : rendered;
|
|
87
102
|
curW += w;
|
|
103
|
+
unit += ch.length;
|
|
88
104
|
}
|
|
89
105
|
}
|
|
106
|
+
if (!placed) {
|
|
107
|
+
row = rows.length;
|
|
108
|
+
col = curW;
|
|
109
|
+
}
|
|
90
110
|
rows.push(cur);
|
|
91
111
|
return { rows, row, col };
|
|
92
112
|
}
|
|
@@ -202,9 +222,12 @@ export function renderInputBox(line: string, opts: InputBoxOptions = {}): string
|
|
|
202
222
|
return renderInputFrame(line, opts).lines;
|
|
203
223
|
}
|
|
204
224
|
/** Visual (row, display-col) of every caret position 0..N of `text` wrapped at `width`,
|
|
205
|
-
* using the SAME wrapping rule as the input box (`wrapWithCursor`).
|
|
206
|
-
*
|
|
207
|
-
*
|
|
225
|
+
* using the SAME wrapping rule as the input box (`wrapWithCursor`). Indexing is by
|
|
226
|
+
* UTF-16 CODE UNIT — the unit `rl.cursor` uses — so `cells[rl.cursor]` is always the
|
|
227
|
+
* cell readline's caret actually sits on, even in text containing astral characters
|
|
228
|
+
* (emoji). A surrogate PAIR contributes two entries with the SAME cell: the pair's
|
|
229
|
+
* start, plus the (never-parked-on) mid-pair offset, so every later index stays aligned
|
|
230
|
+
* with the string's own offsets. */
|
|
208
231
|
export interface CaretCell {
|
|
209
232
|
row: number;
|
|
210
233
|
col: number;
|
|
@@ -221,16 +244,29 @@ export function caretCells(text: string, width: number): CaretCell[] {
|
|
|
221
244
|
// exact order wrapWithCursor uses, so cell rows match the rendered box rows.
|
|
222
245
|
if (w > 0 && curW + w > width && curW > 0) { row += 1; curW = 0; }
|
|
223
246
|
cells.push({ row, col: curW });
|
|
247
|
+
// Keep one entry PER CODE UNIT: the extra entry for a surrogate pair's low half
|
|
248
|
+
// repeats the pair's own cell, so it can never render (or return) a half-character
|
|
249
|
+
// caret position while keeping `cells[i]` addressable by a raw string offset.
|
|
250
|
+
for (let extra = 1; extra < ch.length; extra++) cells.push({ row, col: curW });
|
|
224
251
|
if (ch === "\n") { row += 1; curW = 0; continue; }
|
|
225
252
|
if (ch !== "") curW += w;
|
|
226
253
|
}
|
|
227
254
|
return cells;
|
|
228
255
|
}
|
|
229
256
|
|
|
257
|
+
/** Snap a derived caret offset off the low half of a surrogate pair (a full character is
|
|
258
|
+
* the smallest unit the caret may sit on) — `caretCells` deliberately keeps those
|
|
259
|
+
* offsets addressable, so every offset RETURNED to readline passes through here. */
|
|
260
|
+
function snapToCharBoundary(text: string, offset: number): number {
|
|
261
|
+
const code = text.charCodeAt(offset);
|
|
262
|
+
return offset > 0 && code >= 0xdc00 && code <= 0xdfff ? offset - 1 : offset;
|
|
263
|
+
}
|
|
264
|
+
|
|
230
265
|
/** New caret offset after an Up/Down move within the wrapped input box, keeping the
|
|
231
266
|
* display column (textarea convention: snap to the nearest column ≤ the current one on the
|
|
232
267
|
* target row). Returns null when already on the top row (Up) or bottom row (Down), so the
|
|
233
|
-
* caller can fall through to readline's input-history recall.
|
|
268
|
+
* caller can fall through to readline's input-history recall. `cursor` and the returned
|
|
269
|
+
* offset are UTF-16 code units (`rl.cursor`'s unit), never split a surrogate pair. */
|
|
234
270
|
export function verticalCursorOffset(
|
|
235
271
|
text: string,
|
|
236
272
|
cursor: number,
|
|
@@ -255,7 +291,8 @@ export function verticalCursorOffset(
|
|
|
255
291
|
// Largest column not past the current one — the standard column-preserving snap.
|
|
256
292
|
if (c <= curCol && c > bestCol) { best = p; bestCol = c; }
|
|
257
293
|
}
|
|
258
|
-
|
|
294
|
+
const target = best !== -1 ? best : firstOnRow;
|
|
295
|
+
return snapToCharBoundary(text, target);
|
|
259
296
|
}
|
|
260
297
|
/** Caret offset at the START or END of the VISUAL ROW containing `cursor`, using the
|
|
261
298
|
* SAME wrapping rule as the input box (`caretCells`/`wrapWithCursor`). This is the
|
|
@@ -282,5 +319,5 @@ export function rowBoundaryOffset(
|
|
|
282
319
|
if (p < start) start = p;
|
|
283
320
|
if (p > end) end = p;
|
|
284
321
|
}
|
|
285
|
-
return edge === "start" ? start : end;
|
|
322
|
+
return snapToCharBoundary(text, edge === "start" ? start : end);
|
|
286
323
|
}
|
|
@@ -27,7 +27,8 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
|
|
|
27
27
|
{ command: "/drop", usage: "/drop", description: "Delete the current session and start a new one", group: "session" },
|
|
28
28
|
{ command: "/session", usage: "/session [info|delete]", description: "Show current session info, or delete it", group: "session" },
|
|
29
29
|
{ command: "/rename", usage: "/rename <title>", description: "Rename the current session", group: "session" },
|
|
30
|
-
{ command: "/resume", usage: "/resume [id]", description: "Resume a saved session
|
|
30
|
+
{ command: "/resume", usage: "/resume [id|gajae:<session-id>[#<leaf>]] [--any-cwd]", description: "Resume a saved session or import a read-only GJC v5 session", group: "session" },
|
|
31
|
+
{ command: "/changelog", usage: "/changelog [--full]", description: "Show recent release notes (use --full for the complete changelog)", group: "system" },
|
|
31
32
|
{ command: "/retry", usage: "/retry", description: "Retry the last request", group: "session" },
|
|
32
33
|
{ command: "/undo", usage: "/undo", description: "Undo the last jeo auto-commit and restore the working tree", group: "session" },
|
|
33
34
|
{ command: "/history", usage: "/history [n|all]", description: "Re-print the worked turn history (prompts, tool steps, replies) into scrollback", group: "session" },
|
|
@@ -53,6 +54,7 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
|
|
|
53
54
|
{ command: "/search", usage: "/search <pat> [glob]", description: "Search the repo for a pattern", group: "code" },
|
|
54
55
|
|
|
55
56
|
{ command: "/sessions", usage: "/sessions", description: "List saved sessions", group: "session" },
|
|
57
|
+
{ command: "/jobs", usage: "/jobs [list|tail|await|cancel]", description: "List, inspect, await, or cancel background jobs in this session", group: "system" },
|
|
56
58
|
{ command: "/usage", usage: "/usage", description: "Show cumulative token usage for this session", group: "system" },
|
|
57
59
|
{ command: "/context", usage: "/context", description: "Show context token usage breakdown", group: "system" },
|
|
58
60
|
{ command: "/tools", usage: "/tools", description: "Show the tools currently visible to the agent", group: "system" },
|
|
@@ -9,8 +9,8 @@ import { parseChangelogSections, changelogText } from "../../util/whats-new";
|
|
|
9
9
|
function getLatestChangelogItems(): { version: string; items: string[] } {
|
|
10
10
|
try {
|
|
11
11
|
const sections = parseChangelogSections(changelogText);
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
const latest = sections.find(section => section.groups.some(group => group.items.length > 0));
|
|
13
|
+
if (latest) {
|
|
14
14
|
const items: string[] = [];
|
|
15
15
|
for (const g of latest.groups) {
|
|
16
16
|
for (const item of g.items) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// src/types/playwriter.d.ts
|
|
2
|
+
// Declaration file to allow TypeScript to recognize the optional Playwriter CLI module.
|
|
3
|
+
// This is only needed for the example automation script in scripts/playwriter-apply.ts.
|
|
4
|
+
// The actual Playwriter package is expected to be installed globally or run via npx.
|
|
5
|
+
|
|
6
|
+
declare module "playwriter" {
|
|
7
|
+
export class Playwriter {
|
|
8
|
+
/** Connect to the running Chrome extension bridge */
|
|
9
|
+
connect(): Promise<void>;
|
|
10
|
+
/** Open a new page (tab) in the connected browser */
|
|
11
|
+
newPage(): Promise<any>;
|
|
12
|
+
/** Disconnect from the bridge */
|
|
13
|
+
disconnect(): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* Only paths with a known image extension are considered, so ordinary prose is
|
|
14
14
|
* never mistaken for a file. Non-image / unreadable paths are left untouched.
|
|
15
15
|
*/
|
|
16
|
+
import * as os from "node:os";
|
|
17
|
+
import * as path from "node:path";
|
|
16
18
|
import { readFile, stat } from "node:fs/promises";
|
|
17
19
|
import type { ImageAttachment } from "../ai/types";
|
|
18
20
|
|
|
@@ -108,13 +110,16 @@ export function attachmentFromImageBytes(bytes: Uint8Array): ImageAttachment | n
|
|
|
108
110
|
* Decode one drag-and-drop path token into a usable filesystem path:
|
|
109
111
|
* - strips matching single/double quotes,
|
|
110
112
|
* - unescapes backslash-escaped characters (macOS escapes spaces as `\ `),
|
|
111
|
-
* - resolves a `file://` URI (with percent-decoding)
|
|
113
|
+
* - resolves a `file://` URI (with percent-decoding),
|
|
114
|
+
* - expands a leading `~` / `~/` to the home directory (gjc parity: a TYPED or
|
|
115
|
+
* shell-copied `~/Downloads/shot.png` is a real, attachable path — without this it
|
|
116
|
+
* silently failed to resolve and the raw text was sent to the model instead).
|
|
112
117
|
* Returns the cleaned path.
|
|
113
118
|
*/
|
|
114
|
-
export function decodeDroppedPath(token: string): string {
|
|
119
|
+
export function decodeDroppedPath(token: string, home: string = os.homedir()): string {
|
|
115
120
|
let s = token.trim();
|
|
116
121
|
if ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"'))) {
|
|
117
|
-
return s.slice(1, -1);
|
|
122
|
+
return expandHomePath(s.slice(1, -1), home);
|
|
118
123
|
}
|
|
119
124
|
if (s.startsWith("file://")) {
|
|
120
125
|
let rest = s.slice("file://".length);
|
|
@@ -123,7 +128,15 @@ export function decodeDroppedPath(token: string): string {
|
|
|
123
128
|
try { return decodeURIComponent(rest); } catch { return rest; }
|
|
124
129
|
}
|
|
125
130
|
// Bare token: unescape `\<char>` (shell-style drag escaping).
|
|
126
|
-
return s.replace(/\\(.)/g, "$1");
|
|
131
|
+
return expandHomePath(s.replace(/\\(.)/g, "$1"), home);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `~` / `~/rest` → the home directory. A bare `~user` form is left alone (jeo cannot
|
|
135
|
+
* resolve another account's home portably). */
|
|
136
|
+
function expandHomePath(p: string, home: string): string {
|
|
137
|
+
if (p === "~") return home;
|
|
138
|
+
if (p.startsWith("~/")) return path.join(home, p.slice(2));
|
|
139
|
+
return p;
|
|
127
140
|
}
|
|
128
141
|
|
|
129
142
|
export interface PathToken {
|
|
@@ -222,7 +235,14 @@ export async function attachImagePaths(
|
|
|
222
235
|
out += text.slice(cursor);
|
|
223
236
|
if (images.length === 0) return { text: out, images, cursor: out.length };
|
|
224
237
|
// Collapse the spacing terminals add around a dropped path so the swapped-in tag
|
|
225
|
-
// (and the caret parked after it) is not pushed several columns to the right
|
|
226
|
-
|
|
238
|
+
// (and the caret parked after it) is not pushed several columns to the right, then
|
|
239
|
+
// guarantee EXACTLY ONE trailing space when the last tag ends the draft — identical
|
|
240
|
+
// to `insertImageTag`'s Ctrl+V contract. Without it `normalizeImageTags`'s trim left
|
|
241
|
+
// the caret hard against `]` and the next typed word glued onto the tag
|
|
242
|
+
// ("[image #1]please"); with it, both attach paths behave the same.
|
|
243
|
+
let normalized = normalizeImageTags(out);
|
|
244
|
+
const lastTag = `[image #${n - 1}]`;
|
|
245
|
+
const lastIdx = normalized.indexOf(lastTag);
|
|
246
|
+
if (lastIdx >= 0 && lastIdx + lastTag.length >= normalized.length) normalized += " ";
|
|
227
247
|
return { text: normalized, images, cursor: caretAfterTag(normalized, n - 1) };
|
|
228
248
|
}
|