jeo-code 0.7.25 → 0.7.27
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 +33 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +2 -1
- package/src/agent/compaction.ts +24 -0
- package/src/agent/engine.ts +11 -3
- package/src/agent/task-tool.ts +2 -2
- package/src/ai/model-manager.ts +28 -2
- package/src/ai/providers/anthropic.ts +6 -1
- package/src/commands/launch/input.ts +262 -9
- package/src/commands/launch/slash-views.ts +5 -2
- package/src/commands/launch.ts +80 -39
- package/src/tui/clipboard.ts +49 -0
- package/src/tui/terminal.ts +5 -3
- package/src/util/provider-error.ts +4 -12
- package/src/util/retry.ts +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,39 @@ 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.7.27] - 2026-07-02
|
|
10
|
+
_Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks._
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Esc and Ctrl+C went DEAD after 0.7.25's keyboard-protocol push — the reported "입력후 esc 키 (또 ctrl+c) 입력이 안되는 문제".** Root cause: `CSI >1u` (kitty disambiguate) makes the terminal re-encode Esc as `CSI 27u` and Ctrl+C as `CSI 99;5u` (xterm modifyOtherKeys=2: `CSI 27;5;99~`) — forms nothing in jeo decoded: readline saw an unknown sequence (key `name: undefined`), the raw-`\u0003` scans never matched, and the mid-turn harness classified both as "noise". New decode layer in `src/commands/launch/input.ts` — `matchExtendedKey`/`decodeExtendedKeys` (kitty CSI-u + xterm modifyOtherKeys → legacy bytes), `chunkHasCtrlC` (all three Ctrl+C encodings), `normalizeKeypress` (CSI-u keypress events → the `{name, ctrl, meta}` shape handlers match; includes the Bun quirk where an unknown CSI emits the literal STRING `"undefined"` as `key.name`) — wired into the prompt key filter, the footer/picker keypress handlers, the raw Ctrl+C byte scan, the pre-prompt typed-input drain, and the mid-turn ESC/Ctrl+C abort harness. Key-release reports (`:3`) and unknown functional codes are swallowed instead of leaking `[57376u`-style garbage into the draft. Cmd/Super+letter no longer types the bare letter.
|
|
14
|
+
- **Copy/paste reliability ("복사붙여넣기가 잘 동작안하는 경우"):** (1) Ctrl+V arrived as `CSI 118;5u` under the kitty protocol — binding dead; now normalized. (2) Ctrl+V with a TEXT clipboard was a silent no-op (binding was image-only); it now falls back to `readClipboardText()` (`pbpaste` / PowerShell `Get-Clipboard -Raw` / `wl-paste`/`xclip`/`xsel`) and inserts at the caret with paste-grade sanitation (ANSI stripped, line breaks → box sentinel — a multi-line clipboard can never self-submit). (3) A trailing `\r\n` outside a paste (Windows CRLF tails) forwarded BOTH bytes — the `\r` submitted and the `\n` leaked into the next prompt as a phantom break/submit; now folds to ONE Enter, including when the CRLF is split across stdin chunk reads (`crTail` filter state). (4) `disableKittyKeyboard()` now POPS the kitty keyboard-mode stack (`CSI <u`) instead of pushing a zero-flags entry that leaked onto the outer shell.
|
|
15
|
+
- **Shift+Enter coverage gaps.** `SHIFT_ENTER_SEQS` covered only 2 encodings (`CSI 27;2;13~`, `CSI 13;2u`); now every modified-Enter form breaks the line: kitty mods 2–6 (Shift/Alt/Ctrl combinations), the xterm modifyOtherKeys equivalents, and legacy alt-as-meta `ESC \r` (macOS Option+Enter, Windows Terminal Alt+Enter).
|
|
16
|
+
- **Adaptive-thinking turns died with "output budget exhausted" because `max_tokens` was the THINKING-LEVEL table (4k–32k), not the model's real output capacity.** On Anthropic adaptive models (Opus 4.6+, Sonnet/Fable/Mythos 5) thinking tokens are spent INSIDE `max_tokens`, so a deep-thinking step competed with its own visible reply inside a 16k–32k budget and could stop at `stop_reason=max_tokens` with zero content. New `resolveMaxOutputTokens(model, level)` (`src/ai/model-manager.ts`) resolves the CATALOG max-output (fable-5: 128k) capped by `JEO_MAX_OUTPUT_TOKENS` (default 64k); the thinking level keeps steering DEPTH via `reasoningEffort`/`output_config` only. Wired through `resolveCall`, both `launch.ts` engine call sites, and `task-tool.ts` subagent spawns.
|
|
17
|
+
- **Safety refusals were retried as "transient provider error" at the transport layer, burning 2 extra billed calls per recovery-ladder rung (observed: 392k input tokens for one refused turn).** A refusal is deterministic for identical conversation content. `isRefusalError` moved to `src/util/retry.ts` (re-exported from `provider-error.ts`) and `defaultRetryable` now fails fast on `stop_reason=refusal` / `finish_reason=content_filter` / Gemini `SAFETY`-family markers — the engine's bounded ladder (which MUTATES context between attempts) is the only retry layer. Same failing turn now costs 190k → recovers.
|
|
18
|
+
- **The refusal ladder could never recover when replayed thinking blocks were the classifier trigger.** Stage 2 ("context reset") only elided tool-result bodies; assistant turns kept `reasoningArtifacts`/`toolUse`, and `buildAnthropicMessages` resent the flagged model-authored thinking blocks verbatim — so a fable-5 turn re-refused through the whole ladder (reproduced deterministically at both 32k and 64k budgets, and at minimal thinking). New `stripReasoningArtifactsInPlace` (`src/agent/compaction.ts`) drops the native replay channel (display `reasoning` text survives) at stage 2, so the next request falls back to plain-text history. Verified live: the exact turn that previously exhausted the ladder now recovers at stage 2 and completes.
|
|
19
|
+
- **`LaunchTui.setLivePromptHighlight` test failed on hosts exporting `NO_COLOR`** (agent harnesses, some CI): `detectColorLevel` honors `NO_COLOR` before `FORCE_COLOR` (no-color.org contract), so the test's `FORCE_COLOR=3` never produced a colored theme. The test now pins `JEO_TUI_THEME=cosmic` and clears `NO_COLOR` for the constructor — deterministic on every host.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- **A line-break key on EVERY terminal — the Windows answer ("윈도우환경에 알맞는 단축키").** The lone-LF break rule (`\n` → line break) is now DEFAULT-ON (`JEO_MULTILINE=0` opts out): raw-mode Enter is always `\r`, so a bare `\n` can only be Ctrl+J (universal) or Ctrl+Enter (Windows Terminal/conhost) — both now insert a break with zero terminal configuration. Plus the Claude-Code `\`+Enter convention: Enter on a draft ending in `\` (caret at end) replaces the backslash with a line break — works even on terminals with no Shift+Enter protocol at all (conhost, Terminal.app).
|
|
23
|
+
- **Whole-draft jumps: Ctrl+Home/Ctrl+End (Windows/Linux) and Cmd+↑/Cmd+↓ (macOS kitty-protocol) move the caret to the start/end of the entire draft (`DOC_HOME_SEQS`/`DOC_END_SEQS`), complementing the row-aware Cmd+←/→ and Home/End from 0.7.25.** Sequences are consumed even when no readline is attached, so modifier-arrow params can never leak into the draft as literal text.
|
|
24
|
+
- `/hotkeys` documents the break keys, row/draft jumps, and the Ctrl+V text fallback (18 → 21 rows).
|
|
25
|
+
|
|
26
|
+
### Verified
|
|
27
|
+
- `bun run typecheck` clean; full suite green — 2104 pass / 0 fail across 240 files. New `test/extended-key-decode.test.ts`: 21 tests / 85 assertions across all six consumer layers (decoder, whole-string decode, Ctrl+C matcher, keypress normalizer, prompt filter, abort harness, clipboard paste command) — mutation-checked (flipping the ctrl-bit test reddens 6 tests).
|
|
28
|
+
- Live probes through the REAL implementation (Bun eval, no PTY): kitty Esc keypress normalizes to `escape`; all 3 Ctrl+C encodings detected; 7 break encodings → sentinel; Cmd+←/→ land on row boundaries; Ctrl+Home/Cmd+↓ land on draft edges; trailing and split CRLF fold to one Enter; `\`+Enter emits backspace+break; mid-turn kitty Esc aborts with "ESC pressed…", kitty Ctrl+C hard-exits; Cmd+C is swallowed as input yet visible as `meta+c` to the clear-input binding.
|
|
29
|
+
- Anthropic budget/refusal fixes: affected suites green (`model-manager`, `anthropic-stream`, `model-catalog`, `retry`, `provider-error`, `refusal-recovery`, `compaction`) with 6 new test cases; live E2E (fable-5, xhigh, OAuth) — the previously-blocked multi-step turn completes end-to-end, stage 2 logs "reset 2 tool result(s) and dropped thinking replay from 3 turn(s)".
|
|
30
|
+
|
|
31
|
+
## [0.7.26] - 2026-07-02
|
|
32
|
+
_Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits._
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- **Esc (and meta-mapped Cmd+C) at the prompt intermittently did nothing.** A prior fix reordered the Esc check ahead of the `previewPending` reentrancy gate, but missed an earlier, more totalizing `!previewArmed || pickerActive` gate that Ctrl+C already bypassed (checked first) while Esc did not — so a fresh session's very first Esc after typing could be silently swallowed. The Esc/meta+C check now runs immediately after the Ctrl+C check, before that gate, mirroring Ctrl+C's existing precedent. Verified live in a real `jeo` TUI (tmux-driven keystrokes): 10/10 fresh-session repro attempts now clear the box; previously flaky.
|
|
36
|
+
- **Un-bracketed multi-line paste shredded into separate premature submits.** Terminals/paths that deliver a multi-line paste as ONE stdin chunk WITHOUT bracketed-paste markers (DECSET 2004) — e.g. a raw X11 primary-selection middle-click paste, some SSH clients, or a tmux `paste-buffer` binding without `-p` — hit `filterPromptInputChunk`'s bare `\r`/`\n` pass-through and had each embedded line submitted as its own message mid-composition instead of landing as one multi-line draft. Reproduced live: pasting `"first line\nsecond line\nthird line"` via an un-bracketed tmux paste immediately submitted line 1 to the model, leaving the rest to leak in as orphaned lines. Fixed with a guard in `src/commands/launch/input.ts`: outside an active bracketed paste, a linebreak with MORE data after it in the SAME synchronous chunk (impossible for a genuine standalone Enter keypress, which always arrives as the last byte of its read) now folds to the multi-line sentinel instead of submitting — matching the bracketed-paste contract (review the whole block, then press Enter once). A trailing linebreak with nothing after it is unaffected and still submits normally.
|
|
37
|
+
|
|
38
|
+
### Verified
|
|
39
|
+
- `bun run typecheck` clean; full suite green — 2077 pass / 0 fail across 239 files (4 new cases in `test/prompt-key-filter.test.ts` covering the un-bracketed paste guard, including a no-false-positive check for ordinary single-line typing).
|
|
40
|
+
- Both fixes reproduced and confirmed live in a real running `jeo --no-session` TUI inside tmux (not just unit tests): the exact 3-line un-bracketed paste that previously triggered a premature submit now stays as one multi-line draft, and Esc immediately clears it.
|
|
41
|
+
|
|
9
42
|
## [0.7.25] - 2026-07-02
|
|
10
43
|
_Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts._
|
|
11
44
|
|
package/README.ja.md
CHANGED
|
@@ -205,11 +205,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
205
205
|
## 変更履歴 (Changelog)
|
|
206
206
|
|
|
207
207
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
208
|
+
- **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
|
|
209
|
+
- **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
|
|
208
210
|
- **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
|
|
209
211
|
- **[0.7.24]** (2026-07-02) — OAuth callback page redesign with embedded jeo wordmark + Close button; memory budget O(n²)→O(n) incremental tracking fix.
|
|
210
212
|
- **[0.7.23]** (2026-07-01) — Anthropic 5th-generation model catalog: adds Sonnet 5, Fable 5, and Mythos 5, promotes the 4.6 generation to 1M context / 128k output, and retires the superseded 4.5/3.5/4.1 ids. Bumps the default model to Sonnet 4.6, prices the new tiers, and fixes the thinking transport for Haiku 4.5 and every 5th-gen model.
|
|
211
|
-
- **[0.7.22]** (2026-06-30) — gajae-code 0.7.3–0.7.8 parity sweep: terminal-bell notifications, a gruvbox-dark TUI theme, a resilient `jeo update` runtime check, and an ultragoal artifact gate. Ports the user-facing improvements from upstream gajae-code that jeo did not yet have, re-implemented to fit jeo's config, command, theming, and skill surfaces.
|
|
212
|
-
- **[0.7.21]** (2026-06-26) — Global llm-wiki vault integration, Gemini/Antigravity thinking indicators, generous file-reading windows, and autopilot flag validation. Adds a shared global wiki root configuration with a /wiki slash command, fires reasoning start signals up front for Gemini/Antigravity models, adjusts the large-file reading discipline to use generous windows, and validates autopilot goal and integer flags.
|
|
213
213
|
|
|
214
214
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
215
215
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -205,11 +205,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
205
205
|
## 변경 이력 (Changelog)
|
|
206
206
|
|
|
207
207
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
208
|
+
- **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
|
|
209
|
+
- **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
|
|
208
210
|
- **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
|
|
209
211
|
- **[0.7.24]** (2026-07-02) — OAuth callback page redesign with embedded jeo wordmark + Close button; memory budget O(n²)→O(n) incremental tracking fix.
|
|
210
212
|
- **[0.7.23]** (2026-07-01) — Anthropic 5th-generation model catalog: adds Sonnet 5, Fable 5, and Mythos 5, promotes the 4.6 generation to 1M context / 128k output, and retires the superseded 4.5/3.5/4.1 ids. Bumps the default model to Sonnet 4.6, prices the new tiers, and fixes the thinking transport for Haiku 4.5 and every 5th-gen model.
|
|
211
|
-
- **[0.7.22]** (2026-06-30) — gajae-code 0.7.3–0.7.8 parity sweep: terminal-bell notifications, a gruvbox-dark TUI theme, a resilient `jeo update` runtime check, and an ultragoal artifact gate. Ports the user-facing improvements from upstream gajae-code that jeo did not yet have, re-implemented to fit jeo's config, command, theming, and skill surfaces.
|
|
212
|
-
- **[0.7.21]** (2026-06-26) — Global llm-wiki vault integration, Gemini/Antigravity thinking indicators, generous file-reading windows, and autopilot flag validation. Adds a shared global wiki root configuration with a /wiki slash command, fires reasoning start signals up front for Gemini/Antigravity models, adjusts the large-file reading discipline to use generous windows, and validates autopilot goal and integer flags.
|
|
213
213
|
|
|
214
214
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
215
215
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -232,11 +232,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
232
232
|
## Changelog
|
|
233
233
|
|
|
234
234
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
235
|
+
- **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
|
|
236
|
+
- **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
|
|
235
237
|
- **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
|
|
236
238
|
- **[0.7.24]** (2026-07-02) — OAuth callback page redesign with embedded jeo wordmark + Close button; memory budget O(n²)→O(n) incremental tracking fix.
|
|
237
239
|
- **[0.7.23]** (2026-07-01) — Anthropic 5th-generation model catalog: adds Sonnet 5, Fable 5, and Mythos 5, promotes the 4.6 generation to 1M context / 128k output, and retires the superseded 4.5/3.5/4.1 ids. Bumps the default model to Sonnet 4.6, prices the new tiers, and fixes the thinking transport for Haiku 4.5 and every 5th-gen model.
|
|
238
|
-
- **[0.7.22]** (2026-06-30) — gajae-code 0.7.3–0.7.8 parity sweep: terminal-bell notifications, a gruvbox-dark TUI theme, a resilient `jeo update` runtime check, and an ultragoal artifact gate. Ports the user-facing improvements from upstream gajae-code that jeo did not yet have, re-implemented to fit jeo's config, command, theming, and skill surfaces.
|
|
239
|
-
- **[0.7.21]** (2026-06-26) — Global llm-wiki vault integration, Gemini/Antigravity thinking indicators, generous file-reading windows, and autopilot flag validation. Adds a shared global wiki root configuration with a /wiki slash command, fires reasoning start signals up front for Gemini/Antigravity models, adjusts the large-file reading discipline to use generous windows, and validates autopilot goal and integer flags.
|
|
240
240
|
|
|
241
241
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
242
242
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -205,11 +205,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
205
205
|
## 更新日志 (Changelog)
|
|
206
206
|
|
|
207
207
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
208
|
+
- **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
|
|
209
|
+
- **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
|
|
208
210
|
- **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
|
|
209
211
|
- **[0.7.24]** (2026-07-02) — OAuth callback page redesign with embedded jeo wordmark + Close button; memory budget O(n²)→O(n) incremental tracking fix.
|
|
210
212
|
- **[0.7.23]** (2026-07-01) — Anthropic 5th-generation model catalog: adds Sonnet 5, Fable 5, and Mythos 5, promotes the 4.6 generation to 1M context / 128k output, and retires the superseded 4.5/3.5/4.1 ids. Bumps the default model to Sonnet 4.6, prices the new tiers, and fixes the thinking transport for Haiku 4.5 and every 5th-gen model.
|
|
211
|
-
- **[0.7.22]** (2026-06-30) — gajae-code 0.7.3–0.7.8 parity sweep: terminal-bell notifications, a gruvbox-dark TUI theme, a resilient `jeo update` runtime check, and an ultragoal artifact gate. Ports the user-facing improvements from upstream gajae-code that jeo did not yet have, re-implemented to fit jeo's config, command, theming, and skill surfaces.
|
|
212
|
-
- **[0.7.21]** (2026-06-26) — Global llm-wiki vault integration, Gemini/Antigravity thinking indicators, generous file-reading windows, and autopilot flag validation. Adds a shared global wiki root configuration with a /wiki slash command, fires reasoning start signals up front for Gemini/Antigravity models, adjusts the large-file reading discipline to use generous windows, and validates autopilot goal and integer flags.
|
|
213
213
|
|
|
214
214
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
215
215
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/compaction.ts
CHANGED
|
@@ -147,6 +147,30 @@ export function trimToolResultsInPlace(
|
|
|
147
147
|
return { trimmed, tokens };
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Refusal-recovery companion to `trimToolResultsInPlace`: drop provider-native
|
|
152
|
+
* reasoning artifacts (and native tool-use replay records) from ASSISTANT turns
|
|
153
|
+
* so the next request replays plain text instead of native thinking blocks.
|
|
154
|
+
*
|
|
155
|
+
* Why: Anthropic's refusal classifier judges the WHOLE resent conversation, and
|
|
156
|
+
* replayed `thinking` blocks are model-authored content — the usual trip wire
|
|
157
|
+
* that tool-result eliding cannot clear (field case: a fable-5 turn re-refused
|
|
158
|
+
* through the entire ladder because every resend replayed the flagged thinking
|
|
159
|
+
* blocks verbatim). Messages are REPLACED, never mutated (identity caches).
|
|
160
|
+
* Display fields (`reasoning`) are kept — only the wire-replay channel is cut.
|
|
161
|
+
*/
|
|
162
|
+
export function stripReasoningArtifactsInPlace(history: Message[]): number {
|
|
163
|
+
let stripped = 0;
|
|
164
|
+
for (let i = 0; i < history.length; i++) {
|
|
165
|
+
const m = history[i]!;
|
|
166
|
+
if (m.role !== "assistant" || (!m.reasoningArtifacts?.length && !m.toolUse?.length)) continue;
|
|
167
|
+
const { reasoningArtifacts: _artifacts, toolUse: _toolUse, ...rest } = m;
|
|
168
|
+
history[i] = rest;
|
|
169
|
+
stripped++;
|
|
170
|
+
}
|
|
171
|
+
return stripped;
|
|
172
|
+
}
|
|
173
|
+
|
|
150
174
|
/**
|
|
151
175
|
* Accurate BPE token total for a history, summing `countTokensAccurate` per
|
|
152
176
|
* message (+1 per message for role/separator overhead, mirroring
|
package/src/agent/engine.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
|
|
|
21
21
|
import { truncateToolOutput, formatToolResultBody } from "./tool-output";
|
|
22
22
|
export { TOOL_OUTPUT_MAX, READ_OUTPUT_MAX, TOOL_SPILL_THRESHOLD, MAX_TOOL_ARTIFACTS, truncateToolOutput, spillToolResult } from "./tool-output";
|
|
23
23
|
import { StepBudget, dynamicStepBudgetConfig, resolveStepBudgetConfig, hashSignature, type StepBudgetConfig } from "./step-budget";
|
|
24
|
-
import { historyTokens, trimToolResultsInPlace } from "./compaction";
|
|
24
|
+
import { historyTokens, trimToolResultsInPlace, stripReasoningArtifactsInPlace } from "./compaction";
|
|
25
25
|
import { jeoEnv } from "../util/env";
|
|
26
26
|
import { GUARD_LIMITS, isVerificationSignal, repeatHint, classifyDoneGate } from "./loop-guards";
|
|
27
27
|
import { stripLeakedReasoningTags } from "../ai/think-tags";
|
|
@@ -611,9 +611,17 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
611
611
|
}
|
|
612
612
|
if (refusalRetries === 2) {
|
|
613
613
|
const res = trimToolResultsInPlace(history, { budgetTokens: 0, keepRecent: 0 });
|
|
614
|
+
// Also cut the native thinking-block replay channel: refusals judge the WHOLE
|
|
615
|
+
// resent conversation, and replayed (model-authored) thinking blocks are the
|
|
616
|
+
// trip wire tool-result eliding can't clear — with artifacts stripped the next
|
|
617
|
+
// request falls back to plain-text history (same shape as the 400 fail-safe).
|
|
618
|
+
const strippedTurns = stripReasoningArtifactsInPlace(history);
|
|
619
|
+
const parts: string[] = [];
|
|
620
|
+
if (res.trimmed > 0) parts.push(`reset ${res.trimmed} tool result(s)`);
|
|
621
|
+
if (strippedTurns > 0) parts.push(`dropped thinking replay from ${strippedTurns} turn(s)`);
|
|
614
622
|
ev.onNotice?.(
|
|
615
|
-
|
|
616
|
-
? `provider refused again —
|
|
623
|
+
parts.length
|
|
624
|
+
? `provider refused again — ${parts.join(" and ")} and retrying (refusals require a context reset)`
|
|
617
625
|
: "provider refused again — continuing with a fresh instruction",
|
|
618
626
|
);
|
|
619
627
|
history.push({
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
subagentRoleIds,
|
|
28
28
|
validateSubagentDoneReason,
|
|
29
29
|
} from "./subagents";
|
|
30
|
-
import {
|
|
30
|
+
import { resolveMaxOutputTokens } from "../ai/model-manager";
|
|
31
31
|
import type { SubagentRegistry } from "./subagent-registry";
|
|
32
32
|
|
|
33
33
|
/** Lifecycle event emitted while a delegated subagent runs. */
|
|
@@ -219,7 +219,7 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
|
|
|
219
219
|
cwd,
|
|
220
220
|
model,
|
|
221
221
|
maxSteps,
|
|
222
|
-
maxTokens:
|
|
222
|
+
maxTokens: resolveMaxOutputTokens(model, thinking),
|
|
223
223
|
// Bounded delegation: a subagent's step contract stays exact — the parent
|
|
224
224
|
// owns any retry/extension decision, so the gjc retry flow is disabled here.
|
|
225
225
|
budget: { maxExtensions: 0 },
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -6,7 +6,7 @@ import "./register-providers"; // side-effect: registers built-in adapters into
|
|
|
6
6
|
import type { CallOptions, Message, ProviderAdapter, ProviderName } from "./types";
|
|
7
7
|
import { expandAlias, resolveModelId, effectiveAliasesFor } from "./model-registry";
|
|
8
8
|
import { findCatalogEntry, type ModelCatalogEntry } from "./model-catalog-compat";
|
|
9
|
-
import { toProviderModel, CODEX_MODELS } from "./model-catalog";
|
|
9
|
+
import { toProviderModel, CODEX_MODELS, findCatalogModel } from "./model-catalog";
|
|
10
10
|
import { xaiCredential } from "./providers/xai";
|
|
11
11
|
import { OPENAI_COMPAT_NAMES, isOpenAICompatProvider } from "./providers/openai-compatible-catalog";
|
|
12
12
|
import { withRetry, defaultRetryable, type RetryOptions } from "../util/retry";
|
|
@@ -100,6 +100,32 @@ export function thinkingMaxTokens(level?: "minimal" | "low" | "medium" | "high"
|
|
|
100
100
|
return 16000;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/** Default cap for the catalog-derived output budget. 64k covers every real coding
|
|
104
|
+
* turn (large writes + deep adaptive thinking) while staying clear of the admission
|
|
105
|
+
* 429 risk that a blanket 128k `max_tokens` carries on low-tier API keys. */
|
|
106
|
+
const DEFAULT_MAX_OUTPUT_CAP = 64_000;
|
|
107
|
+
|
|
108
|
+
/** Output-token budget for a call (gjc/omp parity): the model's CATALOG max-output,
|
|
109
|
+
* capped by JEO_MAX_OUTPUT_TOKENS (default 64k) — NOT the thinking-level table.
|
|
110
|
+
*
|
|
111
|
+
* Rationale: on Anthropic adaptive-thinking models (Sonnet/Fable/Mythos 5, Opus 4.6+)
|
|
112
|
+
* thinking tokens are spent INSIDE `max_tokens`. Deriving `max_tokens` from the
|
|
113
|
+
* thinking level (4k–32k) makes thinking and the visible reply compete for the same
|
|
114
|
+
* small budget, so a deep-thinking step dies with `stop_reason=max_tokens` and no
|
|
115
|
+
* content ("output budget exhausted" dead turns). The thinking level keeps steering
|
|
116
|
+
* DEPTH via reasoningEffort/output_config; it no longer constrains output size.
|
|
117
|
+
* Uncatalogued models (local/live ids) keep the legacy thinking-table budget. */
|
|
118
|
+
export function resolveMaxOutputTokens(
|
|
119
|
+
model?: string,
|
|
120
|
+
level?: "minimal" | "low" | "medium" | "high" | "xhigh",
|
|
121
|
+
): number {
|
|
122
|
+
const meta = model ? findCatalogEntry(expandAlias(model, ALIAS_DEFAULTS)) : undefined;
|
|
123
|
+
if (!meta) return thinkingMaxTokens(level);
|
|
124
|
+
const envCap = Number(jeoEnv("MAX_OUTPUT_TOKENS"));
|
|
125
|
+
const cap = Number.isFinite(envCap) && envCap > 0 ? envCap : DEFAULT_MAX_OUTPUT_CAP;
|
|
126
|
+
return Math.min(findCatalogModel(meta.id)?.maxOutputTokens ?? cap, cap);
|
|
127
|
+
}
|
|
128
|
+
|
|
103
129
|
/** Map the thinking level to an OpenAI reasoning-effort tier. minimal/low/medium/high pass
|
|
104
130
|
* through unchanged and xhigh folds to high (the deepest tier the provider APIs accept), so
|
|
105
131
|
* reasoning works at EVERY thinking level (gajae parity: minimal is a real effort). Only an
|
|
@@ -339,7 +365,7 @@ async function resolveCall(options: Partial<CallOptions>, kind: "request" | "str
|
|
|
339
365
|
model: providerModelFor(model),
|
|
340
366
|
systemPrompt: options.systemPrompt,
|
|
341
367
|
temperature: options.temperature ?? 0.2,
|
|
342
|
-
maxTokens: options.maxTokens ??
|
|
368
|
+
maxTokens: options.maxTokens ?? resolveMaxOutputTokens(model, config.thinkingLevel),
|
|
343
369
|
jsonMode: options.jsonMode,
|
|
344
370
|
baseUrl,
|
|
345
371
|
numCtx: options.numCtx ?? (provider === "ollama" ? (config as { ollamaNumCtx?: number }).ollamaNumCtx : undefined),
|
|
@@ -225,7 +225,12 @@ export function anthropicPayload(
|
|
|
225
225
|
const model = stripAnthropicPrefix(options.model);
|
|
226
226
|
const systemPrompt = options.systemPrompt ?? messages.find(m => m.role === "system")?.content;
|
|
227
227
|
// Image attachments + native tool/thinking-block reconstruction live in buildAnthropicMessages.
|
|
228
|
-
|
|
228
|
+
// Non-streaming requests must finish inside Anthropic's ~10-minute HTTP window; a
|
|
229
|
+
// 64k+ budget can exceed it on a slow generation. 32k (the proven xhigh ceiling)
|
|
230
|
+
// stays safe; the stream path carries the full catalog-derived budget.
|
|
231
|
+
const NON_STREAM_MAX_TOKENS = 32_000;
|
|
232
|
+
const requestedMax = options.maxTokens ?? 4000;
|
|
233
|
+
const maxTokens = stream ? requestedMax : Math.min(requestedMax, NON_STREAM_MAX_TOKENS);
|
|
229
234
|
const effort = options.reasoningEffort;
|
|
230
235
|
const thinkingEnabled = effort !== undefined;
|
|
231
236
|
// gjc parity: pick the thinking transport per model. Adaptive (Opus/Sonnet 4.6+) carries NO
|
|
@@ -349,7 +349,11 @@ export function queuePromptInputChunk(state: PromptInputQueue, chunk: string): b
|
|
|
349
349
|
const plain = start === -1 ? rest : rest.slice(0, start);
|
|
350
350
|
if (start !== -1) state.inPaste = true;
|
|
351
351
|
rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
|
|
352
|
-
|
|
352
|
+
// Decode kitty/MOK re-encodings first so a typed segment behaves identically
|
|
353
|
+
// whichever keyboard protocol the terminal negotiated. (Preserved Shift+Enter
|
|
354
|
+
// forms still carry ESC, so feedTypedSegment rejects such segments — exactly
|
|
355
|
+
// what the legacy byte path did.)
|
|
356
|
+
if (feedTypedSegment(state, decodeExtendedKeys(stripMouseReports(plain)))) accepted = true;
|
|
353
357
|
}
|
|
354
358
|
}
|
|
355
359
|
return accepted;
|
|
@@ -449,11 +453,15 @@ export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFl
|
|
|
449
453
|
|
|
450
454
|
const handleData = (chunk: string | Uint8Array) => {
|
|
451
455
|
if (!captureEsc || controller.signal.aborted) return;
|
|
452
|
-
const
|
|
453
|
-
if (
|
|
454
|
-
opts.onBufferedInput?.(
|
|
456
|
+
const raw = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
457
|
+
if (raw.includes(PASTE_START) || opts.pasteActive?.()) {
|
|
458
|
+
opts.onBufferedInput?.(raw);
|
|
455
459
|
return;
|
|
456
460
|
}
|
|
461
|
+
// Kitty/modifyOtherKeys re-encodings → legacy bytes FIRST, so Esc (`CSI 27u`) and
|
|
462
|
+
// Ctrl+C (`CSI 99;5u` / `CSI 27;5;99~`) cancel a live turn on every terminal —
|
|
463
|
+
// previously they fell into the "noise" branch and did NOTHING mid-turn.
|
|
464
|
+
const text = decodeExtendedKeys(raw);
|
|
457
465
|
if (text === "\u000f") {
|
|
458
466
|
opts.onDetailKey?.();
|
|
459
467
|
return;
|
|
@@ -553,10 +561,19 @@ export function decideCtrlC(
|
|
|
553
561
|
return hasInput ? "clear" : "exit";
|
|
554
562
|
}
|
|
555
563
|
|
|
556
|
-
/** Shift+Enter encodings the box rewrites to a hard-break SENTINEL
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
|
|
564
|
+
/** Shift+Enter encodings the box rewrites to a hard-break SENTINEL. Covers every
|
|
565
|
+
* encoding a real terminal produces for a modified Enter:
|
|
566
|
+
* - xterm `modifyOtherKeys` (`CSI 27;<mod>;13~`): Shift=2, Alt=3, Ctrl=5, …
|
|
567
|
+
* - kitty keyboard protocol (`CSI 13;<mod>u`), same modifier digits
|
|
568
|
+
* - `ESC \r` — alt-as-meta Alt+Enter (macOS Option+Enter, Windows Terminal Alt+Enter)
|
|
569
|
+
* Shift/Alt/Ctrl+Enter all mean "insert a line break" (no terminal distinguishes them
|
|
570
|
+
* usefully for a prompt), so every modified-Enter form maps to the SAME sentinel.
|
|
571
|
+
* Shared by the live input filter and its tests so they can't drift. */
|
|
572
|
+
export const SHIFT_ENTER_SEQS: readonly string[] = [
|
|
573
|
+
"\u001b[27;2;13~", "\u001b[27;3;13~", "\u001b[27;5;13~", "\u001b[27;4;13~", "\u001b[27;6;13~",
|
|
574
|
+
"\u001b[13;2u", "\u001b[13;3u", "\u001b[13;5u", "\u001b[13;4u", "\u001b[13;6u",
|
|
575
|
+
"\u001b\r",
|
|
576
|
+
];
|
|
560
577
|
/** Sequences that should jump the caret to the START of the current VISUAL ROW: macOS
|
|
561
578
|
* Cmd+Left (`CSI 1;9D`) and the platform-neutral Home key in both its xterm (`CSI H`)
|
|
562
579
|
* and vt220 (`CSI 1~`) forms, plus the SS3 variant (`ESC O H`) some terminals send in
|
|
@@ -567,6 +584,173 @@ export const ROW_HOME_SEQS: readonly string[] = ["\u001b[1;9D", "\u001b[H", "\u0
|
|
|
567
584
|
/** Mirror of {@link ROW_HOME_SEQS} for the END of the current visual row: macOS
|
|
568
585
|
* Cmd+Right (`CSI 1;9C`) and the xterm/vt220/SS3 End key forms. */
|
|
569
586
|
export const ROW_END_SEQS: readonly string[] = ["\u001b[1;9C", "\u001b[F", "\u001b[4~", "\u001bOF"];
|
|
587
|
+
/** Sequences that jump to the START of the WHOLE draft: Ctrl+Home (the Windows/Linux
|
|
588
|
+
* document-start convention, xterm `CSI 1;5H`) and macOS Cmd+Up (`CSI 1;9A`). */
|
|
589
|
+
export const DOC_HOME_SEQS: readonly string[] = ["\u001b[1;5H", "\u001b[1;9A"];
|
|
590
|
+
/** Mirror of {@link DOC_HOME_SEQS} for the END of the draft: Ctrl+End / Cmd+Down. */
|
|
591
|
+
export const DOC_END_SEQS: readonly string[] = ["\u001b[1;5F", "\u001b[1;9B"];
|
|
592
|
+
|
|
593
|
+
/** Matches ONE extended-keyboard escape at `s[i]` — the kitty CSI-u form
|
|
594
|
+
* (`CSI <code>;<mods>[:<event>]u`) or the xterm modifyOtherKeys form
|
|
595
|
+
* (`CSI 27;<mods>;<code>~`) — and returns its decoded LEGACY bytes plus length.
|
|
596
|
+
*
|
|
597
|
+
* jeo asks the terminal for BOTH protocols at the prompt (they make Shift+Enter
|
|
598
|
+
* distinguishable), but that also re-encodes keys the rest of the REPL matches by
|
|
599
|
+
* their classic bytes: under kitty flag 1, Esc arrives as `CSI 27u` (readline sees
|
|
600
|
+
* an unknown sequence — the "esc가 안 먹힘" bug) and Ctrl+C as `CSI 99;5u` (no
|
|
601
|
+
* `\u0003` byte anywhere — Ctrl+C dead); xterm modifyOtherKeys=2 re-encodes them as
|
|
602
|
+
* `CSI 27;<m>;27~`/`CSI 27;5;99~`. This decoder maps every such form BACK to the
|
|
603
|
+
* legacy byte(s) so readline, the SIGINT paths, and the mid-turn ESC/abort harness
|
|
604
|
+
* keep working identically whichever protocol the terminal negotiated.
|
|
605
|
+
*
|
|
606
|
+
* Modified-Enter forms are NOT decoded here — they are matched (earlier) against
|
|
607
|
+
* {@link SHIFT_ENTER_SEQS} and become the hard-break sentinel; if one reaches this
|
|
608
|
+
* decoder (a non-filter consumer), it decodes to "" (swallowed) rather than \r so a
|
|
609
|
+
* Shift+Enter can never SUBMIT a draft. Key-RELEASE events (kitty `:3` suffix, only
|
|
610
|
+
* sent when a flag-2 terminal leaked them) and unknown codes (F-keys, media keys)
|
|
611
|
+
* decode to "" — swallowed, never leaked as literal text. */
|
|
612
|
+
export function matchExtendedKey(s: string, i: number): { out: string; len: number } | null {
|
|
613
|
+
if (!s.startsWith("\u001b[", i)) return null;
|
|
614
|
+
const win = s.slice(i, i + 24);
|
|
615
|
+
// xterm modifyOtherKeys: CSI 27 ; <mods> ; <code> ~
|
|
616
|
+
const mok = /^\u001b\[27;(\d+);(\d+)~/.exec(win);
|
|
617
|
+
if (mok) return { out: decodeExtendedCode(Number(mok[2]), Number(mok[1])), len: mok[0].length };
|
|
618
|
+
// kitty CSI-u: CSI <code> [; <mods> [: <event>]] u
|
|
619
|
+
const u = /^\u001b\[(\d+)(?:;(\d+)(?::(\d+))?)?u/.exec(win);
|
|
620
|
+
if (!u) return null;
|
|
621
|
+
const event = u[3] ? Number(u[3]) : 1;
|
|
622
|
+
if (event === 3) return { out: "", len: u[0].length }; // key release — never input
|
|
623
|
+
return { out: decodeExtendedCode(Number(u[1]), u[2] ? Number(u[2]) : 1), len: u[0].length };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Legacy bytes for one extended-key (code, modifier) pair. Unknown codes (the kitty
|
|
627
|
+
* functional-key range — F-keys, media keys, non-ASCII code points with modifiers)
|
|
628
|
+
* decode to "" — the report is SWALLOWED, never leaked into the draft as literal
|
|
629
|
+
* `[57376u`-style text. `mods` is the CSI modifier value: 1 + bitmask, where
|
|
630
|
+
* Shift=1, Alt=2, Ctrl=4, Super=8 (so 1 = unmodified, 2 = Shift, 5 = Ctrl, …). */
|
|
631
|
+
function decodeExtendedCode(code: number, mods: number): string {
|
|
632
|
+
const bits = Math.max(0, mods - 1);
|
|
633
|
+
const ctrl = (bits & 4) !== 0;
|
|
634
|
+
const alt = (bits & 2) !== 0;
|
|
635
|
+
const superMod = (bits & 8) !== 0;
|
|
636
|
+
// Esc — any modifier still means "escape" to the prompt.
|
|
637
|
+
if (code === 27) return "\u001b";
|
|
638
|
+
// Enter/Tab/Backspace/Space in their MODIFIED forms; unmodified ones arrive as
|
|
639
|
+
// legacy bytes already. Modified Enter is handled by SHIFT_ENTER_SEQS before this
|
|
640
|
+
// decoder runs in the filter; anywhere else it must not fabricate a submit → "".
|
|
641
|
+
if (code === 13) return mods <= 1 ? "\r" : "";
|
|
642
|
+
if (code === 9) {
|
|
643
|
+
if (bits === 1) return "\u001b[Z"; // Shift+Tab — readline's back-complete form
|
|
644
|
+
return "\t";
|
|
645
|
+
}
|
|
646
|
+
if (code === 127 || code === 8) {
|
|
647
|
+
if (alt) return "\u001b\u007f"; // Alt/Option+Backspace → backward-kill-word
|
|
648
|
+
if (ctrl) return "\u0017"; // Ctrl+Backspace (Windows word-delete) → Ctrl+W
|
|
649
|
+
return "\u007f";
|
|
650
|
+
}
|
|
651
|
+
if (code === 32) return ctrl ? "\u0000" : " ";
|
|
652
|
+
// Printable ASCII. Ctrl+letter → C0 control byte (Ctrl+C → \u0003); Alt+key →
|
|
653
|
+
// ESC-prefixed meta form readline understands (Alt+b/f word motion, …).
|
|
654
|
+
if (code >= 33 && code <= 126) {
|
|
655
|
+
// Cmd/Super+key has NO legacy byte encoding — swallow it rather than insert the
|
|
656
|
+
// bare letter into the draft (Cmd+C reaching the app must not type a "c";
|
|
657
|
+
// normalizeKeypress still reports it as meta+c for the clear-input binding).
|
|
658
|
+
if (superMod && !ctrl && !alt) return "";
|
|
659
|
+
const ch = String.fromCharCode(code);
|
|
660
|
+
if (ctrl && code >= 97 && code <= 122) {
|
|
661
|
+
const ctl = String.fromCharCode(code - 96);
|
|
662
|
+
return alt ? `\u001b${ctl}` : ctl;
|
|
663
|
+
}
|
|
664
|
+
if (ctrl && "@[\\]^_".includes(ch)) {
|
|
665
|
+
return String.fromCharCode(ch.charCodeAt(0) & 0x1f);
|
|
666
|
+
}
|
|
667
|
+
if (alt) return `\u001b${ch}`;
|
|
668
|
+
return ch; // Shift-only / unmodified: the terminal already applied the shift
|
|
669
|
+
}
|
|
670
|
+
return "";
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Decode every extended-keyboard escape (kitty CSI-u / xterm modifyOtherKeys) in `s`
|
|
674
|
+
* to its legacy bytes, leaving all other bytes untouched. Modified-Enter forms in
|
|
675
|
+
* {@link SHIFT_ENTER_SEQS} are preserved verbatim (the caller decides their meaning).
|
|
676
|
+
* Used by every RAW-stdin consumer that matches classic control bytes — the mid-turn
|
|
677
|
+
* ESC/Ctrl+C harness and the live-turn input capture — so keys keep working when the
|
|
678
|
+
* terminal negotiated an extended keyboard protocol. */
|
|
679
|
+
export function decodeExtendedKeys(s: string): string {
|
|
680
|
+
if (!s.includes("\u001b")) return s; // fast path: plain text / large pastes
|
|
681
|
+
let out = "";
|
|
682
|
+
let i = 0;
|
|
683
|
+
while (i < s.length) {
|
|
684
|
+
const breakSeq = SHIFT_ENTER_SEQS.find(seq => s.startsWith(seq, i));
|
|
685
|
+
if (breakSeq) { out += breakSeq; i += breakSeq.length; continue; }
|
|
686
|
+
const ext = matchExtendedKey(s, i);
|
|
687
|
+
if (ext) { out += ext.out; i += ext.len; continue; }
|
|
688
|
+
out += s[i];
|
|
689
|
+
i += 1;
|
|
690
|
+
}
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** True when a RAW stdin chunk contains a Ctrl+C in ANY encoding — the legacy
|
|
695
|
+
* `\u0003` byte, kitty CSI-u (`CSI 99;5u`), or xterm modifyOtherKeys
|
|
696
|
+
* (`CSI 27;5;99~`). The single test every "did the user press Ctrl+C?" byte
|
|
697
|
+
* scan must use once an extended keyboard protocol may be active. */
|
|
698
|
+
export function chunkHasCtrlC(chunk: string): boolean {
|
|
699
|
+
if (chunk.includes("\u0003")) return true;
|
|
700
|
+
return chunk.includes("\u001b") && decodeExtendedKeys(chunk).includes("\u0003");
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** The subset of readline's keypress event the prompt handlers switch on. */
|
|
704
|
+
export interface NormalizedKey {
|
|
705
|
+
name?: string;
|
|
706
|
+
ctrl?: boolean;
|
|
707
|
+
meta?: boolean;
|
|
708
|
+
shift?: boolean;
|
|
709
|
+
sequence?: string;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Normalize a readline `keypress` event for the prompt's stdin listeners.
|
|
713
|
+
*
|
|
714
|
+
* Node/Bun's keypress decoder does not know the kitty CSI-u or xterm
|
|
715
|
+
* modifyOtherKeys encodings: Esc arrives as `{ sequence: "\u001b[27u", name:
|
|
716
|
+
* undefined }`, Ctrl+C as `{ sequence: "\u001b[99;5u" }`, and every `key?.name`
|
|
717
|
+
* check in the footer/picker handlers falls through — the idle-prompt half of the
|
|
718
|
+
* "esc/ctrl+c가 안 먹힘" bug. This maps such events back to the `{ name, ctrl,
|
|
719
|
+
* meta, shift }` shape those handlers already match on; every other event is
|
|
720
|
+
* returned unchanged. */
|
|
721
|
+
export function normalizeKeypress(key: NormalizedKey | undefined): NormalizedKey | undefined {
|
|
722
|
+
const seq = key?.sequence;
|
|
723
|
+
// Bun quirk (verified live): an unrecognized CSI emits `name: "undefined"` — the
|
|
724
|
+
// literal STRING — where Node emits `name: undefined`. Treat both as unnamed.
|
|
725
|
+
const unnamed = key?.name === undefined || key?.name === "undefined";
|
|
726
|
+
if (!key || !seq || !unnamed) return key;
|
|
727
|
+
// Parse the raw sequence directly (kitty CSI-u first, then xterm modifyOtherKeys)
|
|
728
|
+
// instead of round-tripping through the byte decoder: Cmd/Super-modified keys
|
|
729
|
+
// decode to "" (swallowed as INPUT) yet must still normalize to a named EVENT so
|
|
730
|
+
// bindings like "meta+c clears the box" can see them.
|
|
731
|
+
const u = /^\u001b\[(\d+)(?:;(\d+)(?::(\d+))?)?u$/.exec(seq);
|
|
732
|
+
const mok = u ? null : /^\u001b\[27;(\d+);(\d+)~$/.exec(seq);
|
|
733
|
+
if (!u && !mok) return key;
|
|
734
|
+
const code = u ? Number(u[1]) : Number(mok![2]);
|
|
735
|
+
const mods = u ? (u[2] ? Number(u[2]) : 1) : Number(mok![1]);
|
|
736
|
+
const event = u?.[3] ? Number(u[3]) : 1;
|
|
737
|
+
if (event === 3) return key; // key release — leave unnamed (inert)
|
|
738
|
+
const bits = Math.max(0, mods - 1);
|
|
739
|
+
const flags = {
|
|
740
|
+
ctrl: (bits & 4) !== 0,
|
|
741
|
+
meta: (bits & 2) !== 0 || (bits & 8) !== 0, // Alt or Super both read as "meta"
|
|
742
|
+
shift: (bits & 1) !== 0,
|
|
743
|
+
};
|
|
744
|
+
if (code === 27) return { ...key, ...flags, name: "escape", ctrl: false };
|
|
745
|
+
if (code === 13) return { ...key, ...flags, name: "return" };
|
|
746
|
+
if (code === 9) return { ...key, ...flags, name: "tab" };
|
|
747
|
+
if (code === 127 || code === 8) return { ...key, ...flags, name: "backspace" };
|
|
748
|
+
if (code === 32) return { ...key, ...flags, name: "space" };
|
|
749
|
+
if (code >= 33 && code <= 126) {
|
|
750
|
+
return { ...key, ...flags, name: String.fromCharCode(code).toLowerCase() };
|
|
751
|
+
}
|
|
752
|
+
return key;
|
|
753
|
+
}
|
|
570
754
|
|
|
571
755
|
|
|
572
756
|
/** Minimal readline view the prompt key-filter reads (and mutates `cursor` on a
|
|
@@ -600,6 +784,11 @@ export interface PromptKeyFilterEnv {
|
|
|
600
784
|
export interface PromptKeyFilterState {
|
|
601
785
|
inPaste: boolean;
|
|
602
786
|
carry?: string;
|
|
787
|
+
/** The previous chunk's forwarded output ended in a bare `\r` (Enter). A `\n` at the
|
|
788
|
+
* START of the next chunk is then the second half of a split CRLF — swallow it, or
|
|
789
|
+
* (with the default-on lone-LF rule) it would insert a phantom break at the fresh
|
|
790
|
+
* prompt. */
|
|
791
|
+
crTail?: boolean;
|
|
603
792
|
}
|
|
604
793
|
|
|
605
794
|
/** Length of the longest trailing run of `data` that is a PROPER prefix of a
|
|
@@ -674,6 +863,11 @@ export function filterPromptInputChunk(
|
|
|
674
863
|
state.carry = "\r";
|
|
675
864
|
data = data.slice(0, data.length - 1);
|
|
676
865
|
}
|
|
866
|
+
// Second half of a CRLF split across chunk reads (previous chunk's output ended in
|
|
867
|
+
// `\r` = Enter): the leading `\n` here is the SAME keystroke/paste tail, not a new
|
|
868
|
+
// break — swallow it before the lone-LF rule can see it.
|
|
869
|
+
if (state.crTail && !state.inPaste && data.startsWith("\n")) data = data.slice(1);
|
|
870
|
+
state.crTail = false;
|
|
677
871
|
// Empty-line Backspace guard: a standalone Backspace with an empty buffer is a no-op
|
|
678
872
|
// that some Bun readline builds turn into a spurious `close` (hard exit) — drop it
|
|
679
873
|
// before it reaches readline. Forwarded normally inside a paste or with text present.
|
|
@@ -732,9 +926,29 @@ export function filterPromptInputChunk(
|
|
|
732
926
|
i += seq.length;
|
|
733
927
|
continue;
|
|
734
928
|
}
|
|
929
|
+
// Whole-draft jumps: Ctrl+Home/Ctrl+End (Windows/Linux document-start/end) and
|
|
930
|
+
// macOS Cmd+Up/Cmd+Down. Same consume-and-move contract as the row-edge branch.
|
|
931
|
+
const docEdge = DOC_HOME_SEQS.find(seq => data.startsWith(seq, i)) !== undefined ? "start"
|
|
932
|
+
: DOC_END_SEQS.find(seq => data.startsWith(seq, i)) !== undefined ? "end"
|
|
933
|
+
: undefined;
|
|
934
|
+
if (docEdge) {
|
|
935
|
+
const seq = (docEdge === "start" ? DOC_HOME_SEQS : DOC_END_SEQS).find(s => data.startsWith(s, i))!;
|
|
936
|
+
// Without an rl handle there is nothing to move — still CONSUME the sequence
|
|
937
|
+
// so `1;9A`-style params can never leak into the draft as literal text.
|
|
938
|
+
if (rl) rl.cursor = docEdge === "start" ? 0 : (rl.line ?? "").length;
|
|
939
|
+
i += seq.length;
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
735
942
|
|
|
736
943
|
const combo = matchCursorCombo(data, i);
|
|
737
944
|
if (combo) { out += combo[1]; i += combo[0].length; continue; }
|
|
945
|
+
// Extended-keyboard re-encodings (kitty CSI-u / xterm modifyOtherKeys): decode back
|
|
946
|
+
// to the legacy byte(s) so Esc (`CSI 27u`), Ctrl+C (`CSI 99;5u` / `CSI 27;5;99~`),
|
|
947
|
+
// Ctrl+V and friends keep working after jeo enables those protocols for Shift+Enter.
|
|
948
|
+
// Without this, readline receives an unknown sequence and the key is simply DEAD —
|
|
949
|
+
// the reported "esc 키 (또 ctrl+c) 입력이 안되는 문제".
|
|
950
|
+
const ext = matchExtendedKey(data, i);
|
|
951
|
+
if (ext) { out += ext.out; i += ext.len; continue; }
|
|
738
952
|
// Up/Down between the box's visual rows (textarea feel) for any MULTI-ROW draft;
|
|
739
953
|
// at the top/bottom edge a soft-wrapped one-liner falls through to history recall,
|
|
740
954
|
// but a genuine multi-line draft swallows the key (the "↓ cuts text" fix).
|
|
@@ -751,12 +965,51 @@ export function filterPromptInputChunk(
|
|
|
751
965
|
}
|
|
752
966
|
out += data.slice(i, i + 3); i += 3; continue;
|
|
753
967
|
}
|
|
754
|
-
|
|
968
|
+
// Un-bracketed multi-line paste guard: some terminals/multiplexers (a raw X11
|
|
969
|
+
// primary-selection middle-click paste, certain SSH clients, or a tmux binding
|
|
970
|
+
// that omits `-p`) deliver a multi-line paste as ONE stdin chunk WITHOUT the
|
|
971
|
+
// bracketed-paste markers this filter otherwise relies on (PASTE_START/PASTE_END
|
|
972
|
+
// above). Outside an active bracketed paste, readline treats every bare `\r`/`\n`
|
|
973
|
+
// as Enter — so an un-bracketed 3-line paste submitted line 1 immediately, mid-
|
|
974
|
+
// composition, and left the rest to leak in as separate prompts (the "붙여넣기가
|
|
975
|
+
// 잘 안됨" bug: works for single-line/bracketed paste, corrupts multi-line paste
|
|
976
|
+
// when the terminal doesn't negotiate DECSET 2004). A genuine Enter keypress is
|
|
977
|
+
// always the LAST byte of its chunk — raw mode delivers one keystroke per read —
|
|
978
|
+
// so a linebreak with MORE data after it in the SAME synchronous chunk can only be
|
|
979
|
+
// a paste (or a scripted multi-line feed); fold it to the sentinel instead of
|
|
980
|
+
// submitting, matching the bracketed-paste contract (review the whole block, then
|
|
981
|
+
// press Enter once to submit). A trailing linebreak (nothing after it) keeps the
|
|
982
|
+
// existing behavior below — including the opt-in lone-LF Shift+Enter rule.
|
|
983
|
+
const bareBreakLen = data.startsWith("\r\n", i) ? 2 : (data[i] === "\r" || data[i] === "\n") ? 1 : 0;
|
|
984
|
+
if (bareBreakLen > 0 && i + bareBreakLen < data.length) { out += MULTILINE_SENTINEL; i += bareBreakLen; continue; }
|
|
985
|
+
// A TRAILING `\r\n` outside a paste is ONE Enter (Windows CRLF clipboard tails,
|
|
986
|
+
// terminals that transmit CRLF): forward a single `\r`. Previously the `\r`
|
|
987
|
+
// submitted and the `\n` leaked into the NEXT prompt as a phantom extra submit
|
|
988
|
+
// (or, with the lone-LF rule, a phantom break) — one of the "붙여넣기가 잘
|
|
989
|
+
// 동작안하는 경우".
|
|
990
|
+
if (data.startsWith("\r\n", i) && i + 2 === data.length) { out += "\r"; i += 2; continue; }
|
|
991
|
+
// Backslash continuation (Claude-Code convention, works on EVERY terminal even
|
|
992
|
+
// without a Shift+Enter protocol — the Windows-safe fallback): Enter on a draft
|
|
993
|
+
// ending in `\` (caret at the end) replaces the backslash with a line break
|
|
994
|
+
// instead of submitting. Emitted as backspace + sentinel so readline's buffer
|
|
995
|
+
// stays the single source of truth.
|
|
996
|
+
if (
|
|
997
|
+
data[i] === "\r" && i + 1 === data.length && i === 0 && rl &&
|
|
998
|
+
(rl.line ?? "").endsWith("\\") &&
|
|
999
|
+
(typeof rl.cursor !== "number" || rl.cursor === (rl.line ?? "").length)
|
|
1000
|
+
) {
|
|
1001
|
+
out += `\u007f${MULTILINE_SENTINEL}`;
|
|
1002
|
+
i += 1;
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
if (env.loneLfShiftEnter && data[i] === "\n") { out += MULTILINE_SENTINEL; i += 1; continue; } // lone LF = Shift+Enter (default-on; JEO_MULTILINE=0 opts out)
|
|
1006
|
+
|
|
755
1007
|
// Ctrl+L (form feed): the prompt redraw hotkey, handled on the process.stdin keypress
|
|
756
1008
|
// listener — never forward it to readline as a literal char.
|
|
757
1009
|
if (data[i] === "\u000c") { i += 1; continue; }
|
|
758
1010
|
out += data[i]; i += 1;
|
|
759
1011
|
}
|
|
1012
|
+
state.crTail = !state.inPaste && out.endsWith("\r");
|
|
760
1013
|
return { out, drop: false };
|
|
761
1014
|
}
|
|
762
1015
|
|
|
@@ -13,7 +13,8 @@ export function hotkeysLines(): string[] {
|
|
|
13
13
|
" Tab complete slash commands, models, roles, @paths",
|
|
14
14
|
" ↑ / ↓ navigate the slash-command preview (Enter runs the highlighted one)",
|
|
15
15
|
" Enter submit input / confirm picker selection",
|
|
16
|
-
"
|
|
16
|
+
" Shift-Enter / Ctrl-J / \\+Enter insert a line break instead of submitting (Alt/Option-Enter and Ctrl-Enter work too)",
|
|
17
|
+
" Esc clear typed input / cancel an open picker",
|
|
17
18
|
" Ctrl-C cancel the in-flight turn (press again at the prompt to exit)",
|
|
18
19
|
" Ctrl-D exit the REPL",
|
|
19
20
|
" Ctrl-O dump the full last response (untruncated, tables rendered) into scrollback",
|
|
@@ -21,9 +22,11 @@ export function hotkeysLines(): string[] {
|
|
|
21
22
|
" Ctrl-K / Ctrl-U / Ctrl-W kill to end / start of line / previous word (emacs kill-ring)",
|
|
22
23
|
" Ctrl-Y / Alt-Y yank / yank-pop the killed text",
|
|
23
24
|
" Ctrl-A / Ctrl-E move to start / end of line",
|
|
25
|
+
" Cmd-← / Cmd-→ (Home/End) jump to start / end of the current row of a multi-line draft",
|
|
26
|
+
" Cmd-↑ / Cmd-↓ (Ctrl-Home/Ctrl-End) jump to start / end of the whole draft",
|
|
24
27
|
" / open the slash-command palette",
|
|
25
28
|
" @path mention a file (Tab completes relative paths)",
|
|
26
|
-
" Ctrl-V paste
|
|
29
|
+
" Ctrl-V paste from the clipboard: an image attaches as [image #N], text inserts at the caret",
|
|
27
30
|
" drag-drop drop an image file onto the box to attach it (its path becomes [image #N])",
|
|
28
31
|
" drag select on-screen text to copy — copies on cmd/ctrl+c; under --tmux a drag auto-copies to the system clipboard",
|
|
29
32
|
" Shift-drag force the terminal's own selection when tmux owns the mouse (iTerm/macOS: Option-drag)",
|
package/src/commands/launch.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { callLlm, type Message } from "../agent/loop";
|
|
|
37
37
|
import { friendlyProviderError } from "../util/provider-error";
|
|
38
38
|
import { readGlobalConfig, saveConfigPatch, resolveWikiRoot } from "../agent/state";
|
|
39
39
|
import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
|
|
40
|
-
import { describeModel, describeAllProviders, thinkingMaxTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId } from "../ai";
|
|
40
|
+
import { describeModel, describeAllProviders, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId } from "../ai";
|
|
41
41
|
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
42
42
|
import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
|
|
43
43
|
|
|
@@ -71,7 +71,7 @@ import { detectColorLevel, ColorLevel, visibleWidth } from "../tui/components/co
|
|
|
71
71
|
import { readClipboardImage } from "../util/clipboard-image";
|
|
72
72
|
import { attachImagePaths, insertImageTag } from "../util/file-attachment";
|
|
73
73
|
import { formatTranscript } from "../tui/components/transcript";
|
|
74
|
-
import { copyTextToClipboard } from "../tui/clipboard";
|
|
74
|
+
import { copyTextToClipboard, readClipboardText } from "../tui/clipboard";
|
|
75
75
|
import { loadInputHistory, appendInputHistory } from "../agent/input-history";
|
|
76
76
|
import type { ImageAttachment } from "../ai/types";
|
|
77
77
|
import { renderMarkdownTables } from "../tui/components/markdown-table";
|
|
@@ -141,6 +141,7 @@ import {
|
|
|
141
141
|
matchTerminalReport,
|
|
142
142
|
stripMouseReports,
|
|
143
143
|
rewriteCursorCombos,
|
|
144
|
+
stripPasteEscapes,
|
|
144
145
|
MULTILINE_SENTINEL,
|
|
145
146
|
isGenuineMultilineDraft,
|
|
146
147
|
shouldBoxVerticalNav,
|
|
@@ -154,6 +155,8 @@ import {
|
|
|
154
155
|
filterPromptInputChunk,
|
|
155
156
|
pasteIdleDecision,
|
|
156
157
|
PASTE_MERGE_IDLE_MS,
|
|
158
|
+
chunkHasCtrlC,
|
|
159
|
+
normalizeKeypress,
|
|
157
160
|
} from "./launch/input";
|
|
158
161
|
import {
|
|
159
162
|
gatedStdout,
|
|
@@ -1021,7 +1024,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1021
1024
|
tools,
|
|
1022
1025
|
maxSteps: flags.maxSteps,
|
|
1023
1026
|
model: activeModel,
|
|
1024
|
-
maxTokens:
|
|
1027
|
+
maxTokens: resolveMaxOutputTokens(activeModel, sessionThinking),
|
|
1025
1028
|
reasoningEffort: sessionThinking ? thinkingToReasoningEffort(sessionThinking) : undefined,
|
|
1026
1029
|
signal: ac.signal,
|
|
1027
1030
|
steer: drainSteer,
|
|
@@ -1040,7 +1043,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1040
1043
|
maxSteps: Math.min(6, flags.maxSteps > 0 ? flags.maxSteps : 6),
|
|
1041
1044
|
budget: { maxExtensions: 0 },
|
|
1042
1045
|
model: activeModel,
|
|
1043
|
-
maxTokens:
|
|
1046
|
+
maxTokens: resolveMaxOutputTokens(activeModel, sessionThinking),
|
|
1044
1047
|
reasoningEffort: sessionThinking ? thinkingToReasoningEffort(sessionThinking) : undefined,
|
|
1045
1048
|
signal: ac.signal,
|
|
1046
1049
|
steer: drainSteer,
|
|
@@ -1499,11 +1502,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1499
1502
|
const SENTINEL = MULTILINE_SENTINEL;
|
|
1500
1503
|
// Multi-line input filter is ON for any interactive TTY: reliable multi-line paste
|
|
1501
1504
|
// (fills the box, submits intact into the user card) is the default. The lone-"\n"
|
|
1502
|
-
//
|
|
1503
|
-
// `
|
|
1504
|
-
//
|
|
1505
|
+
// break rule is ALSO default-on (JEO_MULTILINE=0 opts out): every real terminal
|
|
1506
|
+
// sends `\r` for Enter in raw mode, so a bare `\n` can only come from Ctrl+J,
|
|
1507
|
+
// Ctrl+Enter (Windows Terminal / conhost), or a ghostty `shift+enter=text:\n`
|
|
1508
|
+
// keybind — all of which MEAN "insert a line break". This is what makes a break
|
|
1509
|
+
// key available on Windows terminals that support neither the kitty keyboard
|
|
1510
|
+
// protocol nor xterm modifyOtherKeys. JEO_NO_MULTILINE=1 fully disables the
|
|
1511
|
+
// filter (reads stdin directly).
|
|
1505
1512
|
const multilineInput = !!process.stdin.isTTY && jeoEnv("NO_MULTILINE") !== "1";
|
|
1506
|
-
const loneLfShiftEnter = jeoEnv("MULTILINE")
|
|
1513
|
+
const loneLfShiftEnter = jeoEnv("MULTILINE") !== "0";
|
|
1507
1514
|
const expandSentinel = (s: string): string => (multilineInput ? s.split(SENTINEL).join("\n") : s);
|
|
1508
1515
|
// Prompt-scoped process listeners (stdin data/keypress, stdout resize). Registered
|
|
1509
1516
|
// once per launch but previously anonymous and never removed — benign for a single
|
|
@@ -1902,12 +1909,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1902
1909
|
// the prompt never carries a trailing/floating blank block.
|
|
1903
1910
|
const setFooterRows = (n: number) => {
|
|
1904
1911
|
n = Math.max(1, Math.min(n, footerRows));
|
|
1912
|
+
|
|
1905
1913
|
if (!previewArmed || n === footerRendered) return;
|
|
1914
|
+
|
|
1906
1915
|
let s = footerParkedRow > 0 ? cursorUp(footerParkedRow) : "";
|
|
1907
1916
|
s += toColumn(1) + clearToEnd(); // wipe old region; cursor now at its top (after output)
|
|
1908
1917
|
if (n > 1) s += "\n".repeat(n - 1) + cursorUp(n - 1);
|
|
1909
1918
|
s += toColumn(1);
|
|
1919
|
+
|
|
1910
1920
|
out.write(s);
|
|
1921
|
+
|
|
1911
1922
|
footerRendered = n;
|
|
1912
1923
|
footerParkedRow = 0;
|
|
1913
1924
|
lastFooterKey = ""; // force a full repaint into the resized region
|
|
@@ -2112,6 +2123,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2112
2123
|
// Re-pin the reservation to the height the latest preview/panel wants (compact at
|
|
2113
2124
|
// idle, grown for a dropdown) BEFORE painting, so no reserved blank trails the prompt.
|
|
2114
2125
|
setFooterRows(footerWantRows);
|
|
2126
|
+
|
|
2115
2127
|
// ALWAYS paint exactly footerRendered rows so the reservation is fully covered
|
|
2116
2128
|
// and no row can spill past it — the bug fix that kept `@folder<more text>`
|
|
2117
2129
|
// typing from scrolling the input box (and prior output) off the top.
|
|
@@ -2186,7 +2198,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2186
2198
|
const clearTypedInput = (): boolean => {
|
|
2187
2199
|
const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
|
|
2188
2200
|
const hadPastedQueue = queuedPromptInput.pastedLines.length > 0;
|
|
2201
|
+
|
|
2189
2202
|
if (!promptHasContent()) return false;
|
|
2203
|
+
|
|
2190
2204
|
// ESC is the escape hatch for an accidental giant paste: drop the queued batch.
|
|
2191
2205
|
if (hadPastedQueue) {
|
|
2192
2206
|
const dropped = queuedPromptInput.pastedLines.splice(0).length;
|
|
@@ -2231,7 +2245,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2231
2245
|
};
|
|
2232
2246
|
const handleCtrlCByte = (chunk: string | Uint8Array) => {
|
|
2233
2247
|
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
2234
|
-
|
|
2248
|
+
// chunkHasCtrlC also matches the kitty CSI-u (`CSI 99;5u`) and xterm
|
|
2249
|
+
// modifyOtherKeys (`CSI 27;5;99~`) encodings jeo's protocol push provokes.
|
|
2250
|
+
if (chunkHasCtrlC(text)) handleCtrlC();
|
|
2235
2251
|
};
|
|
2236
2252
|
// Bun/readline can deliver Ctrl+C as readline SIGINT, process SIGINT, or (tmux)
|
|
2237
2253
|
// a raw \u0003 byte before readline resolves the question; funnel all three through
|
|
@@ -2311,8 +2327,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2311
2327
|
process.stdout.on("resize", onPickerResize);
|
|
2312
2328
|
try {
|
|
2313
2329
|
await new Promise<void>(resolve => {
|
|
2314
|
-
const handler = (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean } | undefined) => {
|
|
2315
|
-
|
|
2330
|
+
const handler = (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean; sequence?: string } | undefined) => {
|
|
2331
|
+
// Same normalization as the footer handler: kitty CSI-u Esc/Ctrl+C must
|
|
2332
|
+
// close a picker exactly like their legacy encodings.
|
|
2333
|
+
const done = onKey(ch, normalizeKeypress(key));
|
|
2316
2334
|
if (done) {
|
|
2317
2335
|
process.stdin.off("keypress", handler);
|
|
2318
2336
|
clear();
|
|
@@ -2727,11 +2745,30 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2727
2745
|
|
|
2728
2746
|
if (previewEnabled) {
|
|
2729
2747
|
process.once("exit", () => out.write("\x1b[?25h")); // safety net: never leave the cursor hidden
|
|
2730
|
-
const footerKeypressHandler = (_ch: string,
|
|
2748
|
+
const footerKeypressHandler = (_ch: string, rawKey: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean; sequence?: string } | undefined) => {
|
|
2749
|
+
// Kitty CSI-u / xterm modifyOtherKeys events arrive with `name: undefined`
|
|
2750
|
+
// (Node's keypress table predates those protocols) — normalize them back to
|
|
2751
|
+
// the `{ name, ctrl, meta }` shape every check below matches on. Without this
|
|
2752
|
+
// Esc and Ctrl+C are DEAD at the idle prompt on kitty-protocol terminals.
|
|
2753
|
+
const key = normalizeKeypress(rawKey);
|
|
2731
2754
|
if (key?.ctrl && key.name === "c") {
|
|
2732
2755
|
handleCtrlC();
|
|
2733
2756
|
return;
|
|
2734
2757
|
}
|
|
2758
|
+
// ESC (or a meta-mapped Cmd+C) at the prompt: wipe the typed text. Checked HERE,
|
|
2759
|
+
// before the `!previewArmed || pickerActive` gate below — mirroring Ctrl+C right
|
|
2760
|
+
// above, which already bypasses that gate. Without this, a transient window where
|
|
2761
|
+
// `previewArmed` is momentarily false (the same class of race Ctrl+C was already
|
|
2762
|
+
// immune to) silently swallowed the ESC keypress: the box stayed unaffected and
|
|
2763
|
+
// `clearTypedInput()` never ran (the "esc 입력 후 안 먹힘" bug — Esc after typing
|
|
2764
|
+
// intermittently did nothing). `clearTypedInput()` itself already no-ops safely
|
|
2765
|
+
// when there is nothing to clear or no armed box to redraw into, so running it
|
|
2766
|
+
// unconditionally here is safe even mid-picker or pre-arm.
|
|
2767
|
+
if (key && ((key.name === "escape" && !key.ctrl) || (key.meta && key.name === "c"))) {
|
|
2768
|
+
clearTypedInput();
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2735
2772
|
if (!previewArmed || pickerActive) return;
|
|
2736
2773
|
// Drag-and-drop file attach: a terminal delivers a dropped file as its PATH typed
|
|
2737
2774
|
// into the box, wrapped in a bracketed paste. On paste-end, swap any readable image
|
|
@@ -2806,27 +2843,43 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2806
2843
|
promptHistoryLines = null;
|
|
2807
2844
|
drawFooter(previewLines(typedLine, navIdx));
|
|
2808
2845
|
}
|
|
2809
|
-
// Ctrl+V: attach a clipboard IMAGE to the next message
|
|
2810
|
-
//
|
|
2811
|
-
//
|
|
2846
|
+
// Ctrl+V: attach a clipboard IMAGE to the next message; when the clipboard
|
|
2847
|
+
// holds no image, fall back to pasting its TEXT at the caret. Terminal-level
|
|
2848
|
+
// paste (Cmd+V / Ctrl+Shift+V) still streams through stdin as before — this
|
|
2849
|
+
// binding covers the terminals/users where that path doesn't fire (the
|
|
2850
|
+
// "복사붙여넣기가 잘 동작안하는 경우"): previously a text-only clipboard made
|
|
2851
|
+
// Ctrl+V a SILENT no-op.
|
|
2812
2852
|
if (key?.ctrl && key.name === "v") {
|
|
2813
2853
|
if (pasteInFlight) return;
|
|
2814
2854
|
pasteInFlight = true;
|
|
2815
2855
|
void (async () => {
|
|
2816
2856
|
try {
|
|
2817
|
-
const img = await readClipboardImage();
|
|
2818
|
-
if (!img) return;
|
|
2819
|
-
pendingImages.push(img);
|
|
2820
2857
|
const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
|
|
2821
|
-
const
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2858
|
+
const img = await readClipboardImage();
|
|
2859
|
+
if (img) {
|
|
2860
|
+
pendingImages.push(img);
|
|
2861
|
+
const at = typeof rli.cursor === "number" ? rli.cursor : rli.line.length;
|
|
2862
|
+
// Insert the [image #N] tag at the caret with normalized single-space
|
|
2863
|
+
// padding (shared with the drag-drop path) so the caret lands right after
|
|
2864
|
+
// the tag instead of being pushed several columns by stray spaces.
|
|
2865
|
+
const ins = insertImageTag(rli.line, at, pendingImages.length);
|
|
2866
|
+
rli.line = ins.text;
|
|
2867
|
+
rli.cursor = ins.cursor;
|
|
2868
|
+
} else {
|
|
2869
|
+
const clip = await readClipboardText();
|
|
2870
|
+
if (!clip) return;
|
|
2871
|
+
// Same sanitation as a bracketed paste: strip ANSI escapes/C0 noise,
|
|
2872
|
+
// fold line breaks to the multi-line sentinel (or spaces when the
|
|
2873
|
+
// filter is off) so a multi-line clipboard cannot self-submit.
|
|
2874
|
+
const cleaned = stripPasteEscapes(clip.replace(/\r\n/g, "\n").replace(/\r/g, "\n"))
|
|
2875
|
+
.split("\n").join(multilineInput ? SENTINEL : " ");
|
|
2876
|
+
if (!cleaned) return;
|
|
2877
|
+
const at = typeof rli.cursor === "number" ? rli.cursor : rli.line.length;
|
|
2878
|
+
rli.line = rli.line.slice(0, at) + cleaned + rli.line.slice(at);
|
|
2879
|
+
rli.cursor = at + cleaned.length;
|
|
2880
|
+
}
|
|
2828
2881
|
typedLine = rli.line;
|
|
2829
|
-
// Sync readline's internal screen model to the injected
|
|
2882
|
+
// Sync readline's internal screen model to the injected text (same as the
|
|
2830
2883
|
// slash/tab completion accept paths): without it readline's stale row/cursor
|
|
2831
2884
|
// model offsets the real caret several columns on the next keystroke — the
|
|
2832
2885
|
// "caret pushed past the tag after attaching an image" shift.
|
|
@@ -2838,20 +2891,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2838
2891
|
})();
|
|
2839
2892
|
return;
|
|
2840
2893
|
}
|
|
2841
|
-
// ESC (or a meta-mapped Cmd+C) at the prompt: wipe the typed text. A bare
|
|
2842
|
-
// ESC decodes as `escape` (meta is set for a lone ESC byte — accept both)
|
|
2843
|
-
// only after readline's escape-sequence timeout, so arrow/wheel sequences
|
|
2844
|
-
// never trigger this. Checked BEFORE the `previewPending` reentrancy gate
|
|
2845
|
-
// (like Ctrl+C above) — a fast typist/paste can deliver several keypress
|
|
2846
|
-
// events in ONE synchronous stdin read, all before the first keystroke's
|
|
2847
|
-
// setImmediate fires; gating this after `previewPending` silently dropped
|
|
2848
|
-
// an ESC that lands in the same burst as preceding keystrokes (the
|
|
2849
|
-
// "esc 입력 후 안 먹힘" bug — Esc right after typing intermittently did nothing).
|
|
2850
|
-
if (key && ((key.name === "escape" && !key.ctrl) || (key.meta && key.name === "c"))) {
|
|
2851
|
-
clearTypedInput();
|
|
2852
|
-
return;
|
|
2853
|
-
}
|
|
2854
2894
|
if (previewPending) return;
|
|
2895
|
+
|
|
2855
2896
|
// Ctrl+C is handled above (clear-or-exit); keep this guard for defensive ordering only.
|
|
2856
2897
|
if (key?.ctrl && key.name === "c") return;
|
|
2857
2898
|
|
package/src/tui/clipboard.ts
CHANGED
|
@@ -62,6 +62,55 @@ export function systemClipboardCopyCommand(
|
|
|
62
62
|
return null;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the local system-clipboard READ command for a platform, or null when no
|
|
67
|
+
* tool is available — the mirror of {@link systemClipboardCopyCommand} for the
|
|
68
|
+
* Ctrl+V text-paste fallback. Pure (the `which` probe is injected) for testability.
|
|
69
|
+
* - macOS: `pbpaste`.
|
|
70
|
+
* - Windows: PowerShell `Get-Clipboard` (`-Raw` keeps embedded newlines intact).
|
|
71
|
+
* - Linux/BSD: Wayland `wl-paste` first, then X11 `xclip -o`, then `xsel`.
|
|
72
|
+
*/
|
|
73
|
+
export function systemClipboardPasteCommand(
|
|
74
|
+
platform: NodeJS.Platform,
|
|
75
|
+
which: (bin: string) => string | null,
|
|
76
|
+
): string[] | null {
|
|
77
|
+
if (platform === "darwin") return which("pbpaste") ? ["pbpaste"] : null;
|
|
78
|
+
if (platform === "win32") {
|
|
79
|
+
return ["powershell", "-NoProfile", "-Command", "Get-Clipboard -Raw"];
|
|
80
|
+
}
|
|
81
|
+
if (which("wl-paste")) return ["wl-paste", "--no-newline"];
|
|
82
|
+
if (which("xclip")) return ["xclip", "-selection", "clipboard", "-o"];
|
|
83
|
+
if (which("xsel")) return ["xsel", "--clipboard", "--output"];
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read TEXT from the system clipboard, or null when empty / no tool / the read
|
|
89
|
+
* failed. Never throws; capped at `timeoutMs` so a hung clipboard tool (remote
|
|
90
|
+
* X11, sandboxed PowerShell) cannot freeze the prompt. Used as the Ctrl+V
|
|
91
|
+
* fallback when the clipboard holds no image — previously that combination was
|
|
92
|
+
* a SILENT no-op (one of the "복사붙여넣기가 잘 동작안하는 경우").
|
|
93
|
+
*/
|
|
94
|
+
export async function readClipboardText(
|
|
95
|
+
deps: { which?: (bin: string) => string | null; platform?: NodeJS.Platform } = {},
|
|
96
|
+
timeoutMs = 4000,
|
|
97
|
+
): Promise<string | null> {
|
|
98
|
+
const platform = deps.platform ?? process.platform;
|
|
99
|
+
const which = deps.which ?? ((bin: string) => Bun.which(bin));
|
|
100
|
+
const cmd = systemClipboardPasteCommand(platform, which);
|
|
101
|
+
if (!cmd) return null;
|
|
102
|
+
try {
|
|
103
|
+
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore" });
|
|
104
|
+
const timer = setTimeout(() => { try { proc.kill(); } catch { /* already gone */ } }, timeoutMs);
|
|
105
|
+
const text = await new Response(proc.stdout).text();
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
if ((await proc.exited) !== 0) return null;
|
|
108
|
+
return text.length > 0 ? text : null;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
65
114
|
/**
|
|
66
115
|
* The single shell command string tmux's `copy-command` option runs to push a
|
|
67
116
|
* copy-mode selection onto the system clipboard, or null when no tool exists.
|
package/src/tui/terminal.ts
CHANGED
|
@@ -130,8 +130,10 @@ export function enableKittyKeyboard(): string {
|
|
|
130
130
|
return `${ESC}>1u`;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
/**
|
|
134
|
-
*
|
|
133
|
+
/** Undo {@link enableKittyKeyboard} on exit. `CSI > 1 u` PUSHES an entry onto the
|
|
134
|
+
* terminal's keyboard-mode stack, so the counterpart is a POP (`CSI < u`) — pushing
|
|
135
|
+
* `>0u` instead would leak our entry and leave flags-0 stacked on top of whatever
|
|
136
|
+
* the shell/tmux had. Terminals without kitty-protocol support ignore both. */
|
|
135
137
|
export function disableKittyKeyboard(): string {
|
|
136
|
-
return `${ESC}
|
|
138
|
+
return `${ESC}<u`;
|
|
137
139
|
}
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* (carrying `.status` and a body), and the agent loop surfaces failures both as a
|
|
5
5
|
* thrown error and as a `doneReason`, so this lives in a shared util used by both.
|
|
6
6
|
*/
|
|
7
|
-
import { isUsageLimitError } from "./retry";
|
|
7
|
+
import { isUsageLimitError, isRefusalError } from "./retry";
|
|
8
|
+
// Re-export: engine.ts and callers import the refusal predicate from here; the
|
|
9
|
+
// definition lives in retry.ts so defaultRetryable can fail fast without a cycle.
|
|
10
|
+
export { isRefusalError } from "./retry";
|
|
8
11
|
|
|
9
12
|
/** Provider-authoritative context-overflow signal (HTTP 400/413 family). The
|
|
10
13
|
* local token estimate can drift under the real count (images, tokenizer
|
|
@@ -18,17 +21,6 @@ export function isContextOverflowError(err: unknown): boolean {
|
|
|
18
21
|
return status === 413; // payload-too-large is always an overflow signal
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
/** Provider safety-refusal signal: an HTTP-200 completion that returned NO
|
|
22
|
-
* content because the model/provider declined (Anthropic `stop_reason=refusal`,
|
|
23
|
-
* OpenAI `finish_reason=content_filter`, Gemini `SAFETY`/`PROHIBITED_CONTENT`
|
|
24
|
-
* block reasons). On routine coding work these are usually transient
|
|
25
|
-
* false-positives — the engine retries the step (bounded) instead of killing
|
|
26
|
-
* the turn with a dead "Error: … returned no content". */
|
|
27
|
-
export function isRefusalError(err: unknown): boolean {
|
|
28
|
-
const msg = (err as Error)?.message ?? String(err);
|
|
29
|
-
return /stop_reason=refusal|finish_reason=content_filter|\(content_filter\)|\(SAFETY\)|\(PROHIBITED_CONTENT\)|\(BLOCKLIST\)/i.test(msg);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
24
|
function formatDuration(ms: number): string {
|
|
33
25
|
const totalSeconds = Math.ceil(ms / 1000);
|
|
34
26
|
if (totalSeconds < 60) return `${totalSeconds}s`;
|
package/src/util/retry.ts
CHANGED
|
@@ -38,6 +38,14 @@ export function defaultRetryable(err: unknown): boolean {
|
|
|
38
38
|
if (isUsageLimitError(err)) {
|
|
39
39
|
return false;
|
|
40
40
|
}
|
|
41
|
+
// Safety refusals are DETERMINISTIC for identical conversation content: the
|
|
42
|
+
// classifier trips on what's IN the context, so a transport-level resend of the
|
|
43
|
+
// same payload re-refuses every time (observed: each engine ladder rung burned
|
|
44
|
+
// 2 extra billed calls before this check). Fail fast — the engine's refusal
|
|
45
|
+
// ladder owns recovery because it mutates the context between attempts.
|
|
46
|
+
if (isRefusalError(err)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
41
49
|
|
|
42
50
|
let message = "";
|
|
43
51
|
if (err instanceof Error) {
|
|
@@ -206,3 +214,15 @@ const USAGE_LIMIT_PATTERN = /usage.?limit|usage_limit_reached|usage_not_included
|
|
|
206
214
|
export function isUsageLimitError(err: unknown): boolean {
|
|
207
215
|
return USAGE_LIMIT_PATTERN.test(errorMessageOf(err));
|
|
208
216
|
}
|
|
217
|
+
|
|
218
|
+
/** Provider safety-refusal signal: an HTTP-200 completion that returned NO
|
|
219
|
+
* content because the model/provider declined (Anthropic `stop_reason=refusal`,
|
|
220
|
+
* OpenAI `finish_reason=content_filter`, Gemini `SAFETY`/`PROHIBITED_CONTENT`
|
|
221
|
+
* block reasons). Lives here (not provider-error.ts) so `defaultRetryable` can
|
|
222
|
+
* fail fast on it: a refusal is DETERMINISTIC for the same conversation content —
|
|
223
|
+
* transport-level resends of an identical payload just burn billed calls. The
|
|
224
|
+
* engine's bounded refusal ladder (resend → context reset → guidance strip) is
|
|
225
|
+
* the correct recovery layer because it MUTATES the context between attempts. */
|
|
226
|
+
export function isRefusalError(err: unknown): boolean {
|
|
227
|
+
return /stop_reason=refusal|finish_reason=content_filter|\(content_filter\)|\(SAFETY\)|\(PROHIBITED_CONTENT\)|\(BLOCKLIST\)/i.test(errorMessageOf(err));
|
|
228
|
+
}
|