jeo-code 0.9.11 → 0.9.13
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 +32 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/auth/browser-tab.ts +182 -0
- package/src/auth/callback-server.ts +68 -20
- 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,38 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.9.13] - 2026-08-03
|
|
12
|
+
_The provider-login browser tab now actually goes away — by itself on macOS, by a working button everywhere else._
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **The OAuth login tab stayed open after "Login complete"** (`src/auth/callback-server.ts`, new `src/auth/browser-tab.ts`) — two separate defects:
|
|
16
|
+
- The page decided whether the close worked from `window.close()`'s return value, which is `undefined` on EVERY browser. The failure branch therefore always ran and hid the "Close" button on the first countdown tick, so the tab was left open with its only manual control gone. Success is now inferred from the document actually going away (`pagehide` / `document.hidden`); the button stays visible and re-attempts on every press, and the countdown hint is replaced by an explicit "press ⌘W / Ctrl+W" instruction only when the browser refuses.
|
|
17
|
+
- `window.close()` cannot close a tab the page's own script did not open, and jeo hands the auth URL to the OS (`open` / `xdg-open` / `start`), so the request was always ignored. jeo now closes the tab from the OUTSIDE on macOS: after the callback page is served, it lists running processes (no Automation permission needed), builds an AppleScript naming ONLY the browsers that are actually running, and closes just the tabs whose URL contains this login's `localhost:<port>/callback`. `window.open("","_self")` is also tried first for the engines that still honor the legacy self-adopt, and the countdown is 3s instead of 5s.
|
|
18
|
+
- **A single missing browser silently disabled the whole close script** — AppleScript resolves app terminology at COMPILE time, which `try` cannot guard, so one static `tell application "Arc"` block on a machine without Arc made the entire script fail to compile and close nothing (`osacompile`: "Expected class name but found property"). The script is now generated only for browsers found in the live process list.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- `JEO_AUTH_TAB_CLOSE=0` opts out of the OS-level tab close entirely; the attempt is hard-capped at 2s, never surfaces an error, and is skipped when the login finished through a manual code paste (no tab of ours was ever rendered).
|
|
22
|
+
|
|
23
|
+
### Verified
|
|
24
|
+
- Real Chromium (Playwright) against the actual served page: the scripted close is refused exactly as it is for a real OAuth tab, and the page survives intact — URL unchanged (`window.open("","_self")` does not blank it), "Login complete ✓" still rendered, countdown hint swapped for the manual instruction, Close button still visible; `window.close()` is invoked once by the countdown and again on every button press (the old build stopped after the first).
|
|
25
|
+
- `osacompile` on the generated AppleScript: fails on the full browser list (reproducing the compile bug) and compiles cleanly for the running-process-derived list.
|
|
26
|
+
- `closeAuthTab` against a live loopback callback URL returns in ~2s without hanging when macOS withholds Automation permission, so a login can never stall behind it.
|
|
27
|
+
- Suites: oauth, browser-tab, openai-oauth, antigravity-login, kimi-oauth, oauth-lock, auth-matrix, doctor — 53 pass / 0 fail. `bun run typecheck` clean, `bun run pack:check` 283 files.
|
|
28
|
+
|
|
29
|
+
## [0.9.12] - 2026-08-02
|
|
30
|
+
_Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`._
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- **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.
|
|
34
|
+
- **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.
|
|
35
|
+
- **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.
|
|
36
|
+
- **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.
|
|
37
|
+
|
|
38
|
+
### Verified
|
|
39
|
+
- 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.
|
|
40
|
+
- 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.)
|
|
41
|
+
- 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.
|
|
42
|
+
|
|
11
43
|
## [0.9.11] - 2026-07-30
|
|
12
44
|
_Imports GJC v5 sessions safely, exposes bounded slash controls, and gates the release artifact before publication._
|
|
13
45
|
|
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.13]** (2026-08-03) — The provider-login browser tab now actually goes away — by itself on macOS, by a working button everywhere else.
|
|
291
|
+
- **[0.9.12]** (2026-08-02) — Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`.
|
|
290
292
|
- **[0.9.11]** (2026-07-30) — Imports GJC v5 sessions safely, exposes bounded slash controls, and gates the release artifact before publication.
|
|
291
293
|
- **[0.9.10]** (2026-07-27) — `jeo update` now verifies the binary you actually run, and recovers from bun's stale registry cache.
|
|
292
|
-
- **[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.13]** (2026-08-03) — The provider-login browser tab now actually goes away — by itself on macOS, by a working button everywhere else.
|
|
292
|
+
- **[0.9.12]** (2026-08-02) — Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`.
|
|
291
293
|
- **[0.9.11]** (2026-07-30) — Imports GJC v5 sessions safely, exposes bounded slash controls, and gates the release artifact before publication.
|
|
292
294
|
- **[0.9.10]** (2026-07-27) — `jeo update` now verifies the binary you actually run, and recovers from bun's stale registry cache.
|
|
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.
|
|
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.13]** (2026-08-03) — The provider-login browser tab now actually goes away — by itself on macOS, by a working button everywhere else.
|
|
299
|
+
- **[0.9.12]** (2026-08-02) — Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`.
|
|
298
300
|
- **[0.9.11]** (2026-07-30) — Imports GJC v5 sessions safely, exposes bounded slash controls, and gates the release artifact before publication.
|
|
299
301
|
- **[0.9.10]** (2026-07-27) — `jeo update` now verifies the binary you actually run, and recovers from bun's stale registry cache.
|
|
300
|
-
- **[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.13]** (2026-08-03) — The provider-login browser tab now actually goes away — by itself on macOS, by a working button everywhere else.
|
|
291
|
+
- **[0.9.12]** (2026-08-02) — Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`.
|
|
290
292
|
- **[0.9.11]** (2026-07-30) — Imports GJC v5 sessions safely, exposes bounded slash controls, and gates the release artifact before publication.
|
|
291
293
|
- **[0.9.10]** (2026-07-27) — `jeo update` now verifies the binary you actually run, and recovers from bun's stale registry cache.
|
|
292
|
-
- **[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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort closing of the OAuth callback TAB the login opened.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: the callback page already asks the browser to close itself
|
|
5
|
+
* (`window.close()` after a short countdown), but every current browser REFUSES
|
|
6
|
+
* that for a tab the page's own script did not open. jeo hands the auth URL to
|
|
7
|
+
* the OS (`open` / `xdg-open` / `start`), so the tab is user-opened by that rule
|
|
8
|
+
* and the request is silently ignored — the login succeeds in the terminal while
|
|
9
|
+
* a dead "Login complete" tab lingers in the browser (the reported bug).
|
|
10
|
+
*
|
|
11
|
+
* The only reliable way to close such a tab is to drive the browser from the
|
|
12
|
+
* outside. macOS exposes exactly that through AppleScript, so this module builds
|
|
13
|
+
* a targeted script that closes ONLY tabs whose URL contains the loopback
|
|
14
|
+
* callback (e.g. `localhost:1455/callback`) — never any other tab — in whichever
|
|
15
|
+
* of the common browsers is ALREADY running (`is running` never launches one).
|
|
16
|
+
*
|
|
17
|
+
* Everything here is best effort and failure-silent: no automation permission, a
|
|
18
|
+
* browser without an AppleScript dictionary (Firefox), Linux/Windows, or a user
|
|
19
|
+
* who already closed the tab all end as a no-op. `JEO_AUTH_TAB_CLOSE=0` opts out.
|
|
20
|
+
*/
|
|
21
|
+
import { jeoEnv } from "../util/env";
|
|
22
|
+
|
|
23
|
+
/** Chromium-family browsers whose AppleScript dictionary exposes `windows`/`tabs`
|
|
24
|
+
* with a `URL` property and a `close` command. Safari uses the same shape and is
|
|
25
|
+
* appended by {@link closeAuthTabScript}. Firefox has no tab-level dictionary at
|
|
26
|
+
* all, so it cannot be driven this way — its tab keeps the page's own fallback. */
|
|
27
|
+
export const APPLESCRIPT_BROWSERS: readonly string[] = [
|
|
28
|
+
"Google Chrome",
|
|
29
|
+
"Google Chrome Beta",
|
|
30
|
+
"Google Chrome Canary",
|
|
31
|
+
"Chromium",
|
|
32
|
+
"Brave Browser",
|
|
33
|
+
"Microsoft Edge",
|
|
34
|
+
"Vivaldi",
|
|
35
|
+
"Arc",
|
|
36
|
+
"Safari",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** Escape a string for embedding inside an AppleScript double-quoted literal. */
|
|
40
|
+
function escapeAppleScript(value: string): string {
|
|
41
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The AppleScript that closes every tab whose URL contains `fragment`.
|
|
46
|
+
*
|
|
47
|
+
* One STATIC `tell` block per browser (never a dynamic `tell application appName`):
|
|
48
|
+
* app-specific terms like `URL of t` only compile inside a literal tell block, so a
|
|
49
|
+
* variable-driven loop would fail to compile and close nothing.
|
|
50
|
+
*
|
|
51
|
+
* Pass ONLY browsers that are actually running (see {@link runningBrowserApps}). A
|
|
52
|
+
* block naming an app that is not INSTALLED cannot resolve its terminology and
|
|
53
|
+
* fails at COMPILE time — which `try` cannot catch, since `try` guards runtime
|
|
54
|
+
* errors only — so one missing browser would sink the whole script and close
|
|
55
|
+
* nothing (verified with `osacompile`: "Expected class name but found property").
|
|
56
|
+
* Each block keeps its own `try` + `is running` guard for the runtime races: a
|
|
57
|
+
* browser quitting mid-script, or a window that refuses the query.
|
|
58
|
+
*/
|
|
59
|
+
export function closeAuthTabScript(fragment: string, browsers: readonly string[] = APPLESCRIPT_BROWSERS): string {
|
|
60
|
+
const frag = escapeAppleScript(fragment);
|
|
61
|
+
const blocks = browsers.map(
|
|
62
|
+
app => `try
|
|
63
|
+
if application "${escapeAppleScript(app)}" is running then
|
|
64
|
+
tell application "${escapeAppleScript(app)}"
|
|
65
|
+
repeat with w in (every window)
|
|
66
|
+
repeat with t in (every tab of w)
|
|
67
|
+
try
|
|
68
|
+
if (URL of t) contains "${frag}" then close t
|
|
69
|
+
end try
|
|
70
|
+
end repeat
|
|
71
|
+
end repeat
|
|
72
|
+
end tell
|
|
73
|
+
end if
|
|
74
|
+
end try`,
|
|
75
|
+
);
|
|
76
|
+
return blocks.join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The subset of {@link APPLESCRIPT_BROWSERS} currently running, derived from
|
|
80
|
+
* `ps -Ax -o comm=` output (each line is an executable path such as
|
|
81
|
+
* `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`). Reading the
|
|
82
|
+
* process list needs no Automation permission, so this narrows the generated
|
|
83
|
+
* script — and with it the compile surface and the number of permission prompts —
|
|
84
|
+
* to the browsers that could actually be holding the callback tab. */
|
|
85
|
+
export function runningBrowserApps(psOutput: string, browsers: readonly string[] = APPLESCRIPT_BROWSERS): string[] {
|
|
86
|
+
const lines = psOutput.split("\n");
|
|
87
|
+
return browsers.filter(app => lines.some(line => line.includes(`/${app}.app/`)));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The command to run for `platform`, or null when the platform has no reliable
|
|
91
|
+
* way to close a tab it did not open (Linux/Windows: no scriptable, browser-
|
|
92
|
+
* agnostic tab API — the callback page's own message is the fallback there). */
|
|
93
|
+
export function browserTabCloseCommand(
|
|
94
|
+
platform: NodeJS.Platform,
|
|
95
|
+
fragment: string,
|
|
96
|
+
browsers: readonly string[] = APPLESCRIPT_BROWSERS,
|
|
97
|
+
): string[] | null {
|
|
98
|
+
if (platform !== "darwin") return null;
|
|
99
|
+
if (!fragment) return null;
|
|
100
|
+
return ["osascript", "-e", closeAuthTabScript(fragment, browsers)];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The `host:port/path` slice of a redirect URI — the narrowest fragment that still
|
|
104
|
+
* identifies THIS login's callback tab (the port is unique per flow/run), so no
|
|
105
|
+
* unrelated tab can ever match. Returns "" when the URI cannot be parsed. */
|
|
106
|
+
export function callbackUrlFragment(redirectUri: string): string {
|
|
107
|
+
try {
|
|
108
|
+
const url = new URL(redirectUri);
|
|
109
|
+
return `${url.host}${url.pathname}`;
|
|
110
|
+
} catch {
|
|
111
|
+
return "";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface CloseAuthTabOptions {
|
|
116
|
+
platform?: NodeJS.Platform;
|
|
117
|
+
env?: Record<string, string | undefined>;
|
|
118
|
+
/** Hard cap so a hung/permission-prompting osascript can never stall the login.
|
|
119
|
+
* The FIRST close on a machine raises macOS's "control Google Chrome" Automation
|
|
120
|
+
* prompt; if the user does not answer in time this kills the helper and the page's
|
|
121
|
+
* own fallback message takes over. Answering it once makes later logins instant. */
|
|
122
|
+
timeoutMs?: number;
|
|
123
|
+
/** Injected for tests; defaults to the real spawn. */
|
|
124
|
+
spawn?: (cmd: string[]) => { exited: Promise<number>; kill: () => void };
|
|
125
|
+
/** Running-process listing (`ps -Ax -o comm=`); injected for tests. Returning ""
|
|
126
|
+
* means "cannot tell", which is treated as "no known browser running". */
|
|
127
|
+
listProcesses?: () => string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Close the browser tab still showing this login's callback page. Resolves `true`
|
|
132
|
+
* only when the OS accepted the request; every other outcome (unsupported
|
|
133
|
+
* platform, opt-out, spawn failure, timeout, non-zero exit) resolves `false` and
|
|
134
|
+
* is silent — the terminal already reported the login result, so this must never
|
|
135
|
+
* surface an error of its own.
|
|
136
|
+
*/
|
|
137
|
+
export async function closeAuthTab(redirectUri: string, opts: CloseAuthTabOptions = {}): Promise<boolean> {
|
|
138
|
+
const env = opts.env ?? process.env;
|
|
139
|
+
if (jeoEnv("AUTH_TAB_CLOSE", env) === "0") return false;
|
|
140
|
+
const platform = opts.platform ?? process.platform;
|
|
141
|
+
if (platform !== "darwin") return false;
|
|
142
|
+
const fragment = callbackUrlFragment(redirectUri);
|
|
143
|
+
if (!fragment) return false;
|
|
144
|
+
const listProcesses =
|
|
145
|
+
opts.listProcesses ??
|
|
146
|
+
(() => {
|
|
147
|
+
try {
|
|
148
|
+
return Bun.spawnSync(["ps", "-Ax", "-o", "comm="], { stdout: "pipe", stderr: "ignore" }).stdout.toString();
|
|
149
|
+
} catch {
|
|
150
|
+
return "";
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
// Never script a browser that is not running: an uninstalled app breaks the whole
|
|
154
|
+
// script at compile time, and a running-only list keeps the Automation prompt to
|
|
155
|
+
// the browser the user actually has open.
|
|
156
|
+
const browsers = runningBrowserApps(listProcesses());
|
|
157
|
+
if (browsers.length === 0) return false;
|
|
158
|
+
const cmd = browserTabCloseCommand(platform, fragment, browsers);
|
|
159
|
+
if (!cmd) return false;
|
|
160
|
+
const timeoutMs = opts.timeoutMs ?? 2000;
|
|
161
|
+
const spawn =
|
|
162
|
+
opts.spawn ??
|
|
163
|
+
((c: string[]) => {
|
|
164
|
+
const proc = Bun.spawn(c, { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
|
|
165
|
+
return { exited: proc.exited, kill: () => proc.kill() };
|
|
166
|
+
});
|
|
167
|
+
try {
|
|
168
|
+
const proc = spawn(cmd);
|
|
169
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
170
|
+
const timeout = new Promise<number>(resolve => {
|
|
171
|
+
timer = setTimeout(() => {
|
|
172
|
+
try { proc.kill(); } catch { /* already gone */ }
|
|
173
|
+
resolve(-1);
|
|
174
|
+
}, timeoutMs);
|
|
175
|
+
});
|
|
176
|
+
const code = await Promise.race([proc.exited, timeout]);
|
|
177
|
+
if (timer) clearTimeout(timer);
|
|
178
|
+
return code === 0;
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
11
11
|
import { generateState } from "./pkce";
|
|
12
|
+
import { closeAuthTab } from "./browser-tab";
|
|
12
13
|
|
|
13
14
|
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
14
15
|
// Loopback callback host. `localhost` (not the 127.0.0.1 IP literal) is the intentional
|
|
@@ -64,34 +65,58 @@ button:hover{background:#1b1c27}
|
|
|
64
65
|
<img class="wordmark" alt="jeo" src="${JEO_WORDMARK_DATA_URI}">
|
|
65
66
|
<h1 class="__STATUS__">__TITLE__</h1>
|
|
66
67
|
<p>__MSG__</p>
|
|
67
|
-
<button id="jeo-close" type="button">Close</button>
|
|
68
|
-
<p class="hint">Closing automatically in <span id="jeo-countdown">__SECONDS__</span>s…</p>
|
|
69
|
-
<p class="hint closed" id="jeo-closed-msg">
|
|
68
|
+
<button id="jeo-close" type="button">Close this tab</button>
|
|
69
|
+
<p class="hint" id="jeo-countdown-msg">Closing automatically in <span id="jeo-countdown">__SECONDS__</span>s…</p>
|
|
70
|
+
<p class="hint closed" id="jeo-closed-msg">This browser refuses to close a tab it opened itself — press \u2318W (Ctrl+W) to close it.</p>
|
|
70
71
|
</div>
|
|
71
72
|
<script>__SCRIPT__</script>
|
|
72
73
|
</body></html>`;
|
|
73
74
|
|
|
74
|
-
// Seconds before the tab
|
|
75
|
-
// failure page
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
const AUTO_CLOSE_SECONDS = 5;
|
|
75
|
+
// Seconds before the tab tries to close itself — applies to BOTH the success and
|
|
76
|
+
// the failure page (each is a final page that should not linger). Pressing
|
|
77
|
+
// "Close this tab" runs the same attempt immediately, without waiting out the
|
|
78
|
+
// countdown, and stays clickable afterwards.
|
|
79
|
+
const AUTO_CLOSE_SECONDS = 3;
|
|
80
80
|
|
|
81
81
|
function renderHtml(ok: boolean, msg: string): string {
|
|
82
|
+
// Closing contract, in order of what actually works:
|
|
83
|
+
// 1. `window.open("","_self")` before `window.close()` — the legacy self-adopt
|
|
84
|
+
// trick that still lets Firefox (and older engines) close an OS-opened tab.
|
|
85
|
+
// 2. `window.close()` — succeeds only where the tab counts as script-opened.
|
|
86
|
+
// 3. If the tab is still here a moment later, the browser refused: tell the
|
|
87
|
+
// user how to close it and KEEP the button working.
|
|
88
|
+
//
|
|
89
|
+
// The old code branched on `window.close()`'s return value — that call returns
|
|
90
|
+
// `undefined` ALWAYS, so the failure branch fired unconditionally and hid the
|
|
91
|
+
// Close button on the first tick. That left the tab open with its only manual
|
|
92
|
+
// control gone (the "인증 브라우저가 종료되지 않는다" report). Success is now
|
|
93
|
+
// detected by observing that the document is actually going away
|
|
94
|
+
// (`document.hidden` / the `pagehide` unload path), never by a return value.
|
|
95
|
+
//
|
|
96
|
+
// The CLI side additionally closes the tab through the OS on macOS once the
|
|
97
|
+
// callback lands (`closeAuthTab` in ./browser-tab.ts), which is the only path
|
|
98
|
+
// that works when the browser refuses the scripted close outright.
|
|
82
99
|
const script = `(function(){
|
|
83
|
-
var n=${AUTO_CLOSE_SECONDS},
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
100
|
+
var n=${AUTO_CLOSE_SECONDS},
|
|
101
|
+
el=document.getElementById("jeo-countdown"),
|
|
102
|
+
countdownMsg=document.getElementById("jeo-countdown-msg"),
|
|
103
|
+
btn=document.getElementById("jeo-close"),
|
|
104
|
+
closedMsg=document.getElementById("jeo-closed-msg"),
|
|
105
|
+
t,gone=false;
|
|
106
|
+
window.addEventListener("pagehide",function(){gone=true;});
|
|
107
|
+
function showManual(){
|
|
108
|
+
if(gone||document.hidden)return;
|
|
109
|
+
if(countdownMsg)countdownMsg.style.display="none";
|
|
110
|
+
if(closedMsg)closedMsg.classList.remove("closed");
|
|
111
|
+
}
|
|
112
|
+
function attemptClose(){
|
|
113
|
+
if(t){clearInterval(t);t=null;}
|
|
114
|
+
try{window.open("","_self");}catch(e){}
|
|
115
|
+
try{window.close();}catch(e){}
|
|
116
|
+
setTimeout(showManual,250);
|
|
92
117
|
}
|
|
93
|
-
t=setInterval(function(){n-=1;if(el)el.textContent=String(Math.max(n,0));if(n<=0)
|
|
94
|
-
if(btn)btn.addEventListener("click",
|
|
118
|
+
t=setInterval(function(){n-=1;if(el)el.textContent=String(Math.max(n,0));if(n<=0)attemptClose();},1000);
|
|
119
|
+
if(btn)btn.addEventListener("click",attemptClose);
|
|
95
120
|
})();`;
|
|
96
121
|
return PAGE_HTML
|
|
97
122
|
.replace("__STATUS__", ok ? "ok" : "fail")
|
|
@@ -110,6 +135,9 @@ export abstract class OAuthCallbackFlow {
|
|
|
110
135
|
protected fixedRedirectUri?: string;
|
|
111
136
|
#resolve?: (r: CallbackResult) => void;
|
|
112
137
|
#reject?: (e: Error) => void;
|
|
138
|
+
/** Set for the duration of one `login()`; fired the moment the callback PAGE is
|
|
139
|
+
* served, which is the only proof a browser tab of ours exists to close. */
|
|
140
|
+
#onPageServed?: () => void;
|
|
113
141
|
|
|
114
142
|
constructor(ctrl: OAuthController, opts: number | OAuthCallbackFlowOptions, callbackPath = DEFAULT_CALLBACK_PATH) {
|
|
115
143
|
this.ctrl = ctrl;
|
|
@@ -131,6 +159,11 @@ export abstract class OAuthCallbackFlow {
|
|
|
131
159
|
async login(): Promise<OAuthCredentials> {
|
|
132
160
|
const state = generateState();
|
|
133
161
|
const { server, redirectUri } = this.#startServer(state);
|
|
162
|
+
// Whether the browser actually reached the callback page. A manual paste (or a
|
|
163
|
+
// cancelled/timed-out flow) never rendered a tab of ours, so there is nothing to
|
|
164
|
+
// close and no reason to pay for an osascript spawn.
|
|
165
|
+
let servedCallbackPage = false;
|
|
166
|
+
this.#onPageServed = () => { servedCallbackPage = true; };
|
|
134
167
|
try {
|
|
135
168
|
const { url, instructions } = await this.generateAuthUrl(state, redirectUri);
|
|
136
169
|
this.ctrl.onAuth?.({ url, instructions });
|
|
@@ -140,9 +173,23 @@ export abstract class OAuthCallbackFlow {
|
|
|
140
173
|
return await this.exchangeToken(code, returnedState || state, redirectUri);
|
|
141
174
|
} finally {
|
|
142
175
|
server.stop();
|
|
176
|
+
this.#onPageServed = undefined;
|
|
177
|
+
// Close the leftover callback tab from the OUTSIDE (macOS only; see
|
|
178
|
+
// ./browser-tab.ts). Awaited — a one-shot `jeo auth login` exits as soon as
|
|
179
|
+
// this returns, so a fire-and-forget spawn would be killed before it ran —
|
|
180
|
+
// but hard-capped, silent, and never able to fail the login it follows.
|
|
181
|
+
if (servedCallbackPage) {
|
|
182
|
+
try { await this.closeBrowserTab(redirectUri); } catch { /* best effort only */ }
|
|
183
|
+
}
|
|
143
184
|
}
|
|
144
185
|
}
|
|
145
186
|
|
|
187
|
+
/** OS-level close of the callback tab (see ./browser-tab.ts). Overridable so tests
|
|
188
|
+
* can observe the attempt without driving a real browser. */
|
|
189
|
+
protected closeBrowserTab(redirectUri: string): Promise<boolean> {
|
|
190
|
+
return closeAuthTab(redirectUri);
|
|
191
|
+
}
|
|
192
|
+
|
|
146
193
|
#startServer(expectedState: string): { server: Bun.Server<unknown>; redirectUri: string } {
|
|
147
194
|
const serve = (port: number) =>
|
|
148
195
|
Bun.serve({
|
|
@@ -186,6 +233,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
186
233
|
message = "Authentication succeeded. Return to the terminal — jeo is ready.";
|
|
187
234
|
}
|
|
188
235
|
|
|
236
|
+
this.#onPageServed?.(); // a real browser tab now holds this page — it can be closed
|
|
189
237
|
const resolve = this.#resolve;
|
|
190
238
|
const reject = this.#reject;
|
|
191
239
|
queueMicrotask(() => {
|
|
@@ -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
|
}
|