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 +14 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/commands/launch/input.ts +38 -6
- package/src/commands/launch.ts +12 -3
- package/src/tui/components/input-box.ts +47 -10
- package/src/types/playwriter.d.ts +15 -0
- package/src/util/file-attachment.ts +26 -6
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
|
@@ -384,17 +384,35 @@ function feedLivePromptSegment(state: PromptInputQueue, segment: string): boolea
|
|
|
384
384
|
return accepted;
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
-
|
|
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
|
|
391
|
-
if (!
|
|
392
|
-
state.partial
|
|
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(
|
|
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) {
|
package/src/commands/launch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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
|
}
|