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.
@@ -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 (rid: string): Promise<void> => {
181
+ const applyResume = async (
182
+ rid: string,
183
+ loaded?: Awaited<ReturnType<typeof loadSession>>,
184
+ ): Promise<void> => {
130
185
  try {
131
- const { header, messages } = await loadSession(rid, cwd);
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);
@@ -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
- const line = (queueBusySnapshot?.().text ?? "").trim();
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
- tui.setLivePromptInput(draft);
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
- const loneLfShiftEnter = jeoEnv("MULTILINE") !== "0";
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
- queueBusyInput = (chunk: string) => captureLivePromptInputChunk(queuedPromptInput, chunk);
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 in CHARACTERS into `line` (readline's rl.cursor). Defaults to end. */
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 chars = Array.from(text.replace(/\r/g, ""));
63
- const pos = Math.max(0, Math.min(cursor, chars.length));
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
- if (i === pos) {
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`). Index i is the caret
206
- * sitting BEFORE char i (N = end of text); `cursor` offsets are code points, matching how
207
- * `renderInputFrame` clamps `opts.cursor` against `Array.from(text)`. */
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
- return best !== -1 ? best : firstOnRow;
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 in this REPL", group: "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
- if (sections.length > 0) {
13
- const latest = sections[0];
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
- const normalized = normalizeImageTags(out);
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
  }