jeo-code 0.6.33 → 0.6.35

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
@@ -6,6 +6,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.6.35] - 2026-06-20
10
+ _The prompt's Ctrl+C now clears a non-empty input box on the first press and only exits on the next press of an empty box; plus app-driven system-clipboard copy (OSC 52 + local tool, tmux-aware), drag-and-drop image attachment, a Ctrl-L prompt re-anchor, and a SIGCONT resume repaint — verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot check._
11
+
12
+ ### Added
13
+ - **System-clipboard COPY that survives SSH and tmux.** New `src/tui/clipboard.ts` puts text on the OS clipboard via OSC 52 (`ESC ] 52 ; c ; <base64> BEL`, wrapped in tmux DCS passthrough when inside tmux) with a local subprocess fallback (`pbcopy` / `wl-copy` / `xclip` / `xsel` / `clip`). The tmux profile (`launch/tmux.ts`) now sets `copy-command` so a `mouse on` drag-select releases the copy-mode selection straight to the system clipboard — making `cmd+v` work even where the outer terminal can't capture the drag.
14
+ - **Drag-and-drop image attachment.** New `src/util/file-attachment.ts` recognises an image path dropped into the prompt (terminals deliver a drop as quoted/escaped text), validates it by magic bytes (not just extension), reads it, and rewrites the path token to the same `[image #N]` tag the Ctrl+V clipboard-image path uses — one consistent reference scheme for the model. Non-image / unreadable paths are left untouched.
15
+ - **`clearVisible()` (`src/tui/terminal.ts`)** — a Ctrl-L redraw that erases the visible screen and homes the cursor (`2J` + `H`) while PRESERVING scrollback (no `3J`), used to re-anchor a prompt whose in-place footer drifted after the screen scrolled. Hotkey help (`/help`) now documents Ctrl-L, Ctrl-V, and drag-drop.
16
+
17
+ ### Changed
18
+ - **Prompt Ctrl+C clears before it exits.** At the idle prompt, a first Ctrl+C with typed text (or a pending clipboard image / queued pasted batch) now WIPES the box and keeps you at the prompt; a Ctrl+C on the already-empty box hard-exits (130). A pure, unit-tested `decideCtrlC(hasInput, msSinceLastCtrlC, collapseMs?)` plus a 50 ms collapse window funnels the four delivery paths of one physical press (footer keypress, `process`/`rl` SIGINT, raw `\u0003` byte) into a single logical action, so one press can never clear AND then exit. In-turn abort, EOF, and modal-picker Ctrl+C remain hard exits.
19
+ - **TUI repaints on resume from suspend (SIGCONT).** After `fg` brings jeo back from a Ctrl-Z / background stop, the live view now re-anchors itself instead of leaving a stale frame; the SIGCONT listener is registered only on non-Windows and removed on dispose (no listener leak).
20
+
21
+ ### Verified
22
+ - `mem-probe` shows a flat post-GC heap (negative per-turn slope) with zero `process` SIGINT/listener accumulation, and `scripts/tmux-verify.sh smoke` boots `jeo --tmux` to a clean input box + model bar — full suite green (1747 tests) and `typecheck` clean.
23
+
24
+
25
+ ## [0.6.34] - 2026-06-20
26
+ _Per-session model memory — each saved session now remembers the model it was last using and restores it on `/resume` — plus clearer `jeo --tmux` attach diagnostics, a tmux session-name double-dash fix, and a more robust no-leak probe gate._
27
+
28
+ ### Added
29
+ - **Sessions remember their model (per-session model selection).** The session JSONL header now carries an optional `model` field: `createSession(cwd, id, model?)` pins it, `updateSessionModel(id, model)` rewrites it in place (no message loss, byte-identical no-op when unchanged), and `loadSession`/`listSessions` restore it. In `launch.ts`, every model change — the `/model` picker, a `model …` action, the OpenAI-compatible-endpoint setter, and live picker selections — is persisted into the active session via a best-effort `persistSessionModel()` (a header-rewrite failure never aborts the turn). On `/resume` (and `--resume`) the session's pinned model is restored unless the CLI explicitly pinned one (`--model`/role/provider wins), so each session can carry its own model independent of the global default. The `--resume` list and the resume picker surface the pinned model (`[provider/model]`).
30
+
31
+ ### Changed
32
+ - **`jeo --tmux` reports a failed attach instead of vanishing.** A nonzero `tmux attach` exit (e.g. `open terminal failed: not a terminal` when stdout isn't a real TTY, a too-small client, or a transient server error) used to be swallowed — jeo returned 0 and left the freshly created session orphaned with no hint. The attach exit code is now surfaced and propagated to `process.exitCode`, and the message is honest about state: it advises `tmux attach -t <session>` only when the session is STILL live, and otherwise reports the session `ended before it could be attached` (so "reattach" is never misleading after an instant inner crash).
33
+ - **tmux session names no longer produce a double dash.** `tmuxSafeNamePart` now trims a trailing dash off the truncated head before appending the disambiguating hash, so a truncation boundary landing right after a `-` yields `name-<hash>` instead of an ugly `name--<hash>`.
34
+ - **`renameSession` shares one header-rewrite path.** Both the rename and the new model-pin go through a single internal `rewriteSessionHeader(id, mutate, cwd)` that locates the JSONL header, applies a mutator (returning `false` to skip the write when nothing changed), and rewrites the file in place — one place for the missing-file/missing-header error handling.
35
+
36
+ ### Verified
37
+ - **No bun memory leak / slowdown.** `scripts/mem-probe.ts` drove 2000–4000 realistic LaunchTui turns: the post-GC heap keeps returning to a flat settled floor (~4.3 MB across turns 200→3400, net **+0.52 MB** vs baseline), with `exit`/`resize`/`SIGINT` process-listener counts stable (no accumulation). The probe's net-growth gate was hardened to measure the **settled floor** (min over the trailing half of samples) rather than the single final sample, since Bun's incremental GC leaves the per-sample heap bimodal — a final sample landing on a transient pre-collection peak was a measurement artifact, not retained memory.
38
+ - **`jeo --tmux` live.** `tmux-verify.sh smoke` OK + `battery` **6/6 PASSED** (boot, `/help`, unknown `$skill`, `/agents`, `$ultragoal`, unresolved `/command`).
39
+ - **Green gates.** `bun run typecheck` clean; `bun test` **1714 pass / 0 fail** (211 files), including the new per-session-model round-trip (`test/session.test.ts`) and tmux attach-failure / double-dash cases (`test/tmux.test.ts`).
40
+
9
41
  ## [0.6.33] - 2026-06-19
10
42
  _A redesigned `jeo` forge mark — a hollow line-board crayfish/eyeglass emblem drawn as thick rounded-corner tubes (no letters, no DNA helix) — that now renders inside compact-scaled forge cards, plus a unified verification directive that adds gjc's test-quality contract, and a fresh `jeo --tmux` no-leak re-verification._
11
43
 
package/README.ja.md CHANGED
@@ -200,11 +200,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
200
200
  ## 変更履歴 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.35]** (2026-06-20) — The prompt's Ctrl+C now clears a non-empty input box on the first press and only exits on the next press of an empty box; plus app-driven system-clipboard copy (OSC 52 + local tool, tmux-aware), drag-and-drop image attachment, a Ctrl-L prompt re-anchor, and a SIGCONT resume repaint — verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot check.
204
+ - **[0.6.34]** (2026-06-20) — Per-session model memory — each saved session now remembers the model it was last using and restores it on `/resume` — plus clearer `jeo --tmux` attach diagnostics, a tmux session-name double-dash fix, and a more robust no-leak probe gate.
203
205
  - **[0.6.33]** (2026-06-19) — A redesigned `jeo` forge mark — a hollow line-board crayfish/eyeglass emblem drawn as thick rounded-corner tubes (no letters, no DNA helix) — that now renders inside compact-scaled forge cards, plus a unified verification directive that adds gjc's test-quality contract, and a fresh `jeo --tmux` no-leak re-verification.
204
206
  - **[0.6.32]** (2026-06-19) — Anthropic extended thinking is actually enabled now — the request finally sends a `thinking` block (adaptive for Opus/Sonnet 4.6+, budget for older), fixing reasoning on **opus-4-8** — plus a multi-token `/command`·`$skill` trigger highlight that paints every invocation and survives the trailing space, and a fresh `jeo --tmux` no-leak re-verification.
205
207
  - **[0.6.31]** (2026-06-19) — Live "Thinking" indicator for signature-only reasoning models (Anthropic opus-4-7/4-8), a live color cue when a `/command` or `$skill` trigger is recognized in the prompt, and a rich gjc-style `/resume` session picker — plus a fresh `jeo --tmux` no-leak re-verification.
206
- - **[0.6.30]** (2026-06-19) — gjc-style intermediate-judgment guard classification extracted from the engine loop, plus a re-verification that `jeo --tmux` does not leak bun memory or slow down.
207
- - **[0.6.29]** (2026-06-19) — Signature-only thinking-block replay (Anthropic opus-4-7/4-8), plus a tmux mouse-flood memory guard confirming `jeo --tmux` does not leak.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -200,11 +200,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
200
200
  ## 변경 이력 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.35]** (2026-06-20) — The prompt's Ctrl+C now clears a non-empty input box on the first press and only exits on the next press of an empty box; plus app-driven system-clipboard copy (OSC 52 + local tool, tmux-aware), drag-and-drop image attachment, a Ctrl-L prompt re-anchor, and a SIGCONT resume repaint — verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot check.
204
+ - **[0.6.34]** (2026-06-20) — Per-session model memory — each saved session now remembers the model it was last using and restores it on `/resume` — plus clearer `jeo --tmux` attach diagnostics, a tmux session-name double-dash fix, and a more robust no-leak probe gate.
203
205
  - **[0.6.33]** (2026-06-19) — A redesigned `jeo` forge mark — a hollow line-board crayfish/eyeglass emblem drawn as thick rounded-corner tubes (no letters, no DNA helix) — that now renders inside compact-scaled forge cards, plus a unified verification directive that adds gjc's test-quality contract, and a fresh `jeo --tmux` no-leak re-verification.
204
206
  - **[0.6.32]** (2026-06-19) — Anthropic extended thinking is actually enabled now — the request finally sends a `thinking` block (adaptive for Opus/Sonnet 4.6+, budget for older), fixing reasoning on **opus-4-8** — plus a multi-token `/command`·`$skill` trigger highlight that paints every invocation and survives the trailing space, and a fresh `jeo --tmux` no-leak re-verification.
205
207
  - **[0.6.31]** (2026-06-19) — Live "Thinking" indicator for signature-only reasoning models (Anthropic opus-4-7/4-8), a live color cue when a `/command` or `$skill` trigger is recognized in the prompt, and a rich gjc-style `/resume` session picker — plus a fresh `jeo --tmux` no-leak re-verification.
206
- - **[0.6.30]** (2026-06-19) — gjc-style intermediate-judgment guard classification extracted from the engine loop, plus a re-verification that `jeo --tmux` does not leak bun memory or slow down.
207
- - **[0.6.29]** (2026-06-19) — Signature-only thinking-block replay (Anthropic opus-4-7/4-8), plus a tmux mouse-flood memory guard confirming `jeo --tmux` does not leak.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -200,11 +200,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
200
200
  ## Changelog
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.35]** (2026-06-20) — The prompt's Ctrl+C now clears a non-empty input box on the first press and only exits on the next press of an empty box; plus app-driven system-clipboard copy (OSC 52 + local tool, tmux-aware), drag-and-drop image attachment, a Ctrl-L prompt re-anchor, and a SIGCONT resume repaint — verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot check.
204
+ - **[0.6.34]** (2026-06-20) — Per-session model memory — each saved session now remembers the model it was last using and restores it on `/resume` — plus clearer `jeo --tmux` attach diagnostics, a tmux session-name double-dash fix, and a more robust no-leak probe gate.
203
205
  - **[0.6.33]** (2026-06-19) — A redesigned `jeo` forge mark — a hollow line-board crayfish/eyeglass emblem drawn as thick rounded-corner tubes (no letters, no DNA helix) — that now renders inside compact-scaled forge cards, plus a unified verification directive that adds gjc's test-quality contract, and a fresh `jeo --tmux` no-leak re-verification.
204
206
  - **[0.6.32]** (2026-06-19) — Anthropic extended thinking is actually enabled now — the request finally sends a `thinking` block (adaptive for Opus/Sonnet 4.6+, budget for older), fixing reasoning on **opus-4-8** — plus a multi-token `/command`·`$skill` trigger highlight that paints every invocation and survives the trailing space, and a fresh `jeo --tmux` no-leak re-verification.
205
207
  - **[0.6.31]** (2026-06-19) — Live "Thinking" indicator for signature-only reasoning models (Anthropic opus-4-7/4-8), a live color cue when a `/command` or `$skill` trigger is recognized in the prompt, and a rich gjc-style `/resume` session picker — plus a fresh `jeo --tmux` no-leak re-verification.
206
- - **[0.6.30]** (2026-06-19) — gjc-style intermediate-judgment guard classification extracted from the engine loop, plus a re-verification that `jeo --tmux` does not leak bun memory or slow down.
207
- - **[0.6.29]** (2026-06-19) — Signature-only thinking-block replay (Anthropic opus-4-7/4-8), plus a tmux mouse-flood memory guard confirming `jeo --tmux` does not leak.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -200,11 +200,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
200
200
  ## 更新日志 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.35]** (2026-06-20) — The prompt's Ctrl+C now clears a non-empty input box on the first press and only exits on the next press of an empty box; plus app-driven system-clipboard copy (OSC 52 + local tool, tmux-aware), drag-and-drop image attachment, a Ctrl-L prompt re-anchor, and a SIGCONT resume repaint — verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot check.
204
+ - **[0.6.34]** (2026-06-20) — Per-session model memory — each saved session now remembers the model it was last using and restores it on `/resume` — plus clearer `jeo --tmux` attach diagnostics, a tmux session-name double-dash fix, and a more robust no-leak probe gate.
203
205
  - **[0.6.33]** (2026-06-19) — A redesigned `jeo` forge mark — a hollow line-board crayfish/eyeglass emblem drawn as thick rounded-corner tubes (no letters, no DNA helix) — that now renders inside compact-scaled forge cards, plus a unified verification directive that adds gjc's test-quality contract, and a fresh `jeo --tmux` no-leak re-verification.
204
206
  - **[0.6.32]** (2026-06-19) — Anthropic extended thinking is actually enabled now — the request finally sends a `thinking` block (adaptive for Opus/Sonnet 4.6+, budget for older), fixing reasoning on **opus-4-8** — plus a multi-token `/command`·`$skill` trigger highlight that paints every invocation and survives the trailing space, and a fresh `jeo --tmux` no-leak re-verification.
205
207
  - **[0.6.31]** (2026-06-19) — Live "Thinking" indicator for signature-only reasoning models (Anthropic opus-4-7/4-8), a live color cue when a `/command` or `$skill` trigger is recognized in the prompt, and a rich gjc-style `/resume` session picker — plus a fresh `jeo --tmux` no-leak re-verification.
206
- - **[0.6.30]** (2026-06-19) — gjc-style intermediate-judgment guard classification extracted from the engine loop, plus a re-verification that `jeo --tmux` does not leak bun memory or slow down.
207
- - **[0.6.29]** (2026-06-19) — Signature-only thinking-block replay (Anthropic opus-4-7/4-8), plus a tmux mouse-flood memory guard confirming `jeo --tmux` does not leak.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.6.33",
3
+ "version": "0.6.35",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -10,6 +10,9 @@ export interface SessionHeader {
10
10
  timestamp: string;
11
11
  cwd: string;
12
12
  title?: string;
13
+ /** Model id pinned to this session; restored on resume so each session can carry
14
+ * its own model independent of the global default (per-session model selection). */
15
+ model?: string;
13
16
  }
14
17
 
15
18
  export interface SessionEntry {
@@ -35,6 +38,8 @@ export interface SessionSummary {
35
38
  /** Session file size in bytes (for the resume picker's metadata line). */
36
39
  sizeBytes?: number;
37
40
  title?: string;
41
+ /** Model id pinned to this session (header `model`), if any. */
42
+ model?: string;
38
43
  }
39
44
 
40
45
  export const SESSION_VERSION = 1;
@@ -53,7 +58,8 @@ export function sessionPath(id: string, cwd = process.cwd()): string {
53
58
 
54
59
  export async function createSession(
55
60
  cwd = process.cwd(),
56
- id = newSessionId()
61
+ id = newSessionId(),
62
+ model?: string
57
63
  ): Promise<{ id: string; path: string }> {
58
64
  const dir = sessionsDir(cwd);
59
65
  await fs.mkdir(dir, { recursive: true });
@@ -64,6 +70,7 @@ export async function createSession(
64
70
  id,
65
71
  timestamp: new Date().toISOString(),
66
72
  cwd,
73
+ ...(model ? { model } : {}),
67
74
  };
68
75
 
69
76
  const file = sessionPath(id, cwd);
@@ -292,6 +299,7 @@ export async function listSessions(cwd = process.cwd()): Promise<SessionSummary[
292
299
  mtimeMs: stat.mtimeMs,
293
300
  sizeBytes: stat.size,
294
301
  title: header.title,
302
+ model: header.model,
295
303
  });
296
304
  } catch {
297
305
  // Tolerate malformed files (skip them)
@@ -309,10 +317,16 @@ export async function latestSessionId(cwd = process.cwd()): Promise<string | und
309
317
  }
310
318
 
311
319
  /**
312
- * Rename a session by updating the title in its JSONL header.
313
- * Throws a clear Error if the session file does not exist.
320
+ * Locate the session's JSONL header, apply `mutate`, and rewrite the file in place.
321
+ * `mutate` returns false to signal "no change needed" (skips the write). Shared by
322
+ * {@link renameSession} and {@link updateSessionModel}. Throws a clear Error when the
323
+ * session file or its header is missing.
314
324
  */
315
- export async function renameSession(id: string, title: string, cwd = process.cwd()): Promise<void> {
325
+ async function rewriteSessionHeader(
326
+ id: string,
327
+ mutate: (header: SessionHeader) => boolean,
328
+ cwd: string,
329
+ ): Promise<void> {
316
330
  const file = sessionPath(id, cwd);
317
331
  let content: string;
318
332
  try {
@@ -347,11 +361,35 @@ export async function renameSession(id: string, title: string, cwd = process.cwd
347
361
  throw new Error(`Session header missing in session ${id}`);
348
362
  }
349
363
 
350
- header.title = title;
364
+ if (!mutate(header)) return;
351
365
  lines[headerIndex] = JSON.stringify(header);
352
366
  await fs.writeFile(file, lines.join("\n"), "utf8");
353
367
  }
354
368
 
369
+ /**
370
+ * Rename a session by updating the title in its JSONL header.
371
+ * Throws a clear Error if the session file does not exist.
372
+ */
373
+ export async function renameSession(id: string, title: string, cwd = process.cwd()): Promise<void> {
374
+ await rewriteSessionHeader(id, header => {
375
+ header.title = title;
376
+ return true;
377
+ }, cwd);
378
+ }
379
+
380
+ /**
381
+ * Pin a model to a session by updating the `model` field in its JSONL header so a
382
+ * later `/resume` restores it. No-op (no write) when the header already names that
383
+ * model. Throws a clear Error if the session file does not exist.
384
+ */
385
+ export async function updateSessionModel(id: string, model: string, cwd = process.cwd()): Promise<void> {
386
+ await rewriteSessionHeader(id, header => {
387
+ if (header.model === model) return false;
388
+ header.model = model;
389
+ return true;
390
+ }, cwd);
391
+ }
392
+
355
393
  /**
356
394
  * Delete a session file.
357
395
  * Returns false on ENOENT, true on success.
@@ -408,3 +408,30 @@ export function classifyMidTurnLine(line: string): "command" | "steer" | "empty"
408
408
  if (t === "/" || t === "$") return "empty";
409
409
  return /^[/$]/.test(t) ? "command" : "steer";
410
410
  }
411
+ /** Default window (ms) over which the several delivery paths of ONE physical Ctrl+C
412
+ * press — readline 'keypress', the rl/process 'SIGINT' event, and the raw \u0003 stdin
413
+ * byte — collapse into a single logical action. A genuine second press (the user
414
+ * reacting to the just-cleared box) is far slower than this, so it is never swallowed. */
415
+ export const CTRLC_COLLAPSE_MS = 50;
416
+
417
+ export type CtrlCAction = "ignore" | "clear" | "exit";
418
+
419
+ /** Decide what a Ctrl+C at the idle prompt should do, given whether the input box
420
+ * currently holds anything clearable (typed text, a pending clipboard image, or a
421
+ * queued pasted batch) and how long ago the previous Ctrl+C was handled:
422
+ *
423
+ * - within `collapseMs` of the last handled press → "ignore" (duplicate delivery of
424
+ * the SAME keystroke; acting on both would let one press clear AND then exit).
425
+ * - input present → "clear" (wipe the box, stay put).
426
+ * - box already empty → "exit" (hard terminal break, 130).
427
+ *
428
+ * Pure so the "first Ctrl+C clears, next Ctrl+C exits" contract is unit-testable
429
+ * without a live TTY. */
430
+ export function decideCtrlC(
431
+ hasInput: boolean,
432
+ msSinceLastCtrlC: number,
433
+ collapseMs: number = CTRLC_COLLAPSE_MS,
434
+ ): CtrlCAction {
435
+ if (msSinceLastCtrlC < collapseMs) return "ignore";
436
+ return hasInput ? "clear" : "exit";
437
+ }
@@ -15,11 +15,14 @@ export function hotkeysLines(): string[] {
15
15
  " Ctrl-C cancel the in-flight turn (press again at the prompt to exit)",
16
16
  " Ctrl-D exit the REPL",
17
17
  " Ctrl-O dump the full last response (untruncated, tables rendered) into scrollback",
18
+ " Ctrl-L redraw / re-anchor the prompt (recover the input box after the screen scrolls)",
18
19
  " Ctrl-K / Ctrl-U / Ctrl-W kill to end / start of line / previous word (emacs kill-ring)",
19
20
  " Ctrl-Y / Alt-Y yank / yank-pop the killed text",
20
21
  " Ctrl-A / Ctrl-E move to start / end of line",
21
22
  " / open the slash-command palette",
22
23
  " @path mention a file (Tab completes relative paths)",
24
+ " Ctrl-V paste a copied image from the clipboard into the next message",
25
+ " drag-drop drop an image file onto the box to attach it (its path becomes [image #N])",
23
26
  ];
24
27
  }
25
28
 
@@ -1,6 +1,7 @@
1
1
  import * as path from "node:path";
2
2
  import * as fs from "node:fs";
3
3
  import { type LaunchFlags } from "./flags";
4
+ import { tmuxCopyCommand } from "../../tui/clipboard";
4
5
 
5
6
  function hashString(input: string): string {
6
7
  let hash = 2166136261;
@@ -14,7 +15,11 @@ function hashString(input: string): string {
14
15
  function tmuxSafeNamePart(input: string, max = 32): string {
15
16
  const safe = input.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "value";
16
17
  if (safe.length <= max) return safe;
17
- return `${safe.slice(0, Math.max(1, max - 7))}-${hashString(input)}`;
18
+ // Trim a trailing dash from the truncated head so a boundary landing right
19
+ // after a `-` doesn't produce an ugly `name--<hash>` (double dash). The head
20
+ // is guaranteed non-empty and to start with an alnum (safe is trimmed).
21
+ const head = safe.slice(0, Math.max(1, max - 7)).replace(/-+$/, "") || safe.slice(0, 1);
22
+ return `${head}-${hashString(input)}`;
18
23
  }
19
24
 
20
25
  function tmuxRuntimeSuffix(flags: LaunchFlags): string {
@@ -151,6 +156,7 @@ export function tmuxProfileCommands(
151
156
  target: string,
152
157
  env: Record<string, string | undefined>,
153
158
  meta: { branch?: string; project?: string } = {},
159
+ deps: { platform?: NodeJS.Platform; which?: (bin: string) => string | null } = {},
154
160
  ): TmuxProfileCommand[] {
155
161
  const t = `=${target}:`;
156
162
  const commands: TmuxProfileCommand[] = [];
@@ -187,7 +193,20 @@ export function tmuxProfileCommands(
187
193
  args: ["set-window-option", "-t", t, "mode-style", "fg=colour231,bg=colour60"],
188
194
  },
189
195
  );
196
+ // Pipe the copy-mode selection straight to the SYSTEM clipboard tool
197
+ // (pbcopy / wl-copy / xclip / xsel). With `mouse on`, a mouse drag-select
198
+ // releases into copy-mode and `copy-command` lands it on the OS clipboard —
199
+ // so a tmux drag-select copies for `cmd+v` even where the outer terminal
200
+ // doesn't honor OSC 52. Skipped when no clipboard tool is on PATH.
201
+ const copyCmd = tmuxCopyCommand(deps.platform ?? process.platform, deps.which ?? ((bin: string) => Bun.which(bin)));
202
+ if (copyCmd) {
203
+ commands.push({
204
+ description: "pipe copy-mode selection to the system clipboard",
205
+ args: ["set-option", "-t", t, "copy-command", copyCmd],
206
+ });
207
+ }
190
208
  }
209
+
191
210
  return commands;
192
211
  }
193
212
 
@@ -67,7 +67,9 @@ import { renderInputFrame, verticalCursorOffset, type HighlightRange } from "../
67
67
  import { renderStatusBar } from "../tui/components/status";
68
68
  import { detectColorLevel, ColorLevel, visibleWidth } from "../tui/components/color";
69
69
  import { readClipboardImage } from "../util/clipboard-image";
70
+ import { attachImagePaths } from "../util/file-attachment";
70
71
  import { formatTranscript } from "../tui/components/transcript";
72
+ import { copyTextToClipboard } from "../tui/clipboard";
71
73
  import { loadInputHistory, appendInputHistory } from "../agent/input-history";
72
74
  import type { ImageAttachment } from "../ai/types";
73
75
  import { renderMarkdownTables } from "../tui/components/markdown-table";
@@ -91,11 +93,12 @@ import {
91
93
  latestSessionId,
92
94
  exportSession,
93
95
  renameSession,
96
+ updateSessionModel,
94
97
  deleteSession,
95
98
  sessionPath,
96
99
  appendCompaction,
97
100
  } from "../agent/session";
98
- import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking, clearScreen, clearToEnd } from "../tui/terminal";
101
+ import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking, clearScreen, clearVisible, clearToEnd } from "../tui/terminal";
99
102
 
100
103
  import {
101
104
  type LaunchFlags,
@@ -137,6 +140,7 @@ import {
137
140
  restoreQueuedLinesToPrefill,
138
141
  createInFlightAbortHarness,
139
142
  classifyMidTurnLine,
143
+ decideCtrlC,
140
144
  } from "./launch/input";
141
145
  import {
142
146
  gatedStdout,
@@ -383,8 +387,27 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
383
387
  stdout: "inherit",
384
388
  stderr: "inherit",
385
389
  });
386
- await attach.exited;
390
+ // A nonzero attach exit (e.g. "open terminal failed: not a terminal" when
391
+ // stdout isn't a real TTY, a too-small client, or a transient server error)
392
+ // otherwise vanished: jeo returned 0 and the freshly created session was left
393
+ // orphaned with no hint. Surface it. Only advise reattach when the session is
394
+ // STILL live — if the inner jeo already exited (bad args, instant crash) the
395
+ // session is gone and "reattach" would be misleading.
396
+ const attachCode = await attach.exited;
397
+ if (attachCode !== 0) {
398
+ const alive = Bun.spawnSync([tmuxBin, "has-session", "-t", `=${sessionName}`], {
399
+ stdout: "ignore",
400
+ stderr: "ignore",
401
+ }).exitCode === 0;
402
+ console.error(
403
+ alive
404
+ ? `Error: tmux attach failed (exit ${attachCode}). The session is still running; reattach with: tmux attach -t ${sessionName}`
405
+ : `Error: tmux session ${sessionName} ended before it could be attached (attach exit ${attachCode}).`,
406
+ );
407
+ process.exitCode = attachCode;
408
+ }
387
409
  return;
410
+
388
411
  } else {
389
412
  console.warn("warning: tmux is not available on PATH. Launching directly...");
390
413
  }
@@ -411,7 +434,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
411
434
  }
412
435
  console.log("Saved sessions (newest first):");
413
436
  for (const s of sessions) {
414
- console.log(` ${s.id} ${s.timestamp} (${s.messageCount} msgs) ${s.preview}`);
437
+ console.log(` ${s.id} ${s.timestamp} (${s.messageCount} msgs)${s.model ? ` [${s.model}]` : ""} ${s.preview}`);
415
438
  }
416
439
  console.log("\nResume with: jeo launch --resume <id>");
417
440
  return;
@@ -588,23 +611,37 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
588
611
  const id = flags.resumeId ?? (await latestSessionId(cwd));
589
612
  if (!id) {
590
613
  console.log("No session to resume. Starting a new one.");
591
- sessionId = (await createSession(cwd)).id;
614
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
592
615
  } else {
593
616
  try {
594
- const { messages } = await loadSession(id, cwd);
617
+ const { header, messages } = await loadSession(id, cwd);
595
618
  for (const m of messages) history.push(m);
596
619
  sessionId = id;
597
- console.log(`Resumed session ${id} (${messages.length} messages).`);
620
+ // Restore the model this session was last using unless the CLI explicitly
621
+ // pinned one (flags.model/role/provider → initialSessionModel wins).
622
+ if (!initialSessionModel && header.model) sessionModel = header.model;
623
+ const modelNote = sessionModel ? ` · model ${sessionModel}` : "";
624
+ console.log(`Resumed session ${id} (${messages.length} messages).${modelNote}`);
598
625
  } catch (err) {
599
626
  console.log(`Could not resume ${id}: ${(err as Error).message}. Starting fresh.`);
600
- sessionId = (await createSession(cwd)).id;
627
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
601
628
  }
602
629
  }
603
630
  } else {
604
- sessionId = (await createSession(cwd)).id;
631
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
605
632
  }
606
633
  }
607
634
 
635
+ // Persist the active per-session model into the session header so `/resume` restores
636
+ // it (each session can carry its own model independent of the global default).
637
+ // Best-effort: a header-rewrite failure must never abort the turn.
638
+ const persistSessionModel = async (): Promise<void> => {
639
+ if (flags.noSession || !sessionId || !sessionModel) return;
640
+ try {
641
+ await updateSessionModel(sessionId, sessionModel, cwd);
642
+ } catch { /* best-effort */ }
643
+ };
644
+
608
645
  // `step N/M` display seed: the explicit --max-steps cap, else the dynamic budget's
609
646
  // rolling base — the engine's onBudget event keeps the denominator honest as it grows.
610
647
  const initialStepLimit = flags.maxSteps > 0 ? flags.maxSteps : initialDynamicStepLimit();
@@ -1007,7 +1044,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1007
1044
  history.length = 1;
1008
1045
  if (sessionId && !flags.noSession) {
1009
1046
  try {
1010
- sessionId = (await createSession(cwd)).id;
1047
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
1011
1048
  } catch { /* best-effort: in-memory clear already done */ }
1012
1049
  }
1013
1050
  console.log("(history cleared)");
@@ -1411,6 +1448,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1411
1448
  out += data.slice(i, i + 3); i += 3; continue;
1412
1449
  }
1413
1450
  if (loneLfShiftEnter && data[i] === "\n") { out += SENTINEL; i += 1; continue; } // lone LF = Shift+Enter (opt-in)
1451
+ // Ctrl+L (form feed): consumed as the prompt "redraw / re-anchor" hotkey (handled on
1452
+ // the process.stdin 'keypress' listener), so it must never reach readline as a literal
1453
+ // char that would otherwise insert garbage / desync the box from rl.line.
1454
+ if (data[i] === "\u000c") { i += 1; continue; }
1414
1455
  out += data[i]; i += 1;
1415
1456
  }
1416
1457
  kf.write(out);
@@ -1974,13 +2015,48 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1974
2015
  out.write(s);
1975
2016
  };
1976
2017
 
1977
- // ESC at the prompt: wipe the typed text (and detach any pending clipboard
1978
- // images their `[image #N]` tags live in that text) instead of leaving stale
1979
- // input. Ctrl+C is no longer a line editor shortcut; it hard-exits below.
2018
+ // Ctrl-L "redraw / re-anchor" recovery. The boxed footer paints IN PLACE relative to
2019
+ // the last parked cursor row; if the terminal scrolled for any reason the app did not
2020
+ // drive (a stray async stdout write, a tmux pane reflow, a wheel-scroll past the margin),
2021
+ // that anchor goes stale and subsequent repaints land on the wrong rows — the box looks
2022
+ // frozen and typed text never appears, even though readline IS still capturing every key
2023
+ // ("화면이 밀려서 입력이 안 보이는" 상태). This wipes the VISIBLE screen (scrollback kept),
2024
+ // re-reserves the footer at the top, and repaints from readline's live buffer, so the box
2025
+ // and caret snap back and whatever was already typed shows immediately.
2026
+ const redrawPromptFooter = () => {
2027
+ if (!previewArmed) return;
2028
+ try {
2029
+ const rows = process.stdout.rows ?? 24;
2030
+ footerRendered = 0;
2031
+ footerParkedRow = 0;
2032
+ lastFooterKey = "";
2033
+ lastDrawnLines = [];
2034
+ out.write(clearVisible());
2035
+ footerRows = previewRowsFor(rows);
2036
+ const initial = Math.max(1, Math.min(footerRows, COMPACT_FOOTER_ROWS));
2037
+ if (initial > 1) out.write("\n".repeat(initial - 1) + cursorUp(initial - 1));
2038
+ out.write(toColumn(1));
2039
+ footerRendered = initial;
2040
+ footerWantRows = initial;
2041
+ drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
2042
+ } catch { /* ignore redraw races */ }
2043
+ };
2044
+
2045
+ // ESC — and a FIRST Ctrl+C while the box is non-empty — wipe the typed text (and
2046
+ // detach any pending clipboard images, whose `[image #N]` tags live in that text)
2047
+ // instead of leaving stale input. A Ctrl+C on the already-empty box hard-exits
2048
+ // (see handleCtrlC below).
2049
+ /** True when the prompt box holds anything a clear/Ctrl+C would discard: typed
2050
+ * text, a pending clipboard image, or a queued pasted batch. Single source of the
2051
+ * "is there input?" predicate shared by clearTypedInput and the Ctrl+C decision. */
2052
+ const promptHasContent = (): boolean => {
2053
+ const line = (rl as unknown as { line?: string }).line;
2054
+ return (line?.length ?? 0) > 0 || pendingImages.length > 0 || queuedPromptInput.pastedLines.length > 0;
2055
+ };
1980
2056
  const clearTypedInput = (): boolean => {
1981
2057
  const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
1982
2058
  const hadPastedQueue = queuedPromptInput.pastedLines.length > 0;
1983
- if ((rli.line?.length ?? 0) === 0 && pendingImages.length === 0 && !hadPastedQueue) return false;
2059
+ if (!promptHasContent()) return false;
1984
2060
  // ESC is the escape hatch for an accidental giant paste: drop the queued batch.
1985
2061
  if (hadPastedQueue) {
1986
2062
  const dropped = queuedPromptInput.pastedLines.splice(0).length;
@@ -1997,9 +2073,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1997
2073
  if (previewArmed) drawFooter(previewLines(""));
1998
2074
  return true;
1999
2075
  };
2000
- // Ctrl+C at the prompt is a hard terminal break (exit code 130), not a line
2076
+ // A Ctrl+C on an EMPTY prompt is a hard terminal break (exit code 130), not a line
2001
2077
  // editor shortcut. `/exit` remains the graceful session-save path; ^C is the
2002
- // emergency "get me back to my shell now" path and must work on the first press.
2078
+ // emergency "get me back to my shell now" path. A first Ctrl+C with text in the box
2079
+ // CLEARS it instead (handleCtrlC); the next Ctrl+C on the now-empty box exits.
2003
2080
  const forceExitFromCtrlC = () => {
2004
2081
  try {
2005
2082
  disarmPreview();
@@ -2010,15 +2087,28 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2010
2087
  }
2011
2088
  process.exit(130);
2012
2089
  };
2013
- const forceExitOnCtrlCByte = (chunk: string | Uint8Array) => {
2090
+ // Prompt Ctrl+C: a single press clears a non-empty box; on an empty box it exits.
2091
+ // `lastCtrlCAt` collapses the duplicate deliveries of one physical press (keypress
2092
+ // + SIGINT + raw byte) so a single Ctrl+C can't clear AND then immediately exit.
2093
+ let lastCtrlCAt = 0;
2094
+ const handleCtrlC = () => {
2095
+ const now = Date.now();
2096
+ const action = decideCtrlC(promptHasContent(), now - lastCtrlCAt);
2097
+ if (action === "ignore") return; // duplicate delivery of the same press
2098
+ lastCtrlCAt = now;
2099
+ if (action === "clear") clearTypedInput(); // had input → wipe it, stay at the prompt
2100
+ else forceExitFromCtrlC(); // empty → hard exit (130)
2101
+ };
2102
+ const handleCtrlCByte = (chunk: string | Uint8Array) => {
2014
2103
  const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
2015
- if (text.includes("\u0003")) forceExitFromCtrlC();
2104
+ if (text.includes("\u0003")) handleCtrlC();
2016
2105
  };
2017
2106
  // Bun/readline can deliver Ctrl+C as readline SIGINT, process SIGINT, or (tmux)
2018
- // a raw \u0003 byte before readline resolves the question; wire all three.
2019
- process.on("SIGINT", forceExitFromCtrlC);
2020
- process.stdin.on("data", forceExitOnCtrlCByte);
2021
- rl.on("SIGINT", forceExitFromCtrlC);
2107
+ // a raw \u0003 byte before readline resolves the question; funnel all three through
2108
+ // handleCtrlC (clear-or-exit + de-duplication) so the behavior is identical.
2109
+ process.on("SIGINT", handleCtrlC);
2110
+ process.stdin.on("data", handleCtrlCByte);
2111
+ rl.on("SIGINT", handleCtrlC);
2022
2112
 
2023
2113
  const runSelectPicker = async <T>(
2024
2114
  render: (cols: number, rows: number) => string[],
@@ -2317,6 +2407,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2317
2407
  const { resolved, provider } = await describeModel(target);
2318
2408
  const st = (await describeAllProviders(cfgForPick)).find(s => s.name === provider);
2319
2409
  sessionModel = target;
2410
+ await persistSessionModel();
2320
2411
  const defaultThinking = isThinkingLevel(action) ? action : undefined;
2321
2412
  if (defaultThinking) {
2322
2413
  sessionThinking = defaultThinking;
@@ -2484,10 +2575,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2484
2575
  process.once("exit", () => out.write("\x1b[?25h")); // safety net: never leave the cursor hidden
2485
2576
  const footerKeypressHandler = (_ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean } | undefined) => {
2486
2577
  if (key?.ctrl && key.name === "c") {
2487
- forceExitFromCtrlC();
2578
+ handleCtrlC();
2488
2579
  return;
2489
2580
  }
2490
2581
  if (!previewArmed || pickerActive) return;
2582
+ // Ctrl+L: redraw / re-anchor the prompt. The recovery for a footer whose in-place
2583
+ // anchor drifted after the screen scrolled (typed text stops showing in the box).
2584
+ if (key?.ctrl && key.name === "l") {
2585
+ redrawPromptFooter();
2586
+ return;
2587
+ }
2491
2588
  // Ctrl+O: toggle a reversible, scrollable detail panel inside the footer
2492
2589
  // (expand on the first press, FOLD on the next). ↑↓/PgUp/PgDn scroll it while
2493
2590
  // open — long/CJK content is fully reachable, nothing is clipped. The live-turn
@@ -2557,7 +2654,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2557
2654
  clearTypedInput();
2558
2655
  return;
2559
2656
  }
2560
- // Ctrl+C hard-exits above; keep this guard for defensive ordering only.
2657
+ // Ctrl+C is handled above (clear-or-exit); keep this guard for defensive ordering only.
2561
2658
  if (key?.ctrl && key.name === "c") return;
2562
2659
  previewPending = true;
2563
2660
  setImmediate(() => {
@@ -2805,6 +2902,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2805
2902
  pendingSelection = undefined;
2806
2903
  navMatches = [];
2807
2904
  navIdx = -1;
2905
+ // File attachment: a dragged-and-dropped image file arrives as its path in
2906
+ // the typed text. Read any image path(s), attach them, and swap the path for
2907
+ // an [image #N] tag (continuing the clipboard-image numbering) — the same
2908
+ // scheme Ctrl+V uses. Skipped for slash commands and `!` shell escapes.
2909
+ if (input && !input.startsWith("/") && !input.startsWith("!")) {
2910
+ const dropped = await attachImagePaths(input, pendingImages.length + 1);
2911
+ if (dropped.images.length > 0) {
2912
+ pendingImages.push(...dropped.images);
2913
+ input = dropped.text;
2914
+ console.log(chalk.dim(`(attached ${dropped.images.length} image file${dropped.images.length > 1 ? "s" : ""} from path)`));
2915
+ }
2916
+ }
2808
2917
  if (input === "/exit" || input === "/quit") break;
2809
2918
  if (input === "") {
2810
2919
  if (pendingImages.length === 0) continue;
@@ -2873,7 +2982,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2873
2982
  const startFreshSession = async (verb: string): Promise<void> => {
2874
2983
  history.length = 1;
2875
2984
  if (!flags.noSession) {
2876
- sessionId = (await createSession(cwd)).id;
2985
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
2877
2986
  advanceSessionBoxColor(); // distinct input-box hue per newly opened session
2878
2987
  console.log(`(${verb} — new session ${sessionId})`);
2879
2988
  } else {
@@ -2916,10 +3025,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2916
3025
  const arg = tokens.slice(1).join(" ").trim();
2917
3026
  const applyResume = async (rid: string): Promise<void> => {
2918
3027
  try {
2919
- const { messages } = await loadSession(rid, cwd);
3028
+ const { header, messages } = await loadSession(rid, cwd);
2920
3029
  history.length = 1;
2921
3030
  for (const m of messages) history.push(m);
2922
3031
  sessionId = rid;
3032
+ // Restore the model this session was last using (per-session model).
3033
+ if (header.model) sessionModel = header.model;
2923
3034
  // Seed /retry + reply marker from the last user/assistant turn.
2924
3035
  lastUserInput = ""; lastReply = "";
2925
3036
  for (let k = history.length - 1; k >= 1; k--) {
@@ -3143,16 +3254,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3143
3254
  }
3144
3255
  try {
3145
3256
  const text = await exportSession(sessionId, "markdown", cwd);
3146
- const clip = process.platform === "darwin" ? "pbcopy" : Bun.which("wl-copy") ? "wl-copy" : Bun.which("xclip") ? "xclip" : "";
3147
- if (clip && Bun.which(clip)) {
3148
- const proc = Bun.spawn(clip === "xclip" ? [clip, "-selection", "clipboard"] : [clip], { stdin: "pipe" });
3149
- proc.stdin.write(text);
3150
- await proc.stdin.end();
3151
- await proc.exited;
3152
- console.log(`(transcript copied to clipboard — ${text.length} chars)`);
3257
+ // Copy via BOTH OSC 52 (reaches the outer terminal over SSH/tmux) and a
3258
+ // local clipboard tool (pbcopy / wl-copy / xclip / xsel / clip) — the union
3259
+ // makes `cmd+v` find the transcript regardless of where the terminal runs.
3260
+ const copied = await copyTextToClipboard(text);
3261
+ if (copied.osc52 || copied.local) {
3262
+ const via = [copied.local ? "system tool" : "", copied.osc52 ? "OSC52" : ""].filter(Boolean).join(" + ");
3263
+ console.log(`(transcript copied to clipboard via ${via} — ${text.length} chars)`);
3153
3264
  } else {
3154
3265
  console.log(text);
3155
- console.log("(no clipboard tool found — transcript printed above)");
3266
+ console.log("(no clipboard path available — transcript printed above)");
3156
3267
  }
3157
3268
  } catch (err) {
3158
3269
  console.log(`! dump failed: ${(err as Error).message}`);
@@ -3497,6 +3608,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3497
3608
  const qualified = qualifyModelId(modelArg, "openai");
3498
3609
  sessionModel = qualified;
3499
3610
  await saveConfigPatch(raw => rememberModelPatch(raw, qualified));
3611
+ await persistSessionModel();
3500
3612
  console.log(`OpenAI-compatible endpoint set: ${url} · default model ${qualified} — saved to ~/.jeo/config.json.`);
3501
3613
  } else {
3502
3614
  console.log(`OpenAI-compatible endpoint set: ${url} — saved to ~/.jeo/config.json.`);
@@ -4012,6 +4124,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4012
4124
  // MRU persistence: picking a model IS saving it — the newest pick wins
4013
4125
  // as the global default; recents keep the rotation for every session.
4014
4126
  await saveConfigPatch(raw => rememberModelPatch(raw, arg));
4127
+ await persistSessionModel();
4015
4128
  }
4016
4129
  const { resolved, provider } = await describeModel(label);
4017
4130
  const st = statuses.find(s => s.name === provider);
@@ -4231,8 +4344,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4231
4344
  disarmPreview();
4232
4345
  out.write("\x1b[?25h\n");
4233
4346
  } catch { /* best effort */ }
4234
- process.removeListener("SIGINT", forceExitFromCtrlC);
4235
- process.stdin.off("data", forceExitOnCtrlCByte);
4347
+ process.removeListener("SIGINT", handleCtrlC);
4348
+ process.stdin.off("data", handleCtrlCByte);
4236
4349
  drainPromptListeners();
4237
4350
  restorePromptRawMode();
4238
4351
  process.exit(130);
@@ -4247,8 +4360,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4247
4360
  // gjc-parity resume pointer (logs/gjc-tui-study analysis Gap C): leave the exact
4248
4361
  // resume command in scrollback on exit, mirroring the --list handler's convention.
4249
4362
  if (sessionId && !flags.noSession) console.log(formatResumeHint(sessionId));
4250
- process.removeListener("SIGINT", forceExitFromCtrlC);
4251
- process.stdin.off("data", forceExitOnCtrlCByte);
4363
+ process.removeListener("SIGINT", handleCtrlC);
4364
+ process.stdin.off("data", handleCtrlCByte);
4252
4365
  drainPromptListeners();
4253
4366
  restorePromptRawMode();
4254
4367
  gracefulReadlineClose = true;
package/src/tui/app.ts CHANGED
@@ -1008,6 +1008,13 @@ export class LaunchTui {
1008
1008
  // force a full repaint instead of diffing against stale line positions.
1009
1009
  if (this.tty) {
1010
1010
  process.stdout.on("resize", this.onResize);
1011
+ // Suspend (Ctrl-Z) → resume (fg): SIGWINCH is lost while the process is stopped,
1012
+ // so a terminal resized mid-suspend would resume with stale geometry and a torn
1013
+ // frame. SIGCONT fires on resume; force a re-measure + full repaint. POSIX only
1014
+ // (Windows has no SIGCONT — registering it would throw).
1015
+ if (process.platform !== "win32") {
1016
+ process.on("SIGCONT", this.onResume);
1017
+ }
1011
1018
  }
1012
1019
  // Animate the spinner + elapsed clock while the model is thinking.
1013
1020
  this.timer = setInterval(() => {
@@ -1063,6 +1070,17 @@ export class LaunchTui {
1063
1070
  } catch { /* resize race — next tick repaints */ }
1064
1071
  }
1065
1072
 
1073
+ /** Resume from suspend (SIGCONT after Ctrl-Z). The terminal may have been resized
1074
+ * while the process was stopped — that SIGWINCH is dropped, so the cached geometry
1075
+ * is stale. Invalidate lastCols/lastRows so resizeRepaint cannot early-return on a
1076
+ * same-looking measurement, then re-measure and fully repaint at the current size. */
1077
+ private readonly onResume = (): void => {
1078
+ if (this.finished) return;
1079
+ this.lastCols = -1;
1080
+ this.lastRows = -1;
1081
+ this.resizeRepaint();
1082
+ };
1083
+
1066
1084
  /** gjc-style agent identity: a bold accent `jeo` name label on its own line that leads
1067
1085
  * every assistant segment — thought blocks (onAssistant) and the final reply (finish). */
1068
1086
  private agentLabel(): string {
@@ -1086,6 +1104,9 @@ export class LaunchTui {
1086
1104
  }
1087
1105
  if (this.tty) {
1088
1106
  process.stdout.removeListener("resize", this.onResize);
1107
+ if (process.platform !== "win32") {
1108
+ process.removeListener("SIGCONT", this.onResume);
1109
+ }
1089
1110
  }
1090
1111
  if (this.usedAltScreen) {
1091
1112
  // Leave the alt screen (restores the main buffer + scrollback), then print the
@@ -0,0 +1,145 @@
1
+ /**
2
+ * System-clipboard COPY for the jeo TUI.
3
+ *
4
+ * Why this exists — the terminal reality:
5
+ * - `cmd+c` / `cmd+v` are owned by the terminal emulator + OS, NOT the app. A
6
+ * terminal program cannot bind them. `cmd+v` already works: jeo enables
7
+ * bracketed paste (DECSET 2004), so a paste arrives as data on stdin. `cmd+c`
8
+ * copies whatever the user has SELECTED with the mouse — that selection is the
9
+ * terminal's, and the only way an app can put text on the system clipboard
10
+ * itself is the OSC 52 escape (or a local clipboard subprocess).
11
+ * - Under tmux `mouse on`, a mouse drag is captured by tmux's copy-mode instead
12
+ * of the terminal, so `cmd+c` no longer copies the dragged text. The fix lives
13
+ * in the tmux profile (`copy-command`, see launch/tmux.ts): it pipes the
14
+ * copy-mode selection straight to the system clipboard tool.
15
+ *
16
+ * This module gives jeo an app-driven "copy to the system clipboard" that works
17
+ * locally AND across SSH/tmux:
18
+ * - OSC 52 (`ESC ] 52 ; c ; <base64> BEL`) asks the OUTER terminal to set its
19
+ * clipboard — the only mechanism that survives an SSH hop. Wrapped in tmux DCS
20
+ * passthrough when running inside tmux so it reaches the real terminal.
21
+ * - A local clipboard subprocess (pbcopy / wl-copy / xclip / xsel / clip) as a
22
+ * belt-and-suspenders path when jeo runs on the same machine as the terminal.
23
+ */
24
+
25
+ /** Max base64 payload pushed through OSC 52. Many terminals silently drop very
26
+ * large clipboard writes (xterm's default is ~100KB of selection data); past
27
+ * this we skip OSC 52 and rely on the local clipboard tool instead. */
28
+ export const OSC52_MAX_BASE64 = 100_000;
29
+
30
+ /**
31
+ * Resolve the local system-clipboard WRITE command for a platform, or null when
32
+ * no tool is available. Pure (the `which` probe is injected) so it is testable
33
+ * without touching the host.
34
+ * - macOS: `pbcopy`.
35
+ * - Windows: `clip` (clip.exe).
36
+ * - Linux/BSD: Wayland `wl-copy` first, then X11 `xclip`, then `xsel`.
37
+ */
38
+ export function systemClipboardCopyCommand(
39
+ platform: NodeJS.Platform,
40
+ which: (bin: string) => string | null,
41
+ ): string[] | null {
42
+ if (platform === "darwin") return which("pbcopy") ? ["pbcopy"] : null;
43
+ if (platform === "win32") return ["clip"]; // clip.exe always ships with Windows
44
+
45
+ if (which("wl-copy")) return ["wl-copy"];
46
+ if (which("xclip")) return ["xclip", "-selection", "clipboard"];
47
+ if (which("xsel")) return ["xsel", "--clipboard", "--input"];
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * The single shell command string tmux's `copy-command` option runs to push a
53
+ * copy-mode selection onto the system clipboard, or null when no tool exists.
54
+ * With `copy-command` set + `mouse on`, a mouse drag-select (and right-click
55
+ * paste menus, in terminals that surface them) lands directly on the system
56
+ * clipboard — bypassing OSC 52's terminal-support requirement. Shares the
57
+ * resolver above so the REPL `/dump` path and the tmux profile never diverge.
58
+ */
59
+ export function tmuxCopyCommand(
60
+ platform: NodeJS.Platform,
61
+ which: (bin: string) => string | null,
62
+ ): string | null {
63
+ const argv = systemClipboardCopyCommand(platform, which);
64
+ return argv ? argv.join(" ") : null;
65
+ }
66
+
67
+ export interface Osc52Options {
68
+ /** Wrap in tmux DCS passthrough so the sequence reaches the OUTER terminal. */
69
+ tmux?: boolean;
70
+ /** Clipboard selection: `c` = system clipboard (default), `p` = primary. */
71
+ clipboard?: "c" | "p";
72
+ }
73
+
74
+ /**
75
+ * Build the OSC 52 clipboard-SET escape for `text`, or "" when the base64 payload
76
+ * exceeds {@link OSC52_MAX_BASE64} (caller falls back to a local tool). When
77
+ * `tmux` is set the whole sequence is wrapped in DCS passthrough (`ESC P tmux ; …
78
+ * ESC \`) with every inner ESC doubled, the tmux contract for forwarding an
79
+ * escape to the terminal underneath it.
80
+ */
81
+ export function osc52Sequence(text: string, opts: Osc52Options = {}): string {
82
+ const b64 = Buffer.from(text, "utf8").toString("base64");
83
+ if (b64.length > OSC52_MAX_BASE64) return "";
84
+ const target = opts.clipboard ?? "c";
85
+ const inner = `\x1b]52;${target};${b64}\x07`;
86
+ if (!opts.tmux) return inner;
87
+ // tmux passthrough: inner ESCs are doubled; the trailing ESC \ is the DCS
88
+ // terminator and is appended AFTER the doubling, never itself doubled.
89
+ return `\x1bPtmux;${inner.replace(/\x1b/g, "\x1b\x1b")}\x1b\\`;
90
+ }
91
+
92
+ export interface CopyToClipboardDeps {
93
+ /** Write the OSC 52 escape to the terminal (defaults to process.stdout). */
94
+ write?: (s: string) => void;
95
+ /** Spawn a local clipboard subprocess (defaults to Bun.spawn). */
96
+ spawn?: (cmd: string[], opts: { stdin: "pipe" }) => { stdin: { write(s: string): void; end(): unknown }; exited: Promise<number> };
97
+ /** Probe for a binary on PATH (defaults to Bun.which). */
98
+ which?: (bin: string) => string | null;
99
+ /** Host platform (defaults to process.platform). */
100
+ platform?: NodeJS.Platform;
101
+ /** True when running inside tmux (defaults to !!process.env.TMUX). */
102
+ insideTmux?: boolean;
103
+ }
104
+
105
+ export interface CopyResult {
106
+ /** OSC 52 escape was emitted to the terminal. */
107
+ osc52: boolean;
108
+ /** A local clipboard subprocess accepted the text. */
109
+ local: boolean;
110
+ }
111
+
112
+ /**
113
+ * Copy `text` to the system clipboard via BOTH available paths: OSC 52 (works
114
+ * over SSH/tmux) and a local clipboard subprocess (works on the same host). Both
115
+ * are best-effort; the union maximizes the chance the user's `cmd+v` finds the
116
+ * text regardless of where the terminal actually runs. Returns which paths fired.
117
+ */
118
+ export async function copyTextToClipboard(text: string, deps: CopyToClipboardDeps = {}): Promise<CopyResult> {
119
+ const platform = deps.platform ?? process.platform;
120
+ const which = deps.which ?? ((bin: string) => Bun.which(bin));
121
+ const insideTmux = deps.insideTmux ?? !!process.env.TMUX;
122
+ const write = deps.write ?? ((s: string) => { try { process.stdout.write(s); } catch { /* terminal gone */ } });
123
+ const spawn = deps.spawn ?? ((cmd, opts) => Bun.spawn(cmd, opts) as ReturnType<NonNullable<CopyToClipboardDeps["spawn"]>>);
124
+
125
+ const result: CopyResult = { osc52: false, local: false };
126
+
127
+ const seq = osc52Sequence(text, { tmux: insideTmux });
128
+ if (seq) {
129
+ write(seq);
130
+ result.osc52 = true;
131
+ }
132
+
133
+ const cmd = systemClipboardCopyCommand(platform, which);
134
+ if (cmd) {
135
+ try {
136
+ const proc = spawn(cmd, { stdin: "pipe" });
137
+ proc.stdin.write(text);
138
+ await proc.stdin.end();
139
+ const code = await proc.exited;
140
+ if (code === 0) result.local = true;
141
+ } catch { /* local tool unavailable / failed — OSC 52 may still have worked */ }
142
+ }
143
+
144
+ return result;
145
+ }
@@ -29,6 +29,16 @@ export function clearScreen(): string {
29
29
  return `${ESC}2J${ESC}3J${ESC}H`;
30
30
  }
31
31
 
32
+ /** Erase the VISIBLE screen (2J) and home the cursor (H), but PRESERVE the scrollback
33
+ * buffer (no 3J). This is the readline/shell "Ctrl-L redraw" clear: the on-screen
34
+ * transcript is wiped and the prompt is repainted from the top, yet scrolling up still
35
+ * reveals prior output. Use it to RE-ANCHOR a prompt whose in-place footer drifted after
36
+ * the terminal scrolled — recovering a "typing does not show in the box" state without
37
+ * destroying history (unlike `clearScreen`, which also drops scrollback). */
38
+ export function clearVisible(): string {
39
+ return `${ESC}2J${ESC}H`;
40
+ }
41
+
32
42
  export function hideCursor(): string {
33
43
  return `${ESC}?25l`;
34
44
  }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * File ATTACHMENT support for the REPL input box.
3
+ *
4
+ * Terminals deliver a dragged-and-dropped file as *text* on stdin: the emulator
5
+ * inserts the file's path (often shell-quoted or with backslash-escaped spaces)
6
+ * at the caret, exactly as if the user typed it. So "attach a file by dropping it
7
+ * into the box" reduces to: recognise an image path inside the submitted text,
8
+ * read the bytes, and turn it into an {@link ImageAttachment} — replacing the raw
9
+ * path token with the same `[image #N]` tag the Ctrl+V clipboard path uses, so the
10
+ * model receives one consistent reference scheme regardless of how the image got
11
+ * attached.
12
+ *
13
+ * Only paths with a known image extension are considered, so ordinary prose is
14
+ * never mistaken for a file. Non-image / unreadable paths are left untouched.
15
+ */
16
+ import { readFile } from "node:fs/promises";
17
+ import type { ImageAttachment } from "../ai/types";
18
+
19
+ const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|bmp)$/i;
20
+
21
+ /** Detect an image media type from magic bytes, or null when the bytes are not a
22
+ * recognised image. Used as the authoritative check (extension only gates the
23
+ * candidate scan; the bytes decide). */
24
+ export function imageMediaTypeFromBytes(bytes: Uint8Array): string | null {
25
+ const b = bytes;
26
+ if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png";
27
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
28
+ if (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return "image/gif";
29
+ if (
30
+ b.length >= 12 &&
31
+ b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
32
+ b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50
33
+ ) return "image/webp";
34
+ if (b.length >= 2 && b[0] === 0x42 && b[1] === 0x4d) return "image/bmp";
35
+ return null;
36
+ }
37
+
38
+ /** Build an {@link ImageAttachment} from raw image bytes, or null when the bytes
39
+ * are not a recognised image format. */
40
+ export function attachmentFromImageBytes(bytes: Uint8Array): ImageAttachment | null {
41
+ const mediaType = imageMediaTypeFromBytes(bytes);
42
+ if (!mediaType) return null;
43
+ return { mediaType, data: Buffer.from(bytes).toString("base64") };
44
+ }
45
+
46
+
47
+ /**
48
+ * Decode one drag-and-drop path token into a usable filesystem path:
49
+ * - strips matching single/double quotes,
50
+ * - unescapes backslash-escaped characters (macOS escapes spaces as `\ `),
51
+ * - resolves a `file://` URI (with percent-decoding).
52
+ * Returns the cleaned path.
53
+ */
54
+ export function decodeDroppedPath(token: string): string {
55
+ let s = token.trim();
56
+ if ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"'))) {
57
+ return s.slice(1, -1);
58
+ }
59
+ if (s.startsWith("file://")) {
60
+ let rest = s.slice("file://".length);
61
+ // file://host/path — drop an (almost always empty / "localhost") authority.
62
+ if (rest.startsWith("/") === false && rest.includes("/")) rest = rest.slice(rest.indexOf("/"));
63
+ try { return decodeURIComponent(rest); } catch { return rest; }
64
+ }
65
+ // Bare token: unescape `\<char>` (shell-style drag escaping).
66
+ return s.replace(/\\(.)/g, "$1");
67
+ }
68
+
69
+ export interface PathToken {
70
+ /** The exact substring matched in the source text (used for replacement). */
71
+ raw: string;
72
+ /** The decoded filesystem path. */
73
+ path: string;
74
+ start: number;
75
+ end: number;
76
+ }
77
+
78
+ /**
79
+ * Scan `text` for image-file path tokens (quoted, `file://`, or bare with
80
+ * backslash-escaped spaces). Only tokens whose decoded path ends in a known image
81
+ * extension are returned, so normal prose never matches. Tokens are returned in
82
+ * source order with their `[start, end)` offsets for in-place replacement.
83
+ */
84
+ export function findImagePathTokens(text: string): PathToken[] {
85
+ const tokens: PathToken[] = [];
86
+ // Order matters: quoted forms first (so an inner space is kept), then file://
87
+ // URIs, then a bare run that allows backslash-escaped characters.
88
+ const re = /'[^']*'|"[^"]*"|file:\/\/\S+|(?:\\.|\S)+/g;
89
+ let m: RegExpExecArray | null;
90
+ while ((m = re.exec(text)) !== null) {
91
+ const raw = m[0];
92
+ const decoded = decodeDroppedPath(raw);
93
+ if (IMAGE_EXT_RE.test(decoded)) {
94
+ tokens.push({ raw, path: decoded, start: m.index, end: m.index + raw.length });
95
+ }
96
+ }
97
+ return tokens;
98
+ }
99
+
100
+ /** Reads a file's bytes, or null when it cannot be read. Injectable for tests. */
101
+ export type FileReader = (path: string) => Promise<Uint8Array | null>;
102
+
103
+ const defaultReader: FileReader = async (p) => {
104
+ try {
105
+ return new Uint8Array(await readFile(p));
106
+ } catch {
107
+ return null;
108
+ }
109
+ };
110
+
111
+ export interface AttachResult {
112
+ /** The input text with every successfully-attached image path replaced by its
113
+ * `[image #N]` tag; unmatched / unreadable paths are left verbatim. */
114
+ text: string;
115
+ /** The newly-read image attachments, in source order. */
116
+ images: ImageAttachment[];
117
+ }
118
+
119
+ /**
120
+ * Resolve image file paths dropped into `text` into attachments.
121
+ *
122
+ * @param text the submitted input line
123
+ * @param startNumber the next `[image #N]` number to assign (1-based; pass
124
+ * `existingImages.length + 1` so dropped files continue the
125
+ * numbering started by Ctrl+V clipboard images)
126
+ * @param read file reader (defaults to the real filesystem)
127
+ */
128
+ export async function attachImagePaths(
129
+ text: string,
130
+ startNumber = 1,
131
+ read: FileReader = defaultReader,
132
+ ): Promise<AttachResult> {
133
+ const tokens = findImagePathTokens(text);
134
+ if (tokens.length === 0) return { text, images: [] };
135
+
136
+ const reads = await Promise.all(
137
+ tokens.map(async (t) => {
138
+ const bytes = await read(t.path);
139
+ return bytes ? attachmentFromImageBytes(bytes) : null;
140
+ }),
141
+ );
142
+
143
+ const images: ImageAttachment[] = [];
144
+ let out = "";
145
+ let cursor = 0;
146
+ let n = startNumber;
147
+ for (let i = 0; i < tokens.length; i++) {
148
+ const att = reads[i];
149
+ if (!att) continue; // not a real image / unreadable → leave the text as-is
150
+ const t = tokens[i]!;
151
+ out += text.slice(cursor, t.start) + `[image #${n}]`;
152
+ cursor = t.end;
153
+ images.push(att);
154
+ n += 1;
155
+ }
156
+ out += text.slice(cursor);
157
+ return { text: out, images };
158
+ }