jeo-code 0.6.34 → 0.6.36

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,7 +6,35 @@ 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.33] - 2026-06-19
9
+ ## [0.6.36] - 2026-06-20
10
+ _When `jeo --tmux` flips the mouse on so you can drag-select, the drag now actually lands on the system clipboard — the in-session tmux profile sets `set-clipboard on` + a local `copy-command` on the CURRENT session only — plus `/help` documents the drag-to-copy and the Shift/Option-drag escape hatch, re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot._
11
+
12
+ ### Fixed
13
+ - **A `--tmux` mouse drag-select now reaches the system clipboard instead of vanishing.** Turning the mouse on (so on-screen text can be selected) re-routes a drag into tmux copy-mode, where the selection used to die inside tmux's own buffer — `cmd/ctrl+v` got nothing. The launch path now applies the same clipboard repair to the CURRENT session that `tmuxProfileCommands` applies to jeo-owned sessions: new `currentTmuxClipboardCommands(env, deps)` emits `set-option set-clipboard on` (lets the copy-mode selection escape via OSC 52) and, when a local clipboard tool is on PATH, `set-option copy-command <tool>` (pipes the drag-select straight to `pbcopy` / `wl-copy` / `xclip` / `xsel` / `clip` for terminals that don't honor OSC 52). Both are written **session-locally — never `-t` or `-g`** — so the user's other tmux sessions are untouched; `JEO_TMUX_PROFILE=0` opts out, and `copy-command` is skipped when no tool is found.
14
+
15
+ ### Changed
16
+ - **`/help` documents the drag-to-copy behavior and its escape hatch.** Two hotkey rows now explain that a drag selects on-screen text (copies on `cmd/ctrl+c`, and auto-copies to the system clipboard under `--tmux`) and that **Shift-drag** (iTerm/macOS: **Option-drag**) forces the terminal's own selection when tmux owns the mouse.
17
+
18
+ ### Verified
19
+ - `scripts/mem-probe.ts` (2000 LaunchTui turns) shows a flat post-GC heap — per-turn slope **−541 bytes/turn**, returning to its settled floor — with zero `process` SIGINT/listener accumulation; `scripts/tmux-verify.sh smoke` boots `jeo --tmux` to a clean input box + model bar (EXIT 0). `bun run typecheck` clean and `bun test` **1748 pass / 0 fail** across 215 files, including the new `currentTmuxClipboardCommands` session-local / no-tool / opt-out cases in `test/tmux.test.ts`.
20
+
21
+
22
+ ## [0.6.35] - 2026-06-20
23
+ _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._
24
+
25
+ ### Added
26
+ - **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.
27
+ - **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.
28
+ - **`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.
29
+
30
+ ### Changed
31
+ - **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.
32
+ - **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).
33
+
34
+ ### Verified
35
+ - `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.
36
+
37
+
10
38
  ## [0.6.34] - 2026-06-20
11
39
  _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._
12
40
 
@@ -23,6 +51,7 @@ _Per-session model memory — each saved session now remembers the model it was
23
51
  - **`jeo --tmux` live.** `tmux-verify.sh smoke` OK + `battery` **6/6 PASSED** (boot, `/help`, unknown `$skill`, `/agents`, `$ultragoal`, unresolved `/command`).
24
52
  - **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`).
25
53
 
54
+ ## [0.6.33] - 2026-06-19
26
55
  _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._
27
56
 
28
57
  ### Changed
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.33]** (2026-06-19)
203
+ - **[0.6.36]** (2026-06-20) — When `jeo --tmux` flips the mouse on so you can drag-select, the drag now actually lands on the system clipboard — the in-session tmux profile sets `set-clipboard on` + a local `copy-command` on the CURRENT session only — plus `/help` documents the drag-to-copy and the Shift/Option-drag escape hatch, re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
204
+ - **[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
205
  - **[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.
206
+ - **[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.
205
207
  - **[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.
206
- - **[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.
207
- - **[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.
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.33]** (2026-06-19)
203
+ - **[0.6.36]** (2026-06-20) — When `jeo --tmux` flips the mouse on so you can drag-select, the drag now actually lands on the system clipboard — the in-session tmux profile sets `set-clipboard on` + a local `copy-command` on the CURRENT session only — plus `/help` documents the drag-to-copy and the Shift/Option-drag escape hatch, re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
204
+ - **[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
205
  - **[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.
206
+ - **[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.
205
207
  - **[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.
206
- - **[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.
207
- - **[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.
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.33]** (2026-06-19)
203
+ - **[0.6.36]** (2026-06-20) — When `jeo --tmux` flips the mouse on so you can drag-select, the drag now actually lands on the system clipboard — the in-session tmux profile sets `set-clipboard on` + a local `copy-command` on the CURRENT session only — plus `/help` documents the drag-to-copy and the Shift/Option-drag escape hatch, re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
204
+ - **[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
205
  - **[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.
206
+ - **[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.
205
207
  - **[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.
206
- - **[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.
207
- - **[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.
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.33]** (2026-06-19)
203
+ - **[0.6.36]** (2026-06-20) — When `jeo --tmux` flips the mouse on so you can drag-select, the drag now actually lands on the system clipboard — the in-session tmux profile sets `set-clipboard on` + a local `copy-command` on the CURRENT session only — plus `/help` documents the drag-to-copy and the Shift/Option-drag escape hatch, re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
204
+ - **[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
205
  - **[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.
206
+ - **[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.
205
207
  - **[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.
206
- - **[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.
207
- - **[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.
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.34",
3
+ "version": "0.6.36",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -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,16 @@ 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])",
26
+ " drag select on-screen text to copy — copies on cmd/ctrl+c; under --tmux a drag auto-copies to the system clipboard",
27
+ " Shift-drag force the terminal's own selection when tmux owns the mouse (iTerm/macOS: Option-drag)",
23
28
  ];
24
29
  }
25
30
 
@@ -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;
@@ -105,8 +106,8 @@ export function shellQuote(arg: string): string {
105
106
  */
106
107
  export function shouldEnableCurrentTmuxMouse(env: Record<string, string | undefined>): boolean {
107
108
  return !!env.TMUX
108
- && (env.JEO_TMUX_LAUNCHED ?? env.JEO_TMUX_LAUNCHED) !== "1"
109
- && (env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0";
109
+ && env.JEO_TMUX_LAUNCHED !== "1"
110
+ && env.JEO_TMUX_MOUSE !== "0";
110
111
  }
111
112
 
112
113
  /**
@@ -155,10 +156,11 @@ export function tmuxProfileCommands(
155
156
  target: string,
156
157
  env: Record<string, string | undefined>,
157
158
  meta: { branch?: string; project?: string } = {},
159
+ deps: { platform?: NodeJS.Platform; which?: (bin: string) => string | null } = {},
158
160
  ): TmuxProfileCommand[] {
159
161
  const t = `=${target}:`;
160
162
  const commands: TmuxProfileCommand[] = [];
161
- if ((env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0") {
163
+ if (env.JEO_TMUX_MOUSE !== "0") {
162
164
  commands.push({
163
165
  description: "enable tmux mouse scrolling (wheel-up → copy-mode over real history)",
164
166
  args: ["set-option", "-t", t, "mouse", "on"],
@@ -180,7 +182,7 @@ export function tmuxProfileCommands(
180
182
  args: ["set-option", "-t", t, "@jeo-project", meta.project],
181
183
  });
182
184
  }
183
- if ((env.JEO_TMUX_PROFILE ?? env.JEO_TMUX_PROFILE) !== "0") {
185
+ if (env.JEO_TMUX_PROFILE !== "0") {
184
186
  commands.push(
185
187
  {
186
188
  description: "enable tmux clipboard integration",
@@ -191,6 +193,53 @@ export function tmuxProfileCommands(
191
193
  args: ["set-window-option", "-t", t, "mode-style", "fg=colour231,bg=colour60"],
192
194
  },
193
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
+ }
208
+ }
209
+
210
+ return commands;
211
+ }
212
+
213
+ /**
214
+ * Clipboard set-options for the CURRENT (foreign) tmux session that the in-session
215
+ * `jeo --tmux` path turns `mouse on` for. Enabling the mouse re-routes a plain
216
+ * drag into copy-mode, so without these a drag-select no longer lands anywhere:
217
+ * - `set-clipboard on` lets the copy-mode selection reach the outer terminal via OSC52;
218
+ * - `copy-command` pipes that selection straight to the local clipboard tool
219
+ * (pbcopy / wl-copy / xclip / xsel / clip), so a drag-select copies for `cmd+v`
220
+ * even where OSC52 is not honored.
221
+ * Applied WITHOUT `-t` (the current session only — never -g, so the user's other
222
+ * sessions are untouched). `JEO_TMUX_PROFILE=0` opts out; `copy-command` is skipped
223
+ * when no clipboard tool is on PATH. This is the in-session analogue of the
224
+ * clipboard block in {@link tmuxProfileCommands} for jeo-owned sessions.
225
+ */
226
+ export function currentTmuxClipboardCommands(
227
+ env: Record<string, string | undefined>,
228
+ deps: { platform?: NodeJS.Platform; which?: (bin: string) => string | null } = {},
229
+ ): TmuxProfileCommand[] {
230
+ if (env.JEO_TMUX_PROFILE === "0") return [];
231
+ const commands: TmuxProfileCommand[] = [
232
+ {
233
+ description: "enable tmux clipboard integration",
234
+ args: ["set-option", "set-clipboard", "on"],
235
+ },
236
+ ];
237
+ const copyCmd = tmuxCopyCommand(deps.platform ?? process.platform, deps.which ?? ((bin: string) => Bun.which(bin)));
238
+ if (copyCmd) {
239
+ commands.push({
240
+ description: "pipe copy-mode selection to the system clipboard",
241
+ args: ["set-option", "copy-command", copyCmd],
242
+ });
194
243
  }
195
244
  return commands;
196
245
  }
@@ -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";
@@ -96,7 +98,7 @@ import {
96
98
  sessionPath,
97
99
  appendCompaction,
98
100
  } from "../agent/session";
99
- 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";
100
102
 
101
103
  import {
102
104
  type LaunchFlags,
@@ -116,6 +118,7 @@ import {
116
118
  shouldEnableCurrentTmuxMouse,
117
119
  tmuxLaunchCommand,
118
120
  tmuxProfileCommands,
121
+ currentTmuxClipboardCommands,
119
122
  resolveWorktree,
120
123
  shellQuote,
121
124
  type TmuxCreateResult,
@@ -138,6 +141,7 @@ import {
138
141
  restoreQueuedLinesToPrefill,
139
142
  createInFlightAbortHarness,
140
143
  classifyMidTurnLine,
144
+ decideCtrlC,
141
145
  } from "./launch/input";
142
146
  import {
143
147
  gatedStdout,
@@ -183,6 +187,7 @@ export {
183
187
  shouldEnableCurrentTmuxMouse,
184
188
  tmuxLaunchCommand,
185
189
  tmuxProfileCommands,
190
+ currentTmuxClipboardCommands,
186
191
  resolveWorktree,
187
192
  shellQuote,
188
193
  type TmuxCreateResult,
@@ -417,6 +422,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
417
422
  const tmuxBin = Bun.which("tmux");
418
423
  if (tmuxBin) {
419
424
  try { Bun.spawnSync([tmuxBin, "set-option", "mouse", "on"]); } catch { /* best-effort */ }
425
+ // Enabling the mouse re-routes a drag into copy-mode; set-clipboard +
426
+ // copy-command make that drag-select actually land on the system clipboard.
427
+ for (const c of currentTmuxClipboardCommands(process.env)) {
428
+ try { Bun.spawnSync([tmuxBin, ...c.args]); } catch { /* best-effort */ }
429
+ }
420
430
  }
421
431
  }
422
432
  }
@@ -1445,6 +1455,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1445
1455
  out += data.slice(i, i + 3); i += 3; continue;
1446
1456
  }
1447
1457
  if (loneLfShiftEnter && data[i] === "\n") { out += SENTINEL; i += 1; continue; } // lone LF = Shift+Enter (opt-in)
1458
+ // Ctrl+L (form feed): consumed as the prompt "redraw / re-anchor" hotkey (handled on
1459
+ // the process.stdin 'keypress' listener), so it must never reach readline as a literal
1460
+ // char that would otherwise insert garbage / desync the box from rl.line.
1461
+ if (data[i] === "\u000c") { i += 1; continue; }
1448
1462
  out += data[i]; i += 1;
1449
1463
  }
1450
1464
  kf.write(out);
@@ -2008,13 +2022,48 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2008
2022
  out.write(s);
2009
2023
  };
2010
2024
 
2011
- // ESC at the prompt: wipe the typed text (and detach any pending clipboard
2012
- // images their `[image #N]` tags live in that text) instead of leaving stale
2013
- // input. Ctrl+C is no longer a line editor shortcut; it hard-exits below.
2025
+ // Ctrl-L "redraw / re-anchor" recovery. The boxed footer paints IN PLACE relative to
2026
+ // the last parked cursor row; if the terminal scrolled for any reason the app did not
2027
+ // drive (a stray async stdout write, a tmux pane reflow, a wheel-scroll past the margin),
2028
+ // that anchor goes stale and subsequent repaints land on the wrong rows — the box looks
2029
+ // frozen and typed text never appears, even though readline IS still capturing every key
2030
+ // ("화면이 밀려서 입력이 안 보이는" 상태). This wipes the VISIBLE screen (scrollback kept),
2031
+ // re-reserves the footer at the top, and repaints from readline's live buffer, so the box
2032
+ // and caret snap back and whatever was already typed shows immediately.
2033
+ const redrawPromptFooter = () => {
2034
+ if (!previewArmed) return;
2035
+ try {
2036
+ const rows = process.stdout.rows ?? 24;
2037
+ footerRendered = 0;
2038
+ footerParkedRow = 0;
2039
+ lastFooterKey = "";
2040
+ lastDrawnLines = [];
2041
+ out.write(clearVisible());
2042
+ footerRows = previewRowsFor(rows);
2043
+ const initial = Math.max(1, Math.min(footerRows, COMPACT_FOOTER_ROWS));
2044
+ if (initial > 1) out.write("\n".repeat(initial - 1) + cursorUp(initial - 1));
2045
+ out.write(toColumn(1));
2046
+ footerRendered = initial;
2047
+ footerWantRows = initial;
2048
+ drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
2049
+ } catch { /* ignore redraw races */ }
2050
+ };
2051
+
2052
+ // ESC — and a FIRST Ctrl+C while the box is non-empty — wipe the typed text (and
2053
+ // detach any pending clipboard images, whose `[image #N]` tags live in that text)
2054
+ // instead of leaving stale input. A Ctrl+C on the already-empty box hard-exits
2055
+ // (see handleCtrlC below).
2056
+ /** True when the prompt box holds anything a clear/Ctrl+C would discard: typed
2057
+ * text, a pending clipboard image, or a queued pasted batch. Single source of the
2058
+ * "is there input?" predicate shared by clearTypedInput and the Ctrl+C decision. */
2059
+ const promptHasContent = (): boolean => {
2060
+ const line = (rl as unknown as { line?: string }).line;
2061
+ return (line?.length ?? 0) > 0 || pendingImages.length > 0 || queuedPromptInput.pastedLines.length > 0;
2062
+ };
2014
2063
  const clearTypedInput = (): boolean => {
2015
2064
  const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
2016
2065
  const hadPastedQueue = queuedPromptInput.pastedLines.length > 0;
2017
- if ((rli.line?.length ?? 0) === 0 && pendingImages.length === 0 && !hadPastedQueue) return false;
2066
+ if (!promptHasContent()) return false;
2018
2067
  // ESC is the escape hatch for an accidental giant paste: drop the queued batch.
2019
2068
  if (hadPastedQueue) {
2020
2069
  const dropped = queuedPromptInput.pastedLines.splice(0).length;
@@ -2031,9 +2080,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2031
2080
  if (previewArmed) drawFooter(previewLines(""));
2032
2081
  return true;
2033
2082
  };
2034
- // Ctrl+C at the prompt is a hard terminal break (exit code 130), not a line
2083
+ // A Ctrl+C on an EMPTY prompt is a hard terminal break (exit code 130), not a line
2035
2084
  // editor shortcut. `/exit` remains the graceful session-save path; ^C is the
2036
- // emergency "get me back to my shell now" path and must work on the first press.
2085
+ // emergency "get me back to my shell now" path. A first Ctrl+C with text in the box
2086
+ // CLEARS it instead (handleCtrlC); the next Ctrl+C on the now-empty box exits.
2037
2087
  const forceExitFromCtrlC = () => {
2038
2088
  try {
2039
2089
  disarmPreview();
@@ -2044,15 +2094,28 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2044
2094
  }
2045
2095
  process.exit(130);
2046
2096
  };
2047
- const forceExitOnCtrlCByte = (chunk: string | Uint8Array) => {
2097
+ // Prompt Ctrl+C: a single press clears a non-empty box; on an empty box it exits.
2098
+ // `lastCtrlCAt` collapses the duplicate deliveries of one physical press (keypress
2099
+ // + SIGINT + raw byte) so a single Ctrl+C can't clear AND then immediately exit.
2100
+ let lastCtrlCAt = 0;
2101
+ const handleCtrlC = () => {
2102
+ const now = Date.now();
2103
+ const action = decideCtrlC(promptHasContent(), now - lastCtrlCAt);
2104
+ if (action === "ignore") return; // duplicate delivery of the same press
2105
+ lastCtrlCAt = now;
2106
+ if (action === "clear") clearTypedInput(); // had input → wipe it, stay at the prompt
2107
+ else forceExitFromCtrlC(); // empty → hard exit (130)
2108
+ };
2109
+ const handleCtrlCByte = (chunk: string | Uint8Array) => {
2048
2110
  const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
2049
- if (text.includes("\u0003")) forceExitFromCtrlC();
2111
+ if (text.includes("\u0003")) handleCtrlC();
2050
2112
  };
2051
2113
  // Bun/readline can deliver Ctrl+C as readline SIGINT, process SIGINT, or (tmux)
2052
- // a raw \u0003 byte before readline resolves the question; wire all three.
2053
- process.on("SIGINT", forceExitFromCtrlC);
2054
- process.stdin.on("data", forceExitOnCtrlCByte);
2055
- rl.on("SIGINT", forceExitFromCtrlC);
2114
+ // a raw \u0003 byte before readline resolves the question; funnel all three through
2115
+ // handleCtrlC (clear-or-exit + de-duplication) so the behavior is identical.
2116
+ process.on("SIGINT", handleCtrlC);
2117
+ process.stdin.on("data", handleCtrlCByte);
2118
+ rl.on("SIGINT", handleCtrlC);
2056
2119
 
2057
2120
  const runSelectPicker = async <T>(
2058
2121
  render: (cols: number, rows: number) => string[],
@@ -2519,10 +2582,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2519
2582
  process.once("exit", () => out.write("\x1b[?25h")); // safety net: never leave the cursor hidden
2520
2583
  const footerKeypressHandler = (_ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean } | undefined) => {
2521
2584
  if (key?.ctrl && key.name === "c") {
2522
- forceExitFromCtrlC();
2585
+ handleCtrlC();
2523
2586
  return;
2524
2587
  }
2525
2588
  if (!previewArmed || pickerActive) return;
2589
+ // Ctrl+L: redraw / re-anchor the prompt. The recovery for a footer whose in-place
2590
+ // anchor drifted after the screen scrolled (typed text stops showing in the box).
2591
+ if (key?.ctrl && key.name === "l") {
2592
+ redrawPromptFooter();
2593
+ return;
2594
+ }
2526
2595
  // Ctrl+O: toggle a reversible, scrollable detail panel inside the footer
2527
2596
  // (expand on the first press, FOLD on the next). ↑↓/PgUp/PgDn scroll it while
2528
2597
  // open — long/CJK content is fully reachable, nothing is clipped. The live-turn
@@ -2592,7 +2661,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2592
2661
  clearTypedInput();
2593
2662
  return;
2594
2663
  }
2595
- // Ctrl+C hard-exits above; keep this guard for defensive ordering only.
2664
+ // Ctrl+C is handled above (clear-or-exit); keep this guard for defensive ordering only.
2596
2665
  if (key?.ctrl && key.name === "c") return;
2597
2666
  previewPending = true;
2598
2667
  setImmediate(() => {
@@ -2840,6 +2909,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2840
2909
  pendingSelection = undefined;
2841
2910
  navMatches = [];
2842
2911
  navIdx = -1;
2912
+ // File attachment: a dragged-and-dropped image file arrives as its path in
2913
+ // the typed text. Read any image path(s), attach them, and swap the path for
2914
+ // an [image #N] tag (continuing the clipboard-image numbering) — the same
2915
+ // scheme Ctrl+V uses. Skipped for slash commands and `!` shell escapes.
2916
+ if (input && !input.startsWith("/") && !input.startsWith("!")) {
2917
+ const dropped = await attachImagePaths(input, pendingImages.length + 1);
2918
+ if (dropped.images.length > 0) {
2919
+ pendingImages.push(...dropped.images);
2920
+ input = dropped.text;
2921
+ console.log(chalk.dim(`(attached ${dropped.images.length} image file${dropped.images.length > 1 ? "s" : ""} from path)`));
2922
+ }
2923
+ }
2843
2924
  if (input === "/exit" || input === "/quit") break;
2844
2925
  if (input === "") {
2845
2926
  if (pendingImages.length === 0) continue;
@@ -3180,16 +3261,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3180
3261
  }
3181
3262
  try {
3182
3263
  const text = await exportSession(sessionId, "markdown", cwd);
3183
- const clip = process.platform === "darwin" ? "pbcopy" : Bun.which("wl-copy") ? "wl-copy" : Bun.which("xclip") ? "xclip" : "";
3184
- if (clip && Bun.which(clip)) {
3185
- const proc = Bun.spawn(clip === "xclip" ? [clip, "-selection", "clipboard"] : [clip], { stdin: "pipe" });
3186
- proc.stdin.write(text);
3187
- await proc.stdin.end();
3188
- await proc.exited;
3189
- console.log(`(transcript copied to clipboard — ${text.length} chars)`);
3264
+ // Copy via BOTH OSC 52 (reaches the outer terminal over SSH/tmux) and a
3265
+ // local clipboard tool (pbcopy / wl-copy / xclip / xsel / clip) — the union
3266
+ // makes `cmd+v` find the transcript regardless of where the terminal runs.
3267
+ const copied = await copyTextToClipboard(text);
3268
+ if (copied.osc52 || copied.local) {
3269
+ const via = [copied.local ? "system tool" : "", copied.osc52 ? "OSC52" : ""].filter(Boolean).join(" + ");
3270
+ console.log(`(transcript copied to clipboard via ${via} — ${text.length} chars)`);
3190
3271
  } else {
3191
3272
  console.log(text);
3192
- console.log("(no clipboard tool found — transcript printed above)");
3273
+ console.log("(no clipboard path available — transcript printed above)");
3193
3274
  }
3194
3275
  } catch (err) {
3195
3276
  console.log(`! dump failed: ${(err as Error).message}`);
@@ -4270,8 +4351,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4270
4351
  disarmPreview();
4271
4352
  out.write("\x1b[?25h\n");
4272
4353
  } catch { /* best effort */ }
4273
- process.removeListener("SIGINT", forceExitFromCtrlC);
4274
- process.stdin.off("data", forceExitOnCtrlCByte);
4354
+ process.removeListener("SIGINT", handleCtrlC);
4355
+ process.stdin.off("data", handleCtrlCByte);
4275
4356
  drainPromptListeners();
4276
4357
  restorePromptRawMode();
4277
4358
  process.exit(130);
@@ -4286,8 +4367,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4286
4367
  // gjc-parity resume pointer (logs/gjc-tui-study analysis Gap C): leave the exact
4287
4368
  // resume command in scrollback on exit, mirroring the --list handler's convention.
4288
4369
  if (sessionId && !flags.noSession) console.log(formatResumeHint(sessionId));
4289
- process.removeListener("SIGINT", forceExitFromCtrlC);
4290
- process.stdin.off("data", forceExitOnCtrlCByte);
4370
+ process.removeListener("SIGINT", handleCtrlC);
4371
+ process.stdin.off("data", handleCtrlCByte);
4291
4372
  drainPromptListeners();
4292
4373
  restorePromptRawMode();
4293
4374
  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
+ }