jeo-code 0.7.26 → 0.7.28
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 +38 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/compaction.ts +24 -0
- package/src/agent/config-schema.ts +1 -0
- package/src/agent/engine.ts +20 -5
- package/src/agent/loop.ts +3 -0
- package/src/agent/state.ts +84 -5
- package/src/agent/task-tool.ts +5 -2
- package/src/ai/model-catalog.ts +23 -0
- package/src/ai/model-discovery.ts +6 -2
- package/src/ai/model-manager.ts +60 -13
- package/src/ai/providers/anthropic.ts +62 -18
- package/src/ai/providers/antigravity.ts +34 -22
- package/src/ai/providers/gemini.ts +81 -27
- package/src/ai/providers/kimi.ts +44 -5
- package/src/ai/providers/openai-responses.ts +36 -2
- package/src/ai/types.ts +10 -0
- package/src/auth/callback-server.ts +19 -5
- package/src/auth/flows/AGENTS.md +7 -6
- package/src/auth/flows/antigravity.ts +3 -0
- package/src/auth/flows/google-project.ts +8 -2
- package/src/auth/flows/google.ts +15 -1
- package/src/auth/flows/index.ts +10 -0
- package/src/auth/flows/kimi.ts +263 -0
- package/src/auth/flows/openai.ts +103 -3
- package/src/auth/oauth.ts +9 -0
- package/src/auth/refresh.ts +6 -1
- package/src/auth/storage.ts +5 -5
- package/src/commands/launch/input.ts +244 -9
- package/src/commands/launch/slash-views.ts +5 -2
- package/src/commands/launch.ts +64 -28
- package/src/commands/team.ts +3 -0
- package/src/tui/clipboard.ts +49 -0
- package/src/tui/components/welcome.ts +98 -39
- package/src/tui/terminal.ts +5 -3
- package/src/util/provider-error.ts +4 -12
- package/src/util/retry.ts +88 -31
|
@@ -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).
|
|
@@ -768,13 +982,34 @@ export function filterPromptInputChunk(
|
|
|
768
982
|
// existing behavior below — including the opt-in lone-LF Shift+Enter rule.
|
|
769
983
|
const bareBreakLen = data.startsWith("\r\n", i) ? 2 : (data[i] === "\r" || data[i] === "\n") ? 1 : 0;
|
|
770
984
|
if (bareBreakLen > 0 && i + bareBreakLen < data.length) { out += MULTILINE_SENTINEL; i += bareBreakLen; continue; }
|
|
771
|
-
|
|
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)
|
|
772
1006
|
|
|
773
1007
|
// Ctrl+L (form feed): the prompt redraw hotkey, handled on the process.stdin keypress
|
|
774
1008
|
// listener — never forward it to readline as a literal char.
|
|
775
1009
|
if (data[i] === "\u000c") { i += 1; continue; }
|
|
776
1010
|
out += data[i]; i += 1;
|
|
777
1011
|
}
|
|
1012
|
+
state.crTail = !state.inPaste && out.endsWith("\r");
|
|
778
1013
|
return { out, drop: false };
|
|
779
1014
|
}
|
|
780
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,9 +1024,10 @@ 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,
|
|
1030
|
+
sessionKey: sessionId,
|
|
1027
1031
|
steer: drainSteer,
|
|
1028
1032
|
events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
|
|
1029
1033
|
});
|
|
@@ -1040,9 +1044,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1040
1044
|
maxSteps: Math.min(6, flags.maxSteps > 0 ? flags.maxSteps : 6),
|
|
1041
1045
|
budget: { maxExtensions: 0 },
|
|
1042
1046
|
model: activeModel,
|
|
1043
|
-
maxTokens:
|
|
1047
|
+
maxTokens: resolveMaxOutputTokens(activeModel, sessionThinking),
|
|
1044
1048
|
reasoningEffort: sessionThinking ? thinkingToReasoningEffort(sessionThinking) : undefined,
|
|
1045
1049
|
signal: ac.signal,
|
|
1050
|
+
sessionKey: sessionId,
|
|
1046
1051
|
steer: drainSteer,
|
|
1047
1052
|
events: wrapEvents(withToolDetailCapture(tui ? tui.events() : streamEvents), opik),
|
|
1048
1053
|
});
|
|
@@ -1499,11 +1504,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1499
1504
|
const SENTINEL = MULTILINE_SENTINEL;
|
|
1500
1505
|
// Multi-line input filter is ON for any interactive TTY: reliable multi-line paste
|
|
1501
1506
|
// (fills the box, submits intact into the user card) is the default. The lone-"\n"
|
|
1502
|
-
//
|
|
1503
|
-
// `
|
|
1504
|
-
//
|
|
1507
|
+
// break rule is ALSO default-on (JEO_MULTILINE=0 opts out): every real terminal
|
|
1508
|
+
// sends `\r` for Enter in raw mode, so a bare `\n` can only come from Ctrl+J,
|
|
1509
|
+
// Ctrl+Enter (Windows Terminal / conhost), or a ghostty `shift+enter=text:\n`
|
|
1510
|
+
// keybind — all of which MEAN "insert a line break". This is what makes a break
|
|
1511
|
+
// key available on Windows terminals that support neither the kitty keyboard
|
|
1512
|
+
// protocol nor xterm modifyOtherKeys. JEO_NO_MULTILINE=1 fully disables the
|
|
1513
|
+
// filter (reads stdin directly).
|
|
1505
1514
|
const multilineInput = !!process.stdin.isTTY && jeoEnv("NO_MULTILINE") !== "1";
|
|
1506
|
-
const loneLfShiftEnter = jeoEnv("MULTILINE")
|
|
1515
|
+
const loneLfShiftEnter = jeoEnv("MULTILINE") !== "0";
|
|
1507
1516
|
const expandSentinel = (s: string): string => (multilineInput ? s.split(SENTINEL).join("\n") : s);
|
|
1508
1517
|
// Prompt-scoped process listeners (stdin data/keypress, stdout resize). Registered
|
|
1509
1518
|
// once per launch but previously anonymous and never removed — benign for a single
|
|
@@ -2238,7 +2247,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2238
2247
|
};
|
|
2239
2248
|
const handleCtrlCByte = (chunk: string | Uint8Array) => {
|
|
2240
2249
|
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
2241
|
-
|
|
2250
|
+
// chunkHasCtrlC also matches the kitty CSI-u (`CSI 99;5u`) and xterm
|
|
2251
|
+
// modifyOtherKeys (`CSI 27;5;99~`) encodings jeo's protocol push provokes.
|
|
2252
|
+
if (chunkHasCtrlC(text)) handleCtrlC();
|
|
2242
2253
|
};
|
|
2243
2254
|
// Bun/readline can deliver Ctrl+C as readline SIGINT, process SIGINT, or (tmux)
|
|
2244
2255
|
// a raw \u0003 byte before readline resolves the question; funnel all three through
|
|
@@ -2318,8 +2329,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2318
2329
|
process.stdout.on("resize", onPickerResize);
|
|
2319
2330
|
try {
|
|
2320
2331
|
await new Promise<void>(resolve => {
|
|
2321
|
-
const handler = (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean } | undefined) => {
|
|
2322
|
-
|
|
2332
|
+
const handler = (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean; sequence?: string } | undefined) => {
|
|
2333
|
+
// Same normalization as the footer handler: kitty CSI-u Esc/Ctrl+C must
|
|
2334
|
+
// close a picker exactly like their legacy encodings.
|
|
2335
|
+
const done = onKey(ch, normalizeKeypress(key));
|
|
2323
2336
|
if (done) {
|
|
2324
2337
|
process.stdin.off("keypress", handler);
|
|
2325
2338
|
clear();
|
|
@@ -2682,7 +2695,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2682
2695
|
// TTY only — the caller prints scriptable guidance otherwise.
|
|
2683
2696
|
const pickApiKeyProvider = async (statuses: Awaited<ReturnType<typeof describeAllProviders>>): Promise<AuthProvider | undefined> => {
|
|
2684
2697
|
const subs = new Set<string>(SUBSCRIPTION_PROVIDER_NAMES); // surfaced under OAuth/subscription login instead
|
|
2685
|
-
|
|
2698
|
+
// kimi is dual-credential (moonshot API key OR Kimi Code OAuth) — keep it key-settable.
|
|
2699
|
+
const keyed = new Set<string>([...API_KEY_ONLY_PROVIDERS, "kimi"]);
|
|
2686
2700
|
const list = apiKeyPicker(statuses.filter(s => keyed.has(s.name) && !subs.has(s.name)), true);
|
|
2687
2701
|
let chosen: ProviderName | undefined;
|
|
2688
2702
|
await runSelectPicker(
|
|
@@ -2734,7 +2748,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2734
2748
|
|
|
2735
2749
|
if (previewEnabled) {
|
|
2736
2750
|
process.once("exit", () => out.write("\x1b[?25h")); // safety net: never leave the cursor hidden
|
|
2737
|
-
const footerKeypressHandler = (_ch: string,
|
|
2751
|
+
const footerKeypressHandler = (_ch: string, rawKey: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean; sequence?: string } | undefined) => {
|
|
2752
|
+
// Kitty CSI-u / xterm modifyOtherKeys events arrive with `name: undefined`
|
|
2753
|
+
// (Node's keypress table predates those protocols) — normalize them back to
|
|
2754
|
+
// the `{ name, ctrl, meta }` shape every check below matches on. Without this
|
|
2755
|
+
// Esc and Ctrl+C are DEAD at the idle prompt on kitty-protocol terminals.
|
|
2756
|
+
const key = normalizeKeypress(rawKey);
|
|
2738
2757
|
if (key?.ctrl && key.name === "c") {
|
|
2739
2758
|
handleCtrlC();
|
|
2740
2759
|
return;
|
|
@@ -2827,27 +2846,43 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2827
2846
|
promptHistoryLines = null;
|
|
2828
2847
|
drawFooter(previewLines(typedLine, navIdx));
|
|
2829
2848
|
}
|
|
2830
|
-
// Ctrl+V: attach a clipboard IMAGE to the next message
|
|
2831
|
-
//
|
|
2832
|
-
//
|
|
2849
|
+
// Ctrl+V: attach a clipboard IMAGE to the next message; when the clipboard
|
|
2850
|
+
// holds no image, fall back to pasting its TEXT at the caret. Terminal-level
|
|
2851
|
+
// paste (Cmd+V / Ctrl+Shift+V) still streams through stdin as before — this
|
|
2852
|
+
// binding covers the terminals/users where that path doesn't fire (the
|
|
2853
|
+
// "복사붙여넣기가 잘 동작안하는 경우"): previously a text-only clipboard made
|
|
2854
|
+
// Ctrl+V a SILENT no-op.
|
|
2833
2855
|
if (key?.ctrl && key.name === "v") {
|
|
2834
2856
|
if (pasteInFlight) return;
|
|
2835
2857
|
pasteInFlight = true;
|
|
2836
2858
|
void (async () => {
|
|
2837
2859
|
try {
|
|
2838
|
-
const img = await readClipboardImage();
|
|
2839
|
-
if (!img) return;
|
|
2840
|
-
pendingImages.push(img);
|
|
2841
2860
|
const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
|
|
2842
|
-
const
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2861
|
+
const img = await readClipboardImage();
|
|
2862
|
+
if (img) {
|
|
2863
|
+
pendingImages.push(img);
|
|
2864
|
+
const at = typeof rli.cursor === "number" ? rli.cursor : rli.line.length;
|
|
2865
|
+
// Insert the [image #N] tag at the caret with normalized single-space
|
|
2866
|
+
// padding (shared with the drag-drop path) so the caret lands right after
|
|
2867
|
+
// the tag instead of being pushed several columns by stray spaces.
|
|
2868
|
+
const ins = insertImageTag(rli.line, at, pendingImages.length);
|
|
2869
|
+
rli.line = ins.text;
|
|
2870
|
+
rli.cursor = ins.cursor;
|
|
2871
|
+
} else {
|
|
2872
|
+
const clip = await readClipboardText();
|
|
2873
|
+
if (!clip) return;
|
|
2874
|
+
// Same sanitation as a bracketed paste: strip ANSI escapes/C0 noise,
|
|
2875
|
+
// fold line breaks to the multi-line sentinel (or spaces when the
|
|
2876
|
+
// filter is off) so a multi-line clipboard cannot self-submit.
|
|
2877
|
+
const cleaned = stripPasteEscapes(clip.replace(/\r\n/g, "\n").replace(/\r/g, "\n"))
|
|
2878
|
+
.split("\n").join(multilineInput ? SENTINEL : " ");
|
|
2879
|
+
if (!cleaned) return;
|
|
2880
|
+
const at = typeof rli.cursor === "number" ? rli.cursor : rli.line.length;
|
|
2881
|
+
rli.line = rli.line.slice(0, at) + cleaned + rli.line.slice(at);
|
|
2882
|
+
rli.cursor = at + cleaned.length;
|
|
2883
|
+
}
|
|
2849
2884
|
typedLine = rli.line;
|
|
2850
|
-
// Sync readline's internal screen model to the injected
|
|
2885
|
+
// Sync readline's internal screen model to the injected text (same as the
|
|
2851
2886
|
// slash/tab completion accept paths): without it readline's stale row/cursor
|
|
2852
2887
|
// model offsets the real caret several columns on the next keystroke — the
|
|
2853
2888
|
// "caret pushed past the tag after attaching an image" shift.
|
|
@@ -3661,7 +3696,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3661
3696
|
// `/provider key [name] [key]` → store an API key for an API-key-only provider
|
|
3662
3697
|
// (groq/deepseek/mistral/…). Interactive: pick the provider, then paste the key.
|
|
3663
3698
|
if (name === "key") {
|
|
3664
|
-
|
|
3699
|
+
// kimi is dual-credential (moonshot API key OR Kimi Code OAuth) — keep it key-settable.
|
|
3700
|
+
const keyed = new Set<string>([...API_KEY_ONLY_PROVIDERS, "kimi"]);
|
|
3665
3701
|
let target = tokens.slice(1).map(t => t.toLowerCase()).find(t => keyed.has(t)) as AuthProvider | undefined;
|
|
3666
3702
|
// A trailing token after the provider name is treated as the key itself.
|
|
3667
3703
|
const inlineKey = target ? tokens.slice(1).filter(t => t.toLowerCase() !== target).pop() : undefined;
|
package/src/commands/team.ts
CHANGED
|
@@ -437,6 +437,9 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
|
|
|
437
437
|
cwd: ctx.cwd,
|
|
438
438
|
model,
|
|
439
439
|
maxSteps,
|
|
440
|
+
// Per-run prompt-cache key: each worker replays its own growing history every
|
|
441
|
+
// step, so a stable per-run key gets provider cache hits (gjc sub-session parity).
|
|
442
|
+
sessionKey: crypto.randomUUID(),
|
|
440
443
|
// Bounded delegation: ralph/team subagents keep an exact step contract; the
|
|
441
444
|
// orchestrator owns retries, so the gjc step-extension flow is disabled here.
|
|
442
445
|
budget: { maxExtensions: 0 },
|
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.
|