jeo-code 0.9.11 → 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 CHANGED
@@ -8,6 +8,20 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.9.12] - 2026-08-02
12
+ _Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`._
13
+
14
+ ### Fixed
15
+ - **A lone `Esc` swallowed the next keystroke — including the first character of a command or a paste** (`src/commands/launch/input.ts`) — a standalone `Esc` byte is a valid bracketed-paste-marker prefix, so the prompt filter carried it and re-prepended it to the NEXT stdin chunk. Forwarded that way, readline's key decoder read `ESC` + `z` as meta-z and consumed the character: after clearing the box with Esc, typing `zebra` produced `ebra`, and typing `/route off` produced `route off` — no longer a slash command, so it was sent to the model as a prompt and routing stayed on (the "`jeo --tmux` 에서 `/route off` 가 안 먹힌다" report). A carried `Esc` that cannot begin an escape sequence is now dropped before readline sees it (jeo already owns Esc on its own raw-stdin keypress listener); an `ESC x` pair delivered in ONE chunk is still a real Alt/Meta chord and passes through untouched.
16
+ - **The painted caret drifted right of the real insertion point in any draft containing emoji** (`src/tui/components/input-box.ts`) — the box mapped `rl.cursor` (UTF-16 code units) onto a CODE-POINT index, so every surrogate pair before the caret shifted it one column and `←` after an emoji appeared not to move at all. `wrapWithCursor`/`caretCells` now index by code unit (a surrogate pair keeps both offsets addressable), and every offset returned to readline by the Up/Down and row-Home/End helpers snaps to a whole character.
17
+ - **A drag-and-dropped image left the next typed word glued to its tag** (`src/util/file-attachment.ts`) — `attachImagePaths` trimmed the tag's trailing space, so `[image #1]` + typing became `[image #1]please`, while the Ctrl+V path (`insertImageTag`) kept one space. Both attach paths now leave exactly one trailing space at end-of-draft with the caret parked after it. Dropped/typed `~/…` paths are also expanded to the home directory (gjc parity), so `~/Downloads/shot.png` attaches instead of being sent to the model as literal text.
18
+ - **A paste made WHILE a turn was running lost its line structure** (`src/commands/launch/input.ts`, `src/commands/launch.ts`) — the live-turn capture trimmed every pasted line and space-joined them, so a pasted code block/stack trace arrived at the next prompt as one flattened line with its indentation gone, and a large paste split across stdin reads gained a stray space wherever the read boundary fell (`hello wo` + `rld` → `hello wo rld`). Mid-turn pastes now fold line breaks to the same `MULTILINE_SENTINEL` the idle path uses and append verbatim; the live box and the mid-turn steering/command paths expand that sentinel, so the model never sees the private-use character. `JEO_NO_MULTILINE=1` degrades to the old space join, which is the only mode where the sentinel is not expanded on submit.
19
+
20
+ ### Verified
21
+ - Live in tmux (`jeo --tmux` inner process, real PTY, caret cell read back with `tmux display-message -p '#{cursor_x},#{cursor_y}'`): Esc → `zebra` stays `zebra`; Esc → `/route off` prints `routing: off (this session)`; `←` after `안녕하세요 테스트😀😀` steps 24 → 22 → 20 columns; a dropped `assets/icon.png` renders `look at [image #1] please` with the caret exactly at column 28; `~/…` and backslash-escaped paths attach.
22
+ - Suites: input-box, vertical-cursor, box-vertical-nav, prompt-key-filter, row-home-end-filter, extended-key-decode, mouse/terminal-report filters, file-attachment, image-attachments, input-history, new-input-first, launch-flags, slash, tmux — 215 pass / 0 fail. `bun run typecheck` clean. (`test/team-subagent.test.ts` timeouts are pre-existing and reproduce with these changes stashed.)
23
+ - Mid-turn paste: a bracketed paste delivered WHILE a turn runs kept its line breaks and indentation in the live box (`def run():` / ` return 1`), and a paste split across stdin reads no longer gains a stray space at the boundary.
24
+
11
25
  ## [0.9.11] - 2026-07-30
12
26
  _Imports GJC v5 sessions safely, exposes bounded slash controls, and gates the release artifact before publication._
13
27
 
package/README.ja.md CHANGED
@@ -287,10 +287,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
287
287
 
288
288
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
289
289
  - **[Unreleased]**
290
+ - **[0.9.12]** (2026-08-02) — Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`.
290
291
  - **[0.9.11]** (2026-07-30) — Imports GJC v5 sessions safely, exposes bounded slash controls, and gates the release artifact before publication.
291
292
  - **[0.9.10]** (2026-07-27) — `jeo update` now verifies the binary you actually run, and recovers from bun's stale registry cache.
292
293
  - **[0.9.9]** (2026-07-27) — Root-causes the frozen `jeo --tmux` TUI: the window was pinned to its launch size, so resizing the terminal only cut the view.
293
- - **[0.9.8]** (2026-07-27) — The provider model list now persists and rehydrates, so an account's live models survive across launches (gjc parity).
294
294
 
295
295
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
296
296
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -288,10 +288,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
288
288
 
289
289
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
290
290
  - **[Unreleased]**
291
+ - **[0.9.12]** (2026-08-02) — Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`.
291
292
  - **[0.9.11]** (2026-07-30) — Imports GJC v5 sessions safely, exposes bounded slash controls, and gates the release artifact before publication.
292
293
  - **[0.9.10]** (2026-07-27) — `jeo update` now verifies the binary you actually run, and recovers from bun's stale registry cache.
293
294
  - **[0.9.9]** (2026-07-27) — Root-causes the frozen `jeo --tmux` TUI: the window was pinned to its launch size, so resizing the terminal only cut the view.
294
- - **[0.9.8]** (2026-07-27) — The provider model list now persists and rehydrates, so an account's live models survive across launches (gjc parity).
295
295
 
296
296
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
297
297
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -295,10 +295,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
295
295
 
296
296
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
297
297
  - **[Unreleased]**
298
+ - **[0.9.12]** (2026-08-02) — Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`.
298
299
  - **[0.9.11]** (2026-07-30) — Imports GJC v5 sessions safely, exposes bounded slash controls, and gates the release artifact before publication.
299
300
  - **[0.9.10]** (2026-07-27) — `jeo update` now verifies the binary you actually run, and recovers from bun's stale registry cache.
300
301
  - **[0.9.9]** (2026-07-27) — Root-causes the frozen `jeo --tmux` TUI: the window was pinned to its launch size, so resizing the terminal only cut the view.
301
- - **[0.9.8]** (2026-07-27) — The provider model list now persists and rehydrates, so an account's live models survive across launches (gjc parity).
302
302
 
303
303
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
304
304
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -287,10 +287,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
287
287
 
288
288
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
289
289
  - **[Unreleased]**
290
+ - **[0.9.12]** (2026-08-02) — Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`.
290
291
  - **[0.9.11]** (2026-07-30) — Imports GJC v5 sessions safely, exposes bounded slash controls, and gates the release artifact before publication.
291
292
  - **[0.9.10]** (2026-07-27) — `jeo update` now verifies the binary you actually run, and recovers from bun's stale registry cache.
292
293
  - **[0.9.9]** (2026-07-27) — Root-causes the frozen `jeo --tmux` TUI: the window was pinned to its launch size, so resizing the terminal only cut the view.
293
- - **[0.9.8]** (2026-07-27) — The provider model list now persists and rehydrates, so an account's live models survive across launches (gjc parity).
294
294
 
295
295
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
296
296
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -384,17 +384,35 @@ function feedLivePromptSegment(state: PromptInputQueue, segment: string): boolea
384
384
  return accepted;
385
385
  }
386
386
 
387
- function feedLivePromptPasteBody(state: PromptInputQueue, body: string): boolean {
387
+ /** Pasted body captured while a TURN is running: pure DATA appended verbatim to the
388
+ * draft the box will show when the turn ends.
389
+ *
390
+ * Line breaks fold to {@link MULTILINE_SENTINEL} — the SAME contract the idle key
391
+ * filter uses — so a pasted code block / stack trace keeps its structure (and its
392
+ * indentation) instead of being flattened into one space-joined line. `multiline:
393
+ * false` (JEO_NO_MULTILINE=1, no sentinel expansion on submit) degrades to a single
394
+ * space so the sentinel can never leak into the model's input.
395
+ *
396
+ * Nothing is trimmed and no separator is inserted between chunks: a large paste is
397
+ * delivered over SEVERAL stdin reads, and the old per-chunk `trim()` + `join(" ")`
398
+ * both dropped leading indentation and spliced a stray space into whatever word the
399
+ * read boundary happened to split ("hello wo" + "rld" → "hello wo rld"). */
400
+ function feedLivePromptPasteBody(state: PromptInputQueue, body: string, multiline: boolean): boolean {
388
401
  if (!body) return false;
389
402
  const normalized = stripPasteEscapes(body).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
390
- const flattened = normalized.split("\n").map(part => part.trim()).filter(Boolean).join(" ");
391
- if (!flattened) return false;
392
- state.partial = state.partial ? `${state.partial} ${flattened}` : flattened;
403
+ const folded = normalized.split("\n").join(multiline ? MULTILINE_SENTINEL : " ");
404
+ if (!folded) return false;
405
+ state.partial += folded;
393
406
  return true;
394
407
  }
395
408
 
396
- export function captureLivePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
409
+ export function captureLivePromptInputChunk(
410
+ state: PromptInputQueue,
411
+ chunk: string,
412
+ opts: { multiline?: boolean } = {},
413
+ ): boolean {
397
414
  if (!chunk) return false;
415
+ const multiline = opts.multiline !== false;
398
416
  let accepted = false;
399
417
  let rest = chunk;
400
418
  while (rest.length > 0) {
@@ -403,7 +421,7 @@ export function captureLivePromptInputChunk(state: PromptInputQueue, chunk: stri
403
421
  const body = end === -1 ? rest : rest.slice(0, end);
404
422
  if (end !== -1) state.inPaste = false;
405
423
  rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
406
- if (feedLivePromptPasteBody(state, body)) accepted = true;
424
+ if (feedLivePromptPasteBody(state, body, multiline)) accepted = true;
407
425
  } else {
408
426
  const start = rest.indexOf(PASTE_START);
409
427
  const plain = start === -1 ? rest : rest.slice(0, start);
@@ -863,6 +881,7 @@ export function filterPromptInputChunk(
863
881
  // or a `\r\n` straddling this read boundary — is never half-interpreted. Carrying is
864
882
  // safe: the concatenation is re-parsed next call, so a tail that turns out NOT to be a
865
883
  // marker is simply emitted one chunk later.
884
+ const carriedEsc = state.carry === "\u001b";
866
885
  data = (state.carry ?? "") + data;
867
886
  state.carry = "";
868
887
  const markerTail = pasteMarkerTailLength(data);
@@ -890,6 +909,19 @@ export function filterPromptInputChunk(
890
909
  let out = "";
891
910
  let i = 0;
892
911
  while (i < data.length) {
912
+ // A lone Esc keystroke that the PREVIOUS chunk carried, now followed by bytes that
913
+ // cannot continue an escape sequence (`ESC [` / `ESC O`): drop the ESC instead of
914
+ // forwarding it. readline's key decoder otherwise holds a dangling ESC as a pending
915
+ // META prefix and merges it with the NEXT keystroke (Esc, then "z" → meta-z), which
916
+ // SWALLOWS that character — the "Esc 로 지운 뒤 첫 글자가 안 먹힘" bug (a pasted
917
+ // block's first character disappeared the same way). jeo owns Esc itself on the raw
918
+ // process.stdin keypress listener (clearTypedInput), so readline never needs the byte.
919
+ // Only a CARRIED Esc is dropped: an `ESC x` pair that arrived in ONE chunk is a real
920
+ // Alt/Meta chord (Alt+b, Alt+f, …) and still reaches readline untouched.
921
+ if (carriedEsc && i === 0 && data[0] === "\u001b" && (data.length === 1 || (data[1] !== "[" && data[1] !== "O"))) {
922
+ i += 1;
923
+ continue;
924
+ }
893
925
  if (!state.inPaste && data.startsWith(PASTE_START, i)) { state.inPaste = true; out += PASTE_START; i += PASTE_START.length; continue; }
894
926
  if (state.inPaste && data.startsWith(PASTE_END, i)) { state.inPaste = false; out += PASTE_END; i += PASTE_END.length; continue; }
895
927
  if (state.inPaste) {
@@ -1398,7 +1398,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1398
1398
  !chunk.includes(PASTE_END);
1399
1399
  const captured = queueBusyInput?.(chunk) ?? false;
1400
1400
  if (typedEnter) {
1401
- 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();
1402
1405
  if (line) {
1403
1406
  // A mid-turn /command or $skill is NOT a query for the model — steering it
1404
1407
  // would send the literal "/model" / "$skill" text to the LLM. Recognize it
@@ -1440,7 +1443,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1440
1443
  // inbox and surfaces as a `user` card (above). JEO_NO_LIVE_DRAFT=1 opts out.
1441
1444
  if (captured && jeoEnv("NO_LIVE_DRAFT") !== "1") {
1442
1445
  const draft = queueBusySnapshot?.().text ?? "";
1443
- 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));
1444
1450
  // Mid-turn command preview: as you type a /command or $skill DURING a turn,
1445
1451
  // show its matches above the input box so command input visibly reacts
1446
1452
  // (idle-prompt parity). Cleared the moment the draft stops being command-shaped.
@@ -2557,7 +2563,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2557
2563
  // (the user explicitly invoked them — no second Enter).
2558
2564
  const pendingMidTurnCommands: string[] = [];
2559
2565
  const queuedPromptInput: PromptInputQueue = { pendingLines: pendingStdinLines, partial: startupDraft ?? "", pastedLines: [], inPaste: false };
2560
- 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 });
2561
2570
  queueBusyPasteActive = () => queuedPromptInput.inPaste;
2562
2571
  queueBusySnapshot = () => ({
2563
2572
  text: queuedPromptInput.partial,
@@ -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
  }
@@ -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
  }