claude-overnight 1.25.47 → 1.25.48

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/dist/cli/cli.d.ts CHANGED
@@ -9,32 +9,11 @@ import { isJWTAuthError } from "../core/auth.js";
9
9
  export declare const isAuthError: typeof isJWTAuthError;
10
10
  export { isJWTAuthError };
11
11
  export declare function fetchModels(timeoutMs?: number): Promise<ModelInfo[]>;
12
- export declare const PASTE_START = "\u001B[200~";
13
- export declare const PASTE_END = "\u001B[201~";
14
12
  export declare const PASTE_PLACEHOLDER_MAX = 80;
15
- export type InputSegment = {
16
- type: "text";
17
- content: string;
18
- } | {
19
- type: "paste";
20
- content: string;
21
- };
22
- /** Split a raw stdin chunk into typed and pasted segments. */
23
- export declare function splitPaste(chunk: string): Array<{
24
- type: "typed" | "paste";
25
- text: string;
26
- }>;
27
- export declare function segmentsToString(segs: InputSegment[]): string;
28
- export declare function renderSegments(segs: InputSegment[]): string;
29
- export declare function appendCharToSegments(segs: InputSegment[], ch: string): void;
30
- /** Appends a pasted block. Short single-line pastes inline as text; the rest become placeholders. */
31
- export declare function appendPasteToSegments(segs: InputSegment[], text: string): void;
32
- /** Backspace removes one char, or an entire paste block atomically. */
33
- export declare function backspaceSegments(segs: InputSegment[]): void;
34
13
  /**
35
- * Read a line from the user with bracketed-paste awareness.
36
- * Pasted multi-line text stays in the buffer as a single block -- only a typed
37
- * Enter submits. Falls back to cooked readline when stdin isn't a TTY.
14
+ * Read a line from the user with bracketed-paste awareness. Pasted multi-line
15
+ * text stays in the buffer as a single block -- only a typed Enter submits.
16
+ * Falls back to cooked readline when stdin isn't a TTY.
38
17
  */
39
18
  export declare function ask(question: string): Promise<string>;
40
19
  export declare function select<T>(label: string, items: {
package/dist/cli/cli.js CHANGED
@@ -4,6 +4,7 @@ import { resolve } from "path";
4
4
  import { createInterface } from "readline";
5
5
  import chalk from "chalk";
6
6
  import { query } from "@anthropic-ai/claude-agent-sdk";
7
+ import { parseChunk, setBracketedPaste, deleteWordBackward } from "../ui/raw-input.js";
7
8
  // ── CLI flag parsing ──
8
9
  export function parseCliFlags(argv) {
9
10
  const known = new Set(["concurrency", "model", "timeout", "budget", "usage-cap", "extra-usage-budget", "merge"]);
@@ -66,69 +67,34 @@ export async function fetchModels(timeoutMs = 10_000) {
66
67
  return [];
67
68
  }
68
69
  }
69
- // ── Bracketed paste + segment-based input ──
70
+ // ── Interactive primitives ──
70
71
  //
71
- // When the terminal is in bracketed paste mode, pasted content is wrapped with
72
- // \x1B[200~ ... \x1B[201~ so we can distinguish typed Enter from pasted newlines.
73
- // Multi-line or long pastes are stored as opaque segments and shown as a compact
74
- // [Pasted +N lines] placeholder while editing -- the full text is substituted on submit.
75
- export const PASTE_START = "\x1B[200~";
76
- export const PASTE_END = "\x1B[201~";
72
+ // Text entry goes through the shared raw-input parser in `../ui/raw-input.ts`,
73
+ // which enforces the single invariant that used to be duplicated (and buggy)
74
+ // here and in the Ink overlay:
75
+ // - Typed Enter = a stdin chunk that is exactly "\r", "\n", or "\r\n".
76
+ // - Anything else with embedded newlines is a paste, not a submit.
77
+ // Multi-line pastes render as a compact `[Pasted +N lines]` placeholder while
78
+ // editing — the full content is substituted on submit.
77
79
  export const PASTE_PLACEHOLDER_MAX = 80;
78
- /** Split a raw stdin chunk into typed and pasted segments. */
79
- export function splitPaste(chunk) {
80
- const out = [];
81
- let i = 0;
82
- while (i < chunk.length) {
83
- const start = chunk.indexOf(PASTE_START, i);
84
- if (start === -1) {
85
- out.push({ type: "typed", text: chunk.slice(i) });
86
- break;
87
- }
88
- if (start > i)
89
- out.push({ type: "typed", text: chunk.slice(i, start) });
90
- const bodyStart = start + PASTE_START.length;
91
- const end = chunk.indexOf(PASTE_END, bodyStart);
92
- if (end === -1) {
93
- out.push({ type: "paste", text: chunk.slice(bodyStart) });
94
- break;
95
- }
96
- out.push({ type: "paste", text: chunk.slice(bodyStart, end) });
97
- i = end + PASTE_END.length;
98
- }
99
- return out;
100
- }
101
- export function segmentsToString(segs) {
102
- return segs.map((s) => s.content).join("");
103
- }
104
- export function renderSegments(segs) {
105
- return segs.map((s) => {
106
- if (s.type === "text")
107
- return s.content;
108
- const lines = s.content.split("\n").length;
109
- return chalk.dim(`[Pasted +${lines} line${lines === 1 ? "" : "s"}]`);
110
- }).join("");
111
- }
112
- export function appendCharToSegments(segs, ch) {
80
+ function appendTypedChar(segs, ch) {
113
81
  const last = segs[segs.length - 1];
114
82
  if (last && last.type === "text")
115
83
  last.content += ch;
116
84
  else
117
85
  segs.push({ type: "text", content: ch });
118
86
  }
119
- /** Appends a pasted block. Short single-line pastes inline as text; the rest become placeholders. */
120
- export function appendPasteToSegments(segs, text) {
87
+ function appendPaste(segs, text) {
121
88
  if (!text)
122
89
  return;
123
90
  const norm = text.replace(/\r\n?/g, "\n");
124
91
  if (!norm.includes("\n") && norm.length <= PASTE_PLACEHOLDER_MAX) {
125
- appendCharToSegments(segs, norm);
92
+ appendTypedChar(segs, norm);
126
93
  return;
127
94
  }
128
95
  segs.push({ type: "paste", content: norm });
129
96
  }
130
- /** Backspace removes one char, or an entire paste block atomically. */
131
- export function backspaceSegments(segs) {
97
+ function backspaceSegs(segs) {
132
98
  while (segs.length > 0) {
133
99
  const last = segs[segs.length - 1];
134
100
  if (last.type === "paste") {
@@ -143,14 +109,22 @@ export function backspaceSegments(segs) {
143
109
  return;
144
110
  }
145
111
  }
112
+ function segsToString(segs) { return segs.map((s) => s.content).join(""); }
113
+ function renderSegs(segs) {
114
+ return segs.map((s) => {
115
+ if (s.type === "text")
116
+ return s.content;
117
+ const lines = s.content.split("\n").length;
118
+ return chalk.dim(`[Pasted +${lines} line${lines === 1 ? "" : "s"}]`);
119
+ }).join("");
120
+ }
146
121
  function stripAnsi(s) {
147
122
  return s.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
148
123
  }
149
- // ── Interactive primitives ──
150
124
  /**
151
- * Read a line from the user with bracketed-paste awareness.
152
- * Pasted multi-line text stays in the buffer as a single block -- only a typed
153
- * Enter submits. Falls back to cooked readline when stdin isn't a TTY.
125
+ * Read a line from the user with bracketed-paste awareness. Pasted multi-line
126
+ * text stays in the buffer as a single block -- only a typed Enter submits.
127
+ * Falls back to cooked readline when stdin isn't a TTY.
154
128
  */
155
129
  export function ask(question) {
156
130
  const { stdin, stdout } = process;
@@ -163,28 +137,25 @@ export function ask(question) {
163
137
  const tail = question.split("\n").pop() ?? "";
164
138
  const tailVisibleLen = stripAnsi(tail).length;
165
139
  let prevWrapRows = 0;
166
- // Only rewrite the input line (and any wrapped continuation rows). The
167
- // question header above is never touched, so redraws can't stack copies
168
- // even if the initial write scrolled the viewport.
169
140
  const redraw = () => {
170
141
  const cols = stdout.columns || 80;
171
142
  if (prevWrapRows > 0)
172
143
  stdout.write(`\x1B[${prevWrapRows}A`);
173
144
  stdout.write("\r\x1B[J");
174
- const rendered = renderSegments(segs);
145
+ const rendered = renderSegs(segs);
175
146
  stdout.write(tail + rendered);
176
147
  const visible = tailVisibleLen + stripAnsi(rendered).length;
177
148
  prevWrapRows = visible > 0 ? Math.floor((visible - 1) / cols) : 0;
178
149
  };
179
150
  stdout.write(question);
180
- stdout.write("\x1B[?2004h");
151
+ setBracketedPaste(stdout, true);
181
152
  try {
182
153
  stdin.setRawMode(true);
183
154
  }
184
155
  catch { }
185
156
  stdin.resume();
186
157
  const cleanup = () => {
187
- stdout.write("\x1B[?2004l");
158
+ setBracketedPaste(stdout, false);
188
159
  try {
189
160
  stdin.setRawMode(false);
190
161
  }
@@ -192,48 +163,44 @@ export function ask(question) {
192
163
  stdin.removeListener("data", onData);
193
164
  stdin.pause();
194
165
  };
166
+ const submit = () => { stdout.write("\n"); cleanup(); resolve(segsToString(segs).trim()); };
195
167
  const onData = (buf) => {
196
- const chunk = buf.toString();
197
- for (const seg of splitPaste(chunk)) {
198
- if (seg.type === "paste") {
199
- appendPasteToSegments(segs, seg.text);
200
- redraw();
201
- continue;
202
- }
203
- for (let ci = 0; ci < seg.text.length; ci++) {
204
- const ch = seg.text[ci];
205
- if (ch === "\r" || ch === "\n") {
206
- stdout.write("\n");
207
- cleanup();
208
- resolve(segmentsToString(segs).trim());
209
- return;
168
+ for (const ev of parseChunk(buf.toString())) {
169
+ switch (ev.type) {
170
+ case "char":
171
+ appendTypedChar(segs, ev.text);
172
+ break;
173
+ case "paste":
174
+ appendPaste(segs, ev.text);
175
+ break;
176
+ case "backspace":
177
+ backspaceSegs(segs);
178
+ break;
179
+ case "word-delete": {
180
+ const s = segsToString(segs);
181
+ const next = deleteWordBackward(s);
182
+ segs.length = 0;
183
+ if (next)
184
+ segs.push({ type: "text", content: next });
185
+ break;
210
186
  }
211
- if (ch === "\x03") {
187
+ case "clear-line":
188
+ segs.length = 0;
189
+ break;
190
+ case "submit":
191
+ submit();
192
+ return;
193
+ case "cancel":
194
+ submit();
195
+ return; // lone ESC = submit, preserves old behavior
196
+ case "interrupt":
212
197
  cleanup();
213
198
  stdout.write("\n");
214
199
  process.exit(130);
215
- }
216
- if (ch === "\x7F" || ch === "\b") {
217
- backspaceSegments(segs);
218
- redraw();
219
- continue;
220
- }
221
- // ESC submits the current input (same as Enter)
222
- if (ch === "\x1B") {
223
- stdout.write("\n");
224
- cleanup();
225
- resolve(segmentsToString(segs).trim());
226
- return;
227
- }
228
- const code = ch.charCodeAt(0);
229
- if (code < 0x20)
230
- continue; // control chars
231
- if (code >= 0x7F && code < 0xA0)
232
- continue; // DEL + C1 controls
233
- appendCharToSegments(segs, ch);
200
+ // tab + nav: ignore during single-line prompts
234
201
  }
235
- redraw();
236
202
  }
203
+ redraw();
237
204
  };
238
205
  stdin.on("data", onData);
239
206
  });
@@ -1 +1 @@
1
- export declare const VERSION = "1.25.47";
1
+ export declare const VERSION = "1.25.48";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by build — do not edit manually.
2
- export const VERSION = "1.25.47";
2
+ export const VERSION = "1.25.48";
@@ -1,13 +1,13 @@
1
1
  import React from "react";
2
2
  import type { UiStore, HostCallbacks } from "./store.js";
3
+ import { deleteWordBackward as rawDeleteWordBackward } from "./raw-input.js";
3
4
  export declare const MAX_INPUT_LEN = 600;
4
5
  export declare const CONTROL_CHAR_RE: RegExp;
5
- /** Strip control characters from typed raw input so escape flushes, newlines,
6
- * and C1 bytes never end up in the user's buffer. Exported for tests. */
6
+ /** Strip control characters from typed raw input. Exported for tests. */
7
7
  export declare function sanitizeTyped(raw: string): string;
8
8
  /** Delete the previous word including any trailing whitespace, readline-style.
9
- * Bound to Ctrl+W and Opt/Cmd+Backspace. Exported for tests. */
10
- export declare function deleteWordBackward(s: string): string;
9
+ * Exported for tests. */
10
+ export declare const deleteWordBackward: typeof rawDeleteWordBackward;
11
11
  interface Props {
12
12
  store: UiStore;
13
13
  callbacks: HostCallbacks;
package/dist/ui/input.js CHANGED
@@ -1,171 +1,172 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Keyboard + input-overlay layer.
3
3
  //
4
- // `useInput` dispatches typed characters and special keys. The hotkey table
5
- // here is the executable mirror of the canonical footer contract:
6
- // ? ask · i steer · d debrief · p pause · s settings · f fallback ·
7
- // r skip-rl · q quit · arrows+0-9 for agent detail nav
4
+ // Two input paths, chosen by `state.input.mode`:
8
5
  //
9
- // Text-entry overlays (steer, ask, settings) are *not* separate phases they
10
- // capture typed chars, show a minimal hint in the footer (handled by
11
- // footer.tsx), and dispatch on Enter/Esc.
6
+ // - mode === "none" → Ink's `useInput` dispatches hotkeys (?, i, d, p, s, …)
7
+ // - mode !== "none" → a raw stdin tap using the shared parser in
8
+ // `./raw-input.ts`. Ink's useInput is disabled while
9
+ // the overlay is open so paste never gets fragmented
10
+ // into per-char keypress events (which used to fire
11
+ // `key.return` on any `\n` in the paste and submit).
12
12
  //
13
- // Text-entry input hygiene: terminals send a zoo of escape/control sequences
14
- // for navigation keys (arrows, cmd+arrow ctrl+a/e on macOS, option+letter,
15
- // pageup/pgdn, home/end, lone ESC flushes). We explicitly swallow all of them
16
- // instead of letting them fall through to the "typed char" branch, which used
17
- // to corrupt the buffer or dismiss the overlay and discard unfinished text.
18
- import { useState, useSyncExternalStore } from "react";
19
- import { Text, Box, useInput } from "ink";
13
+ // The shared parser is the same one `cli.ts` `ask()` uses, so preflight
14
+ // prompts and in-run overlays behave identically: typed Enter = a stdin chunk
15
+ // that's exactly "\r"/"\n"/"\r\n"; anything else with embedded newlines is a
16
+ // paste, not a submit.
17
+ import { useEffect, useState, useSyncExternalStore } from "react";
18
+ import { Text, Box, useInput, useStdin } from "ink";
20
19
  import chalk from "chalk";
21
20
  import { visibleLen, wrap } from "./primitives.js";
22
21
  import { SETTINGS_FIELDS, SETTINGS_LABELS, NUMERIC_SETTINGS_FIELDS, applySettingEdit, readSettingValue, } from "./settings.js";
22
+ import { parseChunk, setBracketedPaste, deleteWordBackward as rawDeleteWordBackward } from "./raw-input.js";
23
23
  export const MAX_INPUT_LEN = 600;
24
- // Any printable char is kept verbatim; everything else is filtered out before
25
- // touching the buffer. Matches: ASCII C0 controls (0x00-0x1F), DEL (0x7F), C1
26
- // controls (0x80-0x9F), and lone ESC (already handled by `key.escape`).
24
+ // Kept for backwards compatibility with existing tests. Matches C0, DEL, C1.
27
25
  export const CONTROL_CHAR_RE = /[\x00-\x1f\x7f-\x9f]/g;
28
- /** Strip control characters from typed raw input so escape flushes, newlines,
29
- * and C1 bytes never end up in the user's buffer. Exported for tests. */
26
+ /** Strip control characters from typed raw input. Exported for tests. */
30
27
  export function sanitizeTyped(raw) {
31
28
  return raw.replace(CONTROL_CHAR_RE, "");
32
29
  }
33
30
  /** Delete the previous word including any trailing whitespace, readline-style.
34
- * Bound to Ctrl+W and Opt/Cmd+Backspace. Exported for tests. */
35
- export function deleteWordBackward(s) {
36
- const trimmed = s.replace(/\s+$/, "");
37
- const idx = trimmed.search(/\S+$/);
38
- return idx < 0 ? "" : trimmed.slice(0, idx);
39
- }
31
+ * Exported for tests. */
32
+ export const deleteWordBackward = rawDeleteWordBackward;
40
33
  export function InputLayer({ store, callbacks, onToast }) {
41
34
  const [buffer, setBuffer] = useState("");
42
35
  const [settingsField, setSettingsField] = useState(0);
43
- useInput((raw, key) => {
44
- const state = store.get();
45
- const mode = state.input.mode;
46
- const swarm = state.swarm;
47
- const lc = state.liveConfig;
48
- // ── Text-entry modes ──
49
- if (mode !== "none") {
50
- // Navigation keys must NEVER touch the buffer or dismiss the overlay.
51
- // (Bug: cmd+arrow on macOS Terminal sends ctrl+a/ctrl+e which used to
52
- // leak through and append "a"/"e"; arrows on some terminals flushed
53
- // partial ESC sequences that dropped the user's unfinished text.)
54
- if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow ||
55
- key.pageUp || key.pageDown || key.home || key.end)
36
+ const state = useSyncExternalStore(store.subscribe, store.get, store.get);
37
+ const mode = state.input.mode;
38
+ const textEntry = mode !== "none";
39
+ // ── Text-entry path: raw stdin tap via the shared parser ──
40
+ const { stdin, setRawMode, isRawModeSupported } = useStdin();
41
+ useEffect(() => {
42
+ if (!textEntry || !stdin || !isRawModeSupported)
43
+ return;
44
+ setRawMode(true);
45
+ setBracketedPaste(process.stdout, true);
46
+ // Snapshot the overlay-relevant state locally; callbacks always pull the
47
+ // latest live state via `store.get()` on each event.
48
+ const onData = (buf) => {
49
+ const cur = store.get();
50
+ const m = cur.input.mode;
51
+ if (m === "none")
56
52
  return;
57
- // Esc bails (loses the buffer — always intentional).
58
- if (key.escape) {
53
+ const swarm = cur.swarm;
54
+ const lc = cur.liveConfig;
55
+ const closeOverlay = () => {
59
56
  setBuffer("");
60
57
  setSettingsField(0);
61
58
  store.patch({ input: { mode: "none", buffer: "", settingsField: 0 } });
62
- return;
63
- }
64
- if (key.return) {
65
- const text = buffer.trim();
66
- if (mode === "steer" && text)
67
- callbacks.onSteer(text);
68
- else if (mode === "ask" && text)
69
- callbacks.onAsk(text);
70
- else if (mode === "settings") {
71
- const field = SETTINGS_FIELDS[settingsField % SETTINGS_FIELDS.length];
72
- if (lc)
73
- applySettingEdit(field, text, lc, swarm);
74
- callbacks.settingsTick();
75
- const next = settingsField + 1;
76
- setBuffer("");
77
- if (next >= SETTINGS_FIELDS.length) {
78
- setSettingsField(0);
79
- store.patch({ input: { mode: "none", buffer: "", settingsField: 0 } });
80
- }
81
- else {
82
- setSettingsField(next);
83
- store.patch({ input: { mode: "settings", buffer: "", settingsField: next } });
84
- }
85
- return;
86
- }
59
+ };
60
+ const advanceSettings = () => {
61
+ const next = settingsField + 1;
87
62
  setBuffer("");
88
- setSettingsField(0);
89
- store.patch({ input: { mode: "none", buffer: "", settingsField: 0 } });
90
- return;
91
- }
92
- // Word-delete: option/alt + backspace — expected on macOS.
93
- if ((key.meta || key.ctrl) && (key.backspace || key.delete)) {
94
- const next = deleteWordBackward(buffer);
95
- setBuffer(next);
96
- store.patch({ input: { ...state.input, buffer: next } });
97
- return;
98
- }
99
- // Swallow modifier combos so they can't leak as stray letters.
100
- // (cmd+→ on macOS Terminal = \x05 = ctrl+e → input handler sees raw='e';
101
- // without this guard we used to append 'e'.)
102
- if (key.ctrl || key.meta) {
103
- if (mode !== "settings" && key.ctrl && raw === "u") {
104
- // ctrl+U: clear the whole line — standard readline behavior.
105
- setBuffer("");
106
- store.patch({ input: { ...state.input, buffer: "" } });
107
- return;
63
+ if (next >= SETTINGS_FIELDS.length) {
64
+ setSettingsField(0);
65
+ store.patch({ input: { mode: "none", buffer: "", settingsField: 0 } });
108
66
  }
109
- if (key.ctrl && raw === "w") {
110
- const next = deleteWordBackward(buffer);
111
- setBuffer(next);
112
- store.patch({ input: { ...state.input, buffer: next } });
113
- return;
67
+ else {
68
+ setSettingsField(next);
69
+ store.patch({ input: { mode: "settings", buffer: "", settingsField: next } });
114
70
  }
115
- return;
116
- }
117
- if (key.tab) {
118
- if (mode === "settings") {
119
- const field = SETTINGS_FIELDS[settingsField % SETTINGS_FIELDS.length];
120
- if (field === "pause" && swarm && lc) {
121
- const next = !swarm.paused;
122
- swarm.setPaused(next);
123
- lc.paused = next;
124
- lc.dirty = true;
125
- callbacks.settingsTick();
126
- }
127
- const next = settingsField + 1;
128
- setBuffer("");
129
- if (next >= SETTINGS_FIELDS.length) {
130
- setSettingsField(0);
131
- store.patch({ input: { mode: "none", buffer: "", settingsField: 0 } });
71
+ };
72
+ for (const ev of parseChunk(buf.toString())) {
73
+ switch (ev.type) {
74
+ case "cancel":
75
+ closeOverlay();
76
+ return;
77
+ case "interrupt":
78
+ // Treat ^C inside overlay as cancel, not process exit.
79
+ closeOverlay();
80
+ return;
81
+ case "submit": {
82
+ const text = buffer.trim();
83
+ if (m === "steer" && text)
84
+ callbacks.onSteer(text);
85
+ else if (m === "ask" && text)
86
+ callbacks.onAsk(text);
87
+ else if (m === "settings") {
88
+ const field = SETTINGS_FIELDS[settingsField % SETTINGS_FIELDS.length];
89
+ if (lc)
90
+ applySettingEdit(field, text, lc, swarm);
91
+ callbacks.settingsTick();
92
+ advanceSettings();
93
+ return;
94
+ }
95
+ closeOverlay();
96
+ return;
132
97
  }
133
- else {
134
- setSettingsField(next);
135
- store.patch({ input: { mode: "settings", buffer: "", settingsField: next } });
98
+ case "tab":
99
+ if (m === "settings") {
100
+ const field = SETTINGS_FIELDS[settingsField % SETTINGS_FIELDS.length];
101
+ if (field === "pause" && swarm && lc) {
102
+ const next = !swarm.paused;
103
+ swarm.setPaused(next);
104
+ lc.paused = next;
105
+ lc.dirty = true;
106
+ callbacks.settingsTick();
107
+ }
108
+ advanceSettings();
109
+ }
110
+ break;
111
+ case "backspace":
112
+ setBuffer((prev) => {
113
+ const next = prev.slice(0, -1);
114
+ store.patch({ input: { ...store.get().input, buffer: next } });
115
+ return next;
116
+ });
117
+ break;
118
+ case "word-delete":
119
+ setBuffer((prev) => {
120
+ const next = rawDeleteWordBackward(prev);
121
+ store.patch({ input: { ...store.get().input, buffer: next } });
122
+ return next;
123
+ });
124
+ break;
125
+ case "clear-line":
126
+ if (m !== "settings") {
127
+ setBuffer("");
128
+ store.patch({ input: { ...store.get().input, buffer: "" } });
129
+ }
130
+ break;
131
+ case "nav":
132
+ // Navigation keys are a no-op inside the overlay. Used to leak
133
+ // as stray letters because cmd+→ on macOS sends ctrl+e.
134
+ break;
135
+ case "char":
136
+ case "paste": {
137
+ let text = ev.type === "paste" ? ev.text.replace(/\r\n?/g, "\n") : ev.text;
138
+ // Settings mode is single-line and numeric fields are digits-only.
139
+ if (m === "settings") {
140
+ const field = SETTINGS_FIELDS[settingsField % SETTINGS_FIELDS.length];
141
+ if (field === "pause")
142
+ break;
143
+ if (NUMERIC_SETTINGS_FIELDS.has(field))
144
+ text = text.replace(/[^0-9.]/g, "");
145
+ text = text.replace(/\n/g, " ");
146
+ }
147
+ if (!text)
148
+ break;
149
+ setBuffer((prev) => {
150
+ const next = (prev + text).slice(0, MAX_INPUT_LEN);
151
+ store.patch({ input: { ...store.get().input, buffer: next } });
152
+ return next;
153
+ });
154
+ break;
136
155
  }
137
156
  }
138
- // Tab in steer/ask modes is a no-op, not a submit or a "tab character".
139
- return;
140
- }
141
- if (key.backspace || key.delete) {
142
- const next = buffer.slice(0, -1);
143
- setBuffer(next);
144
- store.patch({ input: { ...state.input, buffer: next } });
145
- return;
146
- }
147
- // Typed printable char(s) — raw is the string for this event. Strip any
148
- // control chars (lone ESC flushes, \n linefeeds parseKeypress labels as
149
- // 'enter', partial ESC [ sequences) before touching the buffer.
150
- if (raw && raw.length > 0) {
151
- let text = sanitizeTyped(raw);
152
- if (mode === "settings") {
153
- const field = SETTINGS_FIELDS[settingsField % SETTINGS_FIELDS.length];
154
- if (NUMERIC_SETTINGS_FIELDS.has(field))
155
- text = text.replace(/[^0-9.]/g, "");
156
- if (field === "pause")
157
- return;
158
- }
159
- if (!text)
160
- return;
161
- const next = (buffer + text).slice(0, MAX_INPUT_LEN);
162
- setBuffer(next);
163
- store.patch({ input: { ...state.input, buffer: next } });
164
157
  }
165
- return;
166
- }
167
- // ── Hotkey mode ──
168
- // Arrow keys — agent detail cycle
158
+ };
159
+ stdin.on("data", onData);
160
+ return () => {
161
+ stdin.off("data", onData);
162
+ setBracketedPaste(process.stdout, false);
163
+ };
164
+ }, [textEntry, stdin, setRawMode, isRawModeSupported, buffer, settingsField, store, callbacks]);
165
+ // ── Hotkey path: Ink's useInput, only active when no overlay is open ──
166
+ useInput((raw, key) => {
167
+ const s = store.get();
168
+ const swarm = s.swarm;
169
+ const lc = s.liveConfig;
169
170
  if (key.rightArrow || key.downArrow) {
170
171
  callbacks.cycleAgent(1);
171
172
  return;
@@ -178,19 +179,17 @@ export function InputLayer({ store, callbacks, onToast }) {
178
179
  callbacks.clearSelectedAgent();
179
180
  return;
180
181
  }
181
- // Escape in hotkey mode — clear agent selection or dismiss answered ask
182
182
  if (key.escape) {
183
- if (state.selectedAgentId != null) {
183
+ if (s.selectedAgentId != null) {
184
184
  callbacks.clearSelectedAgent();
185
185
  return;
186
186
  }
187
- if (state.ask && !state.ask.streaming) {
187
+ if (s.ask && !s.ask.streaming) {
188
188
  callbacks.clearAsk();
189
189
  return;
190
190
  }
191
191
  return;
192
192
  }
193
- // Ctrl-C: abort swarm or exit
194
193
  if (key.ctrl && raw === "c") {
195
194
  if (swarm && !swarm.aborted) {
196
195
  swarm.abort();
@@ -198,9 +197,8 @@ export function InputLayer({ store, callbacks, onToast }) {
198
197
  }
199
198
  process.exit(0);
200
199
  }
201
- // Enter in hotkey mode — reveal ask answer file in Finder if we have one
202
200
  if (key.return) {
203
- if (state.askTempFileAvailable)
201
+ if (s.askTempFileAvailable)
204
202
  callbacks.openAskTempFile();
205
203
  return;
206
204
  }
@@ -209,18 +207,16 @@ export function InputLayer({ store, callbacks, onToast }) {
209
207
  const code = raw.charCodeAt(0);
210
208
  if (code < 0x20 || code > 0x7E)
211
209
  return;
212
- // Any ctrl/meta combo (that isn't one of the specific hotkeys above) is
213
- // nav-adjacent on most terminals; ignore instead of matching "c"/"i" etc.
214
210
  if (key.ctrl || key.meta)
215
211
  return;
216
212
  const toast = (msg) => onToast(msg);
217
213
  switch (raw.toLowerCase()) {
218
214
  case "?":
219
- if (!state.hasOnAsk)
215
+ if (!s.hasOnAsk)
220
216
  return toast("Ask not wired for this run");
221
- if (state.askBusy || state.ask?.streaming)
217
+ if (s.askBusy || s.ask?.streaming)
222
218
  return toast("Ask already in flight");
223
- if (state.ask && !state.ask.streaming) {
219
+ if (s.ask && !s.ask.streaming) {
224
220
  callbacks.clearAsk();
225
221
  return;
226
222
  }
@@ -228,17 +224,16 @@ export function InputLayer({ store, callbacks, onToast }) {
228
224
  setBuffer("");
229
225
  return;
230
226
  case "i":
231
- if (!state.hasOnSteer)
227
+ if (!s.hasOnSteer)
232
228
  return toast("Steering not wired for this run");
233
229
  store.patch({ input: { mode: "steer", buffer: "", settingsField: 0 } });
234
230
  setBuffer("");
235
231
  return;
236
232
  case "d":
237
- // Show latest debrief entry in the overlay; if nothing yet, toast.
238
- if (state.debrief)
239
- return; // already visible
240
- if (state.debriefHistory.length > 0) {
241
- const last = state.debriefHistory[state.debriefHistory.length - 1];
233
+ if (s.debrief)
234
+ return;
235
+ if (s.debriefHistory.length > 0) {
236
+ const last = s.debriefHistory[s.debriefHistory.length - 1];
242
237
  store.patch({ debrief: { text: last.text, label: last.label } });
243
238
  return;
244
239
  }
@@ -270,11 +265,8 @@ export function InputLayer({ store, callbacks, onToast }) {
270
265
  swarm.retryRateLimitNow();
271
266
  return;
272
267
  case "q":
273
- // Second press with the current swarm already aborted = hard exit.
274
268
  if (swarm?.aborted)
275
269
  process.exit(0);
276
- // Always request quit: flips the runner's `stopping` flag so the wave
277
- // loop breaks instead of advancing to steering / post-run review.
278
270
  callbacks.requestQuit();
279
271
  return;
280
272
  }
@@ -284,9 +276,7 @@ export function InputLayer({ store, callbacks, onToast }) {
284
276
  if (n < running.length)
285
277
  callbacks.selectAgent(running[n].id);
286
278
  }
287
- });
288
- // Render the active text-entry prompt under the footer hint.
289
- const state = useSyncExternalStore(store.subscribe, store.get, store.get);
279
+ }, { isActive: !textEntry });
290
280
  if (state.input.mode === "none")
291
281
  return null;
292
282
  const caretOn = state.tick % 2 === 0;
@@ -305,7 +295,7 @@ function InputPrompt({ mode, buffer, settingsField, state, caretOn }) {
305
295
  let subtitle;
306
296
  let hint;
307
297
  let currentLine = null;
308
- let filteredBuffer = buffer;
298
+ const filteredBuffer = buffer;
309
299
  if (mode === "settings") {
310
300
  const total = SETTINGS_FIELDS.length;
311
301
  const field = SETTINGS_FIELDS[settingsField % total];
@@ -323,13 +313,11 @@ function InputPrompt({ mode, buffer, settingsField, state, caretOn }) {
323
313
  const action = mode === "steer" ? "queue" : "send";
324
314
  hint = chalk.dim(`Enter ${action} \u00b7 Esc cancel \u00b7 Ctrl+U clear \u00b7 Ctrl+W del word`);
325
315
  }
326
- // Word-wrap the buffer so long entries don't blow past the box edge.
327
316
  const bufferLines = filteredBuffer.length === 0
328
317
  ? [""]
329
318
  : wrap(filteredBuffer, Math.max(20, innerW));
330
319
  const caret = caretOn ? accent("\u2588") : " ";
331
320
  const lastIdx = bufferLines.length - 1;
332
- // Char counter — dims normally, warns at 80%, red at 95%.
333
321
  const pct = buffer.length / MAX_INPUT_LEN;
334
322
  const counter = buffer.length === 0 ? "" :
335
323
  pct >= 0.95 ? chalk.red(`${buffer.length}/${MAX_INPUT_LEN}`)
@@ -0,0 +1,38 @@
1
+ export type InputEvent = {
2
+ type: "char";
3
+ text: string;
4
+ } | {
5
+ type: "paste";
6
+ text: string;
7
+ } | {
8
+ type: "backspace";
9
+ } | {
10
+ type: "word-delete";
11
+ } | {
12
+ type: "clear-line";
13
+ } | {
14
+ type: "submit";
15
+ } | {
16
+ type: "cancel";
17
+ } | {
18
+ type: "interrupt";
19
+ } | {
20
+ type: "tab";
21
+ } | {
22
+ type: "nav";
23
+ name: "up" | "down" | "left" | "right" | "home" | "end" | "pgup" | "pgdn";
24
+ };
25
+ export declare const PASTE_START = "\u001B[200~";
26
+ export declare const PASTE_END = "\u001B[201~";
27
+ export declare function sanitize(raw: string): string;
28
+ /** Split a chunk into events. Honors bracketed paste, detects paste-by-shape
29
+ * (multi-byte chunk containing newlines), and cleanly consumes escape
30
+ * sequences without leaking their terminator as a typed char. */
31
+ export declare function parseChunk(chunk: string): InputEvent[];
32
+ /** Enable/disable bracketed paste on the given stdout. Best-effort — terminals
33
+ * that don't support it simply ignore the sequence, and `parseChunk`'s
34
+ * shape-based paste detection covers them. */
35
+ export declare function setBracketedPaste(stdout: NodeJS.WriteStream, enabled: boolean): void;
36
+ /** Readline-style word delete: strip trailing whitespace, then strip the last
37
+ * non-whitespace run. */
38
+ export declare function deleteWordBackward(s: string): string;
@@ -0,0 +1,241 @@
1
+ // Shared raw-stdin parser for text entry.
2
+ //
3
+ // Single source of truth for what a chunk from stdin means. Used by both the
4
+ // Ink overlay (src/ui/input.tsx) during a run and the preflight `ask()` prompt
5
+ // (src/cli/cli.ts) before a run. Fixes two classes of bugs that existed in
6
+ // both copies:
7
+ //
8
+ // 1. "@ triggered send" — CSI/SS3 terminator check was `< 0x7E` (missed `~`)
9
+ // and the ESC+printable branch silently dropped the next char.
10
+ // 2. "paste with newline sent early" — Ink's useInput fragments multi-byte
11
+ // chunks into per-char keypress events, firing key.return on any `\n` in
12
+ // a paste. Here we keep the whole chunk and decide paste-vs-typed-enter
13
+ // by whether the chunk is exactly a newline (typed) or contains
14
+ // newlines alongside other bytes (pasted).
15
+ //
16
+ // The parser is pure: takes a string chunk, returns an ordered event list.
17
+ // Bracketed-paste markers are honored when present but we don't rely on them.
18
+ export const PASTE_START = "\x1B[200~";
19
+ export const PASTE_END = "\x1B[201~";
20
+ // Control chars to strip from any text we append to a buffer. Matches C0
21
+ // (0x00-0x1F), DEL (0x7F), and C1 (0x80-0x9F). Newlines are kept — the caller
22
+ // decides per segment whether to strip or preserve them.
23
+ const CONTROL_STRIP_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g;
24
+ export function sanitize(raw) {
25
+ return raw.replace(CONTROL_STRIP_RE, "");
26
+ }
27
+ // CSI/SS3 final byte is in the range 0x40..0x7E inclusive. (The old code used
28
+ // `< 0x7E` which dropped past the `~` terminator used by function keys and
29
+ // the bracketed-paste close marker.)
30
+ function isCsiFinal(code) {
31
+ return code >= 0x40 && code <= 0x7E;
32
+ }
33
+ // Recognized CSI/SS3 sequences for navigation. Anything else with a valid
34
+ // CSI/SS3 shape is silently consumed.
35
+ function csiToNav(body) {
36
+ // body = everything between ESC[ (or ESCO) and the terminator, plus terminator
37
+ // Common sequences: A=up, B=down, C=right, D=left, H=home, F=end,
38
+ // 5~=pgup, 6~=pgdn, 1~/7~=home, 4~/8~=end
39
+ if (body === "A")
40
+ return { type: "nav", name: "up" };
41
+ if (body === "B")
42
+ return { type: "nav", name: "down" };
43
+ if (body === "C")
44
+ return { type: "nav", name: "right" };
45
+ if (body === "D")
46
+ return { type: "nav", name: "left" };
47
+ if (body === "H" || body === "1~" || body === "7~")
48
+ return { type: "nav", name: "home" };
49
+ if (body === "F" || body === "4~" || body === "8~")
50
+ return { type: "nav", name: "end" };
51
+ if (body === "5~")
52
+ return { type: "nav", name: "pgup" };
53
+ if (body === "6~")
54
+ return { type: "nav", name: "pgdn" };
55
+ return null;
56
+ }
57
+ /** Split a chunk into events. Honors bracketed paste, detects paste-by-shape
58
+ * (multi-byte chunk containing newlines), and cleanly consumes escape
59
+ * sequences without leaking their terminator as a typed char. */
60
+ export function parseChunk(chunk) {
61
+ const out = [];
62
+ if (!chunk)
63
+ return out;
64
+ // First pass: carve out bracketed-paste blocks. Everything outside those
65
+ // markers is "free text" — we still apply shape-based paste detection to it.
66
+ const parts = [];
67
+ let i = 0;
68
+ while (i < chunk.length) {
69
+ const ps = chunk.indexOf(PASTE_START, i);
70
+ if (ps === -1) {
71
+ parts.push({ kind: "free", text: chunk.slice(i) });
72
+ break;
73
+ }
74
+ if (ps > i)
75
+ parts.push({ kind: "free", text: chunk.slice(i, ps) });
76
+ const bodyStart = ps + PASTE_START.length;
77
+ const pe = chunk.indexOf(PASTE_END, bodyStart);
78
+ if (pe === -1) {
79
+ parts.push({ kind: "paste", text: chunk.slice(bodyStart) });
80
+ break;
81
+ }
82
+ parts.push({ kind: "paste", text: chunk.slice(bodyStart, pe) });
83
+ i = pe + PASTE_END.length;
84
+ }
85
+ for (const part of parts) {
86
+ if (part.kind === "paste") {
87
+ if (part.text)
88
+ out.push({ type: "paste", text: part.text });
89
+ continue;
90
+ }
91
+ // Free text: walk byte by byte, consuming escape sequences and control
92
+ // bytes. Typed enter = a chunk that's EXACTLY "\r", "\n", or "\r\n".
93
+ const s = part.text;
94
+ if (!s)
95
+ continue;
96
+ // Fast path: a chunk that is just enter, tab, backspace, or ^C.
97
+ if (s === "\r" || s === "\n" || s === "\r\n") {
98
+ out.push({ type: "submit" });
99
+ continue;
100
+ }
101
+ if (s === "\x03") {
102
+ out.push({ type: "interrupt" });
103
+ continue;
104
+ }
105
+ if (s === "\x7F" || s === "\b") {
106
+ out.push({ type: "backspace" });
107
+ continue;
108
+ }
109
+ if (s === "\t") {
110
+ out.push({ type: "tab" });
111
+ continue;
112
+ }
113
+ if (s === "\x1B") {
114
+ out.push({ type: "cancel" });
115
+ continue;
116
+ }
117
+ if (s === "\x15") {
118
+ out.push({ type: "clear-line" });
119
+ continue;
120
+ } // ^U
121
+ if (s === "\x17") {
122
+ out.push({ type: "word-delete" });
123
+ continue;
124
+ } // ^W
125
+ // Shape-based paste: multi-char chunk containing a newline that is NOT
126
+ // just "\r\n". Terminals buffer keypresses at ~1 byte; paste comes in as
127
+ // a single large chunk. Anything multi-char with embedded newlines is
128
+ // paste, not a sequence of typed Enters.
129
+ if ((s.includes("\n") || s.includes("\r")) && s.length > 2) {
130
+ const stripped = s.replace(/\r/g, "");
131
+ if (stripped)
132
+ out.push({ type: "paste", text: stripped });
133
+ continue;
134
+ }
135
+ // Otherwise walk the chunk one logical token at a time.
136
+ let j = 0;
137
+ let buf = "";
138
+ const flushBuf = () => { if (buf) {
139
+ out.push({ type: "char", text: buf });
140
+ buf = "";
141
+ } };
142
+ while (j < s.length) {
143
+ const ch = s[j];
144
+ if (ch === "\r" || ch === "\n") {
145
+ // Bare newline embedded inside a short non-paste chunk: treat as
146
+ // submit if it's the tail, otherwise drop (shouldn't happen in well-
147
+ // formed terminal input — paste goes through the shape path above).
148
+ flushBuf();
149
+ out.push({ type: "submit" });
150
+ j++;
151
+ continue;
152
+ }
153
+ if (ch === "\x03") {
154
+ flushBuf();
155
+ out.push({ type: "interrupt" });
156
+ j++;
157
+ continue;
158
+ }
159
+ if (ch === "\x7F" || ch === "\b") {
160
+ flushBuf();
161
+ out.push({ type: "backspace" });
162
+ j++;
163
+ continue;
164
+ }
165
+ if (ch === "\t") {
166
+ flushBuf();
167
+ out.push({ type: "tab" });
168
+ j++;
169
+ continue;
170
+ }
171
+ if (ch === "\x15") {
172
+ flushBuf();
173
+ out.push({ type: "clear-line" });
174
+ j++;
175
+ continue;
176
+ }
177
+ if (ch === "\x17") {
178
+ flushBuf();
179
+ out.push({ type: "word-delete" });
180
+ j++;
181
+ continue;
182
+ }
183
+ if (ch === "\x1B") {
184
+ flushBuf();
185
+ const next = j + 1 < s.length ? s[j + 1] : null;
186
+ if (next === null) {
187
+ out.push({ type: "cancel" });
188
+ j++;
189
+ continue;
190
+ }
191
+ if (next === "[" || next === "O") {
192
+ // CSI or SS3: consume up to and including the final byte (0x40..0x7E).
193
+ let k = j + 2;
194
+ let body = "";
195
+ while (k < s.length) {
196
+ const code = s.charCodeAt(k);
197
+ body += s[k];
198
+ k++;
199
+ if (isCsiFinal(code))
200
+ break;
201
+ }
202
+ const nav = csiToNav(body);
203
+ if (nav)
204
+ out.push(nav);
205
+ j = k;
206
+ continue;
207
+ }
208
+ // ESC + printable = Meta/Alt + key. Drop both bytes; upstream can
209
+ // surface specific combos later if needed.
210
+ j += 2;
211
+ continue;
212
+ }
213
+ const code = ch.charCodeAt(0);
214
+ // Skip remaining control bytes silently.
215
+ if (code < 0x20 || (code >= 0x7F && code < 0xA0)) {
216
+ j++;
217
+ continue;
218
+ }
219
+ buf += ch;
220
+ j++;
221
+ }
222
+ flushBuf();
223
+ }
224
+ return out;
225
+ }
226
+ /** Enable/disable bracketed paste on the given stdout. Best-effort — terminals
227
+ * that don't support it simply ignore the sequence, and `parseChunk`'s
228
+ * shape-based paste detection covers them. */
229
+ export function setBracketedPaste(stdout, enabled) {
230
+ try {
231
+ stdout.write(enabled ? "\x1B[?2004h" : "\x1B[?2004l");
232
+ }
233
+ catch { }
234
+ }
235
+ /** Readline-style word delete: strip trailing whitespace, then strip the last
236
+ * non-whitespace run. */
237
+ export function deleteWordBackward(s) {
238
+ const trimmed = s.replace(/\s+$/, "");
239
+ const idx = trimmed.search(/\S+$/);
240
+ return idx < 0 ? "" : trimmed.slice(0, idx);
241
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.25.47",
3
+ "version": "1.25.48",
4
4
  "description": "Parallel Claude agents in git worktrees with a usage cap that reserves headroom for your interactive Claude Code. Crash-safe resume. Provider-agnostic model catalog (Anthropic, Cursor, OpenAI, Gemini, DeepSeek, Llama, Qwen) with capability-based task scoping.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.25.47",
3
+ "version": "1.25.48",
4
4
  "description": "Claude Code skill for understanding, installing, and inspecting claude-overnight runs -- parallel Claude agents in git worktrees with thinking waves, multi-wave steering, and crash-safe resume. Supports Cursor API Proxy, Qwen, OpenRouter.",
5
5
  "author": {
6
6
  "name": "Francesco Fornace"