jeo-code 0.6.34 → 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 +17 -1
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/commands/launch/input.ts +27 -0
- package/src/commands/launch/slash-views.ts +3 -0
- package/src/commands/launch/tmux.ts +15 -0
- package/src/commands/launch.ts +101 -27
- package/src/tui/app.ts +21 -0
- package/src/tui/clipboard.ts +145 -0
- package/src/tui/terminal.ts +10 -0
- package/src/util/file-attachment.ts +158 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,22 @@ 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.
|
|
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
|
+
|
|
10
25
|
## [0.6.34] - 2026-06-20
|
|
11
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._
|
|
12
27
|
|
|
@@ -23,6 +38,7 @@ _Per-session model memory — each saved session now remembers the model it was
|
|
|
23
38
|
- **`jeo --tmux` live.** `tmux-verify.sh smoke` OK + `battery` **6/6 PASSED** (boot, `/help`, unknown `$skill`, `/agents`, `$ultragoal`, unresolved `/command`).
|
|
24
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`).
|
|
25
40
|
|
|
41
|
+
## [0.6.33] - 2026-06-19
|
|
26
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._
|
|
27
43
|
|
|
28
44
|
### 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.
|
|
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
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.
|
|
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.
|
|
205
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.
|
|
206
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.
|
|
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.
|
|
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
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.
|
|
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.
|
|
205
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.
|
|
206
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.
|
|
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.
|
|
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
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.
|
|
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.
|
|
205
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.
|
|
206
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.
|
|
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.
|
|
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
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.
|
|
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.
|
|
205
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.
|
|
206
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.
|
|
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
|
@@ -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;
|
|
@@ -155,6 +156,7 @@ 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[] = [];
|
|
@@ -191,7 +193,20 @@ 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
|
+
}
|
|
194
208
|
}
|
|
209
|
+
|
|
195
210
|
return commands;
|
|
196
211
|
}
|
|
197
212
|
|
package/src/commands/launch.ts
CHANGED
|
@@ -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,
|
|
@@ -138,6 +140,7 @@ import {
|
|
|
138
140
|
restoreQueuedLinesToPrefill,
|
|
139
141
|
createInFlightAbortHarness,
|
|
140
142
|
classifyMidTurnLine,
|
|
143
|
+
decideCtrlC,
|
|
141
144
|
} from "./launch/input";
|
|
142
145
|
import {
|
|
143
146
|
gatedStdout,
|
|
@@ -1445,6 +1448,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1445
1448
|
out += data.slice(i, i + 3); i += 3; continue;
|
|
1446
1449
|
}
|
|
1447
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; }
|
|
1448
1455
|
out += data[i]; i += 1;
|
|
1449
1456
|
}
|
|
1450
1457
|
kf.write(out);
|
|
@@ -2008,13 +2015,48 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2008
2015
|
out.write(s);
|
|
2009
2016
|
};
|
|
2010
2017
|
|
|
2011
|
-
//
|
|
2012
|
-
//
|
|
2013
|
-
//
|
|
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
|
+
};
|
|
2014
2056
|
const clearTypedInput = (): boolean => {
|
|
2015
2057
|
const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
|
|
2016
2058
|
const hadPastedQueue = queuedPromptInput.pastedLines.length > 0;
|
|
2017
|
-
if ((
|
|
2059
|
+
if (!promptHasContent()) return false;
|
|
2018
2060
|
// ESC is the escape hatch for an accidental giant paste: drop the queued batch.
|
|
2019
2061
|
if (hadPastedQueue) {
|
|
2020
2062
|
const dropped = queuedPromptInput.pastedLines.splice(0).length;
|
|
@@ -2031,9 +2073,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2031
2073
|
if (previewArmed) drawFooter(previewLines(""));
|
|
2032
2074
|
return true;
|
|
2033
2075
|
};
|
|
2034
|
-
// Ctrl+C
|
|
2076
|
+
// A Ctrl+C on an EMPTY prompt is a hard terminal break (exit code 130), not a line
|
|
2035
2077
|
// editor shortcut. `/exit` remains the graceful session-save path; ^C is the
|
|
2036
|
-
// emergency "get me back to my shell now" path
|
|
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.
|
|
2037
2080
|
const forceExitFromCtrlC = () => {
|
|
2038
2081
|
try {
|
|
2039
2082
|
disarmPreview();
|
|
@@ -2044,15 +2087,28 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2044
2087
|
}
|
|
2045
2088
|
process.exit(130);
|
|
2046
2089
|
};
|
|
2047
|
-
|
|
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) => {
|
|
2048
2103
|
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
2049
|
-
if (text.includes("\u0003"))
|
|
2104
|
+
if (text.includes("\u0003")) handleCtrlC();
|
|
2050
2105
|
};
|
|
2051
2106
|
// Bun/readline can deliver Ctrl+C as readline SIGINT, process SIGINT, or (tmux)
|
|
2052
|
-
// a raw \u0003 byte before readline resolves the question;
|
|
2053
|
-
|
|
2054
|
-
process.
|
|
2055
|
-
|
|
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);
|
|
2056
2112
|
|
|
2057
2113
|
const runSelectPicker = async <T>(
|
|
2058
2114
|
render: (cols: number, rows: number) => string[],
|
|
@@ -2519,10 +2575,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2519
2575
|
process.once("exit", () => out.write("\x1b[?25h")); // safety net: never leave the cursor hidden
|
|
2520
2576
|
const footerKeypressHandler = (_ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean } | undefined) => {
|
|
2521
2577
|
if (key?.ctrl && key.name === "c") {
|
|
2522
|
-
|
|
2578
|
+
handleCtrlC();
|
|
2523
2579
|
return;
|
|
2524
2580
|
}
|
|
2525
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
|
+
}
|
|
2526
2588
|
// Ctrl+O: toggle a reversible, scrollable detail panel inside the footer
|
|
2527
2589
|
// (expand on the first press, FOLD on the next). ↑↓/PgUp/PgDn scroll it while
|
|
2528
2590
|
// open — long/CJK content is fully reachable, nothing is clipped. The live-turn
|
|
@@ -2592,7 +2654,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2592
2654
|
clearTypedInput();
|
|
2593
2655
|
return;
|
|
2594
2656
|
}
|
|
2595
|
-
// Ctrl+C
|
|
2657
|
+
// Ctrl+C is handled above (clear-or-exit); keep this guard for defensive ordering only.
|
|
2596
2658
|
if (key?.ctrl && key.name === "c") return;
|
|
2597
2659
|
previewPending = true;
|
|
2598
2660
|
setImmediate(() => {
|
|
@@ -2840,6 +2902,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2840
2902
|
pendingSelection = undefined;
|
|
2841
2903
|
navMatches = [];
|
|
2842
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
|
+
}
|
|
2843
2917
|
if (input === "/exit" || input === "/quit") break;
|
|
2844
2918
|
if (input === "") {
|
|
2845
2919
|
if (pendingImages.length === 0) continue;
|
|
@@ -3180,16 +3254,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3180
3254
|
}
|
|
3181
3255
|
try {
|
|
3182
3256
|
const text = await exportSession(sessionId, "markdown", cwd);
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
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)`);
|
|
3190
3264
|
} else {
|
|
3191
3265
|
console.log(text);
|
|
3192
|
-
console.log("(no clipboard
|
|
3266
|
+
console.log("(no clipboard path available — transcript printed above)");
|
|
3193
3267
|
}
|
|
3194
3268
|
} catch (err) {
|
|
3195
3269
|
console.log(`! dump failed: ${(err as Error).message}`);
|
|
@@ -4270,8 +4344,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4270
4344
|
disarmPreview();
|
|
4271
4345
|
out.write("\x1b[?25h\n");
|
|
4272
4346
|
} catch { /* best effort */ }
|
|
4273
|
-
process.removeListener("SIGINT",
|
|
4274
|
-
process.stdin.off("data",
|
|
4347
|
+
process.removeListener("SIGINT", handleCtrlC);
|
|
4348
|
+
process.stdin.off("data", handleCtrlCByte);
|
|
4275
4349
|
drainPromptListeners();
|
|
4276
4350
|
restorePromptRawMode();
|
|
4277
4351
|
process.exit(130);
|
|
@@ -4286,8 +4360,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4286
4360
|
// gjc-parity resume pointer (logs/gjc-tui-study analysis Gap C): leave the exact
|
|
4287
4361
|
// resume command in scrollback on exit, mirroring the --list handler's convention.
|
|
4288
4362
|
if (sessionId && !flags.noSession) console.log(formatResumeHint(sessionId));
|
|
4289
|
-
process.removeListener("SIGINT",
|
|
4290
|
-
process.stdin.off("data",
|
|
4363
|
+
process.removeListener("SIGINT", handleCtrlC);
|
|
4364
|
+
process.stdin.off("data", handleCtrlCByte);
|
|
4291
4365
|
drainPromptListeners();
|
|
4292
4366
|
restorePromptRawMode();
|
|
4293
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
|
+
}
|
package/src/tui/terminal.ts
CHANGED
|
@@ -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
|
+
}
|