@ouro.bot/cli 0.1.0-alpha.260 → 0.1.0-alpha.261

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.json CHANGED
@@ -1,11 +1,17 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.261",
6
+ "changes": [
7
+ "feat(tui): full input parity with Claude Code \u2014 kill ring, emacs nav, Home/End, Ctrl+D, forward delete, Esc history, bracketed paste, clipboard image, token deletion, chip navigation. 156 new tests."
8
+ ]
9
+ },
4
10
  {
5
11
  "version": "0.1.0-alpha.260",
6
12
  "changes": [
7
- "fix(daemon): MCP bridge empty-response bug for long-running commands. `sendDaemonCommand` and `checkDaemonSocketAlive` were calling `client.end()` immediately after writing, which half-closed the TCP connection. The daemon server's `net.createServer()` uses the default `allowHalfOpen: false`, so when it saw the client's FIN it auto-closed its own writable side — and any response the server tried to write after processing a long-running command (like `agent.senseTurn`, which runs a full LLM turn) was dropped on the floor. Verified via direct socket repro: with `client.end()`, `agent.senseTurn` returned empty in ~149ms; without it, the same call returned a real response in ~5.8s. This was a silent regression of the fix in #303 (commit `253e4b1f` titled \"socket half-close fix\" actually *added* the half-close back). Fix: drop the `client.end()` calls from both sites AND set `allowHalfOpen: true` on the daemon's `net.createServer(...)` as defense-in-depth so future clients calling `end()` don't silently break again.",
8
- "fix(daemon): tighten pidfile trust — `killOrphanProcesses` now verifies each pidfile PID is an actual orphan (PPID reparented to init/PID 1) before SIGTERMing it. Previously a polluted pidfile (written by a crashed daemon whose PIDs have since been reused by the OS for unrelated processes) could cause mass-kill of unrelated apps. New exported helper `filterPidfilePidsToActualOrphans` provides direct unit coverage.",
13
+ "fix(daemon): MCP bridge empty-response bug for long-running commands. `sendDaemonCommand` and `checkDaemonSocketAlive` were calling `client.end()` immediately after writing, which half-closed the TCP connection. The daemon server's `net.createServer()` uses the default `allowHalfOpen: false`, so when it saw the client's FIN it auto-closed its own writable side \u2014 and any response the server tried to write after processing a long-running command (like `agent.senseTurn`, which runs a full LLM turn) was dropped on the floor. Verified via direct socket repro: with `client.end()`, `agent.senseTurn` returned empty in ~149ms; without it, the same call returned a real response in ~5.8s. This was a silent regression of the fix in #303 (commit `253e4b1f` titled \"socket half-close fix\" actually *added* the half-close back). Fix: drop the `client.end()` calls from both sites AND set `allowHalfOpen: true` on the daemon's `net.createServer(...)` as defense-in-depth so future clients calling `end()` don't silently break again.",
14
+ "fix(daemon): tighten pidfile trust \u2014 `killOrphanProcesses` now verifies each pidfile PID is an actual orphan (PPID reparented to init/PID 1) before SIGTERMing it. Previously a polluted pidfile (written by a crashed daemon whose PIDs have since been reused by the OS for unrelated processes) could cause mass-kill of unrelated apps. New exported helper `filterPidfilePidsToActualOrphans` provides direct unit coverage.",
9
15
  "fix(daemon, repertoire): rename six `_stop` nerves events to `_end` so they pair correctly with their `_start` counterparts under the nerves audit's start/end pairing rule. Affected: `daemon.thoughts_follow_stop`, `daemon.server_stop`, `daemon.update_checker_stop`, `daemon.mcp_server_stop`, `daemon.habit_scheduler_stop`, `mcp.manager_stop`. The naming was semantically fine (`stop` pairs with `start`), but the audit specifically looks for `_end`/`_error` suffixes. Was producing intermittent audit failures whenever any test run exercised these teardown paths (seen today as flakes on `daemon-socket-errors.test.ts` and `MarkdownStreamer` tests).",
10
16
  "fix(providers): drop the harness-imposed MiniMax VLM timeout entirely. Previously the module defaulted to a 60-second AbortSignal; E2E validation of the BB image fix hit a real VLM request that took >60s to return (same bytes returned in 9.5s on the immediate retry). Raising to 120s would have been arbitrary too \u2014 the correct answer, per the same reasoning as PR #322 for LLM providers, is to not impose a harness ceiling at all. When `timeoutMs` isn't provided, `fetch()` now runs without an AbortSignal and undici's own defaults (headersTimeout + bodyTimeout, both 5 minutes) are the ceiling. Callers that want a tighter bound can still pass an explicit `timeoutMs`. The AbortError path is kept for that case, and the error message adapts to say \"underlying stack default\" when there was no harness-set value."
11
17
  ]
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BracketedPasteHandler = void 0;
4
+ /**
5
+ * Bracketed paste mode handler for the TUI.
6
+ *
7
+ * Detects \x1b[200~ (paste start) and \x1b[201~ (paste end) markers in
8
+ * raw stdin data. Buffers paste content between markers and emits it as
9
+ * a single "paste" event. Non-paste data is emitted as "data" unchanged.
10
+ *
11
+ * Works alongside Ink's stdin handling by intercepting raw data events.
12
+ */
13
+ const events_1 = require("events");
14
+ const runtime_1 = require("../../nerves/runtime");
15
+ const PASTE_START = "\x1b[200~";
16
+ const PASTE_END = "\x1b[201~";
17
+ class BracketedPasteHandler extends events_1.EventEmitter {
18
+ stdin;
19
+ stdout;
20
+ pasting = false;
21
+ buffer = "";
22
+ dataHandler;
23
+ constructor(stdin, stdout) {
24
+ super();
25
+ this.stdin = stdin;
26
+ this.stdout = stdout;
27
+ // Enable bracketed paste mode
28
+ stdout.write("\x1b[?2004h");
29
+ this.dataHandler = (chunk) => {
30
+ this.processChunk(chunk.toString());
31
+ };
32
+ stdin.prependListener("data", this.dataHandler);
33
+ }
34
+ processChunk(data) {
35
+ let remaining = data;
36
+ while (remaining.length > 0) {
37
+ if (this.pasting) {
38
+ // Look for end marker in buffer + remaining
39
+ const combined = this.buffer + remaining;
40
+ const endIdx = combined.indexOf(PASTE_END);
41
+ if (endIdx !== -1) {
42
+ // Found end marker -- emit paste content
43
+ const pasteContent = combined.slice(0, endIdx);
44
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.bracketed_paste", message: "bracketed paste received", meta: { length: pasteContent.length } });
45
+ this.emit("paste", pasteContent);
46
+ this.buffer = "";
47
+ this.pasting = false;
48
+ // Process remaining data after end marker
49
+ remaining = combined.slice(endIdx + PASTE_END.length);
50
+ continue;
51
+ }
52
+ // No end marker yet -- buffer everything so far
53
+ this.buffer = combined;
54
+ return;
55
+ }
56
+ // Not pasting -- look for start marker
57
+ const startIdx = remaining.indexOf(PASTE_START);
58
+ if (startIdx !== -1) {
59
+ // Emit any data before the start marker
60
+ const before = remaining.slice(0, startIdx);
61
+ if (before.length > 0) {
62
+ this.emit("data", before);
63
+ }
64
+ // Enter paste mode
65
+ this.pasting = true;
66
+ this.buffer = "";
67
+ remaining = remaining.slice(startIdx + PASTE_START.length);
68
+ continue;
69
+ }
70
+ // No start marker -- emit as regular data
71
+ this.emit("data", remaining);
72
+ return;
73
+ }
74
+ }
75
+ /** Disable bracketed paste mode and remove listeners. */
76
+ destroy() {
77
+ this.stdout.write("\x1b[?2004l");
78
+ this.stdin.removeListener("data", this.dataHandler);
79
+ this.removeAllListeners();
80
+ }
81
+ }
82
+ exports.BracketedPasteHandler = BracketedPasteHandler;
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.imageRefEndingAt = imageRefEndingAt;
4
+ exports.imageRefStartingAt = imageRefStartingAt;
5
+ exports.deleteTokenBefore = deleteTokenBefore;
6
+ exports.deleteTokenAfter = deleteTokenAfter;
7
+ /**
8
+ * Token-aware navigation and deletion for [Image #N] references.
9
+ *
10
+ * Image refs are treated as atomic "chips" — arrow keys hop over them,
11
+ * backspace/delete removes them atomically, and word-kill treats them
12
+ * as single words.
13
+ */
14
+ const runtime_1 = require("../../nerves/runtime");
15
+ const IMAGE_REF_PATTERN = /\[Image #\d+\]/g;
16
+ /**
17
+ * If an [Image #N] ref ends exactly at the given position, return its start index.
18
+ * Otherwise return undefined.
19
+ */
20
+ function imageRefEndingAt(text, pos) {
21
+ // Search backward from pos for a potential image ref
22
+ // Max length of [Image #NNN] is ~14 chars, search a window
23
+ const windowStart = Math.max(0, pos - 20);
24
+ const window = text.slice(windowStart, pos);
25
+ IMAGE_REF_PATTERN.lastIndex = 0;
26
+ let match;
27
+ while ((match = IMAGE_REF_PATTERN.exec(window)) !== null) {
28
+ const matchEnd = windowStart + match.index + match[0].length;
29
+ if (matchEnd === pos) {
30
+ return windowStart + match.index;
31
+ }
32
+ }
33
+ return undefined;
34
+ }
35
+ /**
36
+ * If an [Image #N] ref starts exactly at the given position, return its end index.
37
+ * Otherwise return undefined.
38
+ */
39
+ function imageRefStartingAt(text, pos) {
40
+ if (pos >= text.length)
41
+ return undefined;
42
+ // Check if text at pos matches an image ref
43
+ const window = text.slice(pos, pos + 20);
44
+ IMAGE_REF_PATTERN.lastIndex = 0;
45
+ const match = IMAGE_REF_PATTERN.exec(window);
46
+ if (match && match.index === 0) {
47
+ return pos + match[0].length;
48
+ }
49
+ return undefined;
50
+ }
51
+ /**
52
+ * If cursor is right after an [Image #N], remove the entire ref.
53
+ * Returns { text, pos } with the ref removed, or null if no ref found.
54
+ */
55
+ function deleteTokenBefore(text, pos) {
56
+ const start = imageRefEndingAt(text, pos);
57
+ if (start === undefined)
58
+ return null;
59
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.image_ref_delete", message: "deleted image ref token", meta: { pos } });
60
+ const before = text.slice(0, start);
61
+ const after = text.slice(pos);
62
+ return { text: before + after, pos: start };
63
+ }
64
+ /**
65
+ * If cursor is right before an [Image #N], remove the entire ref.
66
+ * Returns { text, pos } with the ref removed, or null if no ref found.
67
+ */
68
+ function deleteTokenAfter(text, pos) {
69
+ const end = imageRefStartingAt(text, pos);
70
+ if (end === undefined)
71
+ return null;
72
+ const before = text.slice(0, pos);
73
+ const after = text.slice(end);
74
+ return { text: before + after, pos };
75
+ }
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleKillToEnd = handleKillToEnd;
4
+ exports.handleKillToStart = handleKillToStart;
5
+ exports.handleKillWordBack = handleKillWordBack;
6
+ exports.handleYank = handleYank;
7
+ exports.handleCursorLeft = handleCursorLeft;
8
+ exports.handleCursorRight = handleCursorRight;
9
+ exports.handleBackspace = handleBackspace;
10
+ exports.handleForwardDelete = handleForwardDelete;
11
+ exports.handleHome = handleHome;
12
+ exports.handleEnd = handleEnd;
13
+ exports.classifyEscapeSequence = classifyEscapeSequence;
14
+ exports.handleYankPop = handleYankPop;
15
+ exports.handleEmptyPaste = handleEmptyPaste;
16
+ const runtime_1 = require("../../nerves/runtime");
17
+ /**
18
+ * Ctrl+K: kill from cursor to end of line.
19
+ * Pushes killed text to ring with "append" direction.
20
+ */
21
+ function handleKillToEnd(text, cursorPos, ring) {
22
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.input_kill", message: "kill to end", meta: { direction: "end" } });
23
+ if (cursorPos >= text.length) {
24
+ return { text, cursorPos };
25
+ }
26
+ const killed = text.slice(cursorPos);
27
+ ring.push(killed, "append");
28
+ return { text: text.slice(0, cursorPos), cursorPos };
29
+ }
30
+ /**
31
+ * Ctrl+U: kill from start of line to cursor.
32
+ * Pushes killed text to ring with "prepend" direction.
33
+ */
34
+ function handleKillToStart(text, cursorPos, ring) {
35
+ if (cursorPos === 0) {
36
+ return { text, cursorPos };
37
+ }
38
+ const killed = text.slice(0, cursorPos);
39
+ ring.push(killed, "prepend");
40
+ return { text: text.slice(cursorPos), cursorPos: 0 };
41
+ }
42
+ /**
43
+ * Ctrl+W / Meta+Backspace: kill word before cursor.
44
+ * Pushes killed word to ring with "prepend" direction.
45
+ */
46
+ function handleKillWordBack(text, cursorPos, ring) {
47
+ if (cursorPos === 0) {
48
+ return { text, cursorPos };
49
+ }
50
+ const before = text.slice(0, cursorPos);
51
+ const after = text.slice(cursorPos);
52
+ const wordStart = before.replace(/\s*\S+\s*$/, "");
53
+ const killed = before.slice(wordStart.length);
54
+ ring.push(killed, "prepend");
55
+ return { text: wordStart + after, cursorPos: wordStart.length };
56
+ }
57
+ /**
58
+ * Ctrl+Y: yank (paste) from kill ring at cursor.
59
+ * Returns null if ring is empty.
60
+ */
61
+ function handleYank(text, cursorPos, ring) {
62
+ const yanked = ring.yank();
63
+ if (yanked === undefined)
64
+ return null;
65
+ const before = text.slice(0, cursorPos);
66
+ const after = text.slice(cursorPos);
67
+ return { text: before + yanked + after, cursorPos: cursorPos + yanked.length };
68
+ }
69
+ // ─── Emacs Cursor Navigation ─────────────────────────────────────
70
+ /** Ctrl+B: move cursor left by 1. Returns new cursor position. */
71
+ function handleCursorLeft(_text, cursorPos) {
72
+ return Math.max(0, cursorPos - 1);
73
+ }
74
+ /** Ctrl+F: move cursor right by 1. Returns new cursor position. */
75
+ function handleCursorRight(text, cursorPos) {
76
+ return Math.min(text.length, cursorPos + 1);
77
+ }
78
+ /** Ctrl+H: backspace (delete char before cursor). */
79
+ function handleBackspace(text, cursorPos) {
80
+ if (cursorPos === 0)
81
+ return { text, cursorPos };
82
+ const before = text.slice(0, cursorPos - 1);
83
+ const after = text.slice(cursorPos);
84
+ return { text: before + after, cursorPos: cursorPos - 1 };
85
+ }
86
+ /** Forward delete: delete char at cursor position. */
87
+ function handleForwardDelete(text, cursorPos) {
88
+ if (cursorPos >= text.length)
89
+ return { text, cursorPos };
90
+ const before = text.slice(0, cursorPos);
91
+ const after = text.slice(cursorPos + 1);
92
+ return { text: before + after, cursorPos };
93
+ }
94
+ // ─── Home / End ──────────────────────────────────────────────────
95
+ /** Home key: move cursor to position 0. Returns new cursor position. */
96
+ function handleHome(_text, _cursorPos) {
97
+ return 0;
98
+ }
99
+ /** End key: move cursor to end of text. Returns new cursor position. */
100
+ function handleEnd(text, _cursorPos) {
101
+ return text.length;
102
+ }
103
+ // ─── Escape Sequence Classification ──────────────────────────────
104
+ /**
105
+ * Classify raw escape sequences that Ink passes through as regular characters.
106
+ * Returns:
107
+ * "home" — Home key sequences
108
+ * "end" — End key sequences
109
+ * "ignore" — PageUp, PageDown, mouse wheel (should be suppressed)
110
+ * null — not a recognized escape sequence
111
+ */
112
+ function classifyEscapeSequence(inputChar) {
113
+ if (!inputChar.startsWith("\x1b["))
114
+ return null;
115
+ const seq = inputChar.slice(2); // after \x1b[
116
+ // Home: \x1b[H or \x1b[1~
117
+ if (seq === "H" || seq === "1~")
118
+ return "home";
119
+ // End: \x1b[F or \x1b[4~
120
+ if (seq === "F" || seq === "4~")
121
+ return "end";
122
+ // PageUp: \x1b[5~, PageDown: \x1b[6~
123
+ if (seq === "5~" || seq === "6~")
124
+ return "ignore";
125
+ // SGR mouse wheel: \x1b[<64;... or \x1b[<65;...
126
+ if (seq.startsWith("<64;") || seq.startsWith("<65;"))
127
+ return "ignore";
128
+ return null;
129
+ }
130
+ /**
131
+ * Alt+Y: yank-pop -- replace previously yanked text with next ring entry.
132
+ * Returns null if not in yanking state or ring is empty.
133
+ */
134
+ function handleYankPop(text, cursorPos, ring) {
135
+ const lastYanked = ring.lastYankedText;
136
+ if (!ring.isYanking || lastYanked === undefined)
137
+ return null;
138
+ const popped = ring.yankPop();
139
+ if (popped === undefined)
140
+ return null;
141
+ // Find where the last yanked text ends (at current cursor position)
142
+ // and starts (cursorPos - lastYanked.length)
143
+ const yankStart = cursorPos - lastYanked.length;
144
+ const before = text.slice(0, yankStart);
145
+ const after = text.slice(cursorPos);
146
+ return { text: before + popped + after, cursorPos: yankStart + popped.length };
147
+ }
148
+ /**
149
+ * Handle an empty paste event by checking clipboard for images (macOS only).
150
+ * Returns null if no image found, not on macOS, or clipboard empty.
151
+ */
152
+ async function handleEmptyPaste(text, cursorPos, currentImageCount, platform, clipboardReader) {
153
+ if (platform !== "darwin")
154
+ return null;
155
+ const imageData = await clipboardReader();
156
+ if (!imageData)
157
+ return null;
158
+ const refNum = currentImageCount + 1;
159
+ const ref = `[Image #${refNum}]`;
160
+ const before = text.slice(0, cursorPos);
161
+ const after = text.slice(cursorPos);
162
+ return {
163
+ text: before + ref + after,
164
+ cursorPos: cursorPos + ref.length,
165
+ imageRef: refNum,
166
+ imageData,
167
+ };
168
+ }
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KillRing = void 0;
4
+ /**
5
+ * Kill ring — emacs-style cut buffer with accumulation and yank-pop cycling.
6
+ *
7
+ * Stores up to MAX_SIZE killed text entries. Consecutive kills in the same
8
+ * direction accumulate into a single entry. Yank retrieves the most recent
9
+ * entry; yank-pop cycles backward through the ring.
10
+ */
11
+ const runtime_1 = require("../../nerves/runtime");
12
+ const MAX_SIZE = 10;
13
+ class KillRing {
14
+ entries = [];
15
+ accumulating = false;
16
+ yankIndex = -1;
17
+ _isYanking = false;
18
+ _lastYankedText = undefined;
19
+ /** Whether the ring is currently in yank-cycling mode. */
20
+ get isYanking() {
21
+ return this._isYanking;
22
+ }
23
+ /** The text that was last returned by yank() or yankPop(). */
24
+ get lastYankedText() {
25
+ return this._lastYankedText;
26
+ }
27
+ /**
28
+ * Push killed text onto the ring.
29
+ *
30
+ * If accumulating (consecutive kills without a reset), the text is merged
31
+ * with the top entry: "append" concatenates to the end, "prepend" prepends
32
+ * to the start. Otherwise a new entry is created, evicting the oldest if
33
+ * the ring is full.
34
+ */
35
+ push(text, direction) {
36
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.kill_ring_push", message: "killed text to ring", meta: { direction, length: text.length } });
37
+ if (this.accumulating && this.entries.length > 0) {
38
+ const top = this.entries[this.entries.length - 1];
39
+ this.entries[this.entries.length - 1] =
40
+ direction === "append" ? top + text : text + top;
41
+ }
42
+ else {
43
+ if (this.entries.length >= MAX_SIZE) {
44
+ this.entries.shift();
45
+ }
46
+ this.entries.push(text);
47
+ }
48
+ this.accumulating = true;
49
+ }
50
+ /**
51
+ * Return the most recent kill ring entry and enter yanking mode.
52
+ * Returns undefined if the ring is empty.
53
+ */
54
+ yank() {
55
+ if (this.entries.length === 0)
56
+ return undefined;
57
+ this.yankIndex = this.entries.length - 1;
58
+ this._isYanking = true;
59
+ this._lastYankedText = this.entries[this.yankIndex];
60
+ return this._lastYankedText;
61
+ }
62
+ /**
63
+ * Cycle to the next older entry in the ring (wrapping around).
64
+ * Must be called after yank(). Returns undefined if ring is empty
65
+ * or not in yanking state.
66
+ */
67
+ yankPop() {
68
+ if (!this._isYanking || this.entries.length === 0)
69
+ return undefined;
70
+ this.yankIndex =
71
+ (this.yankIndex - 1 + this.entries.length) % this.entries.length;
72
+ this._lastYankedText = this.entries[this.yankIndex];
73
+ return this._lastYankedText;
74
+ }
75
+ /** Stop consecutive-kill accumulation (called on non-kill keystrokes). */
76
+ resetAccumulation() {
77
+ this.accumulating = false;
78
+ }
79
+ /** Exit yank-cycling mode (called on non-yank keystrokes). */
80
+ resetYankState() {
81
+ this._isYanking = false;
82
+ this._lastYankedText = undefined;
83
+ this.yankIndex = -1;
84
+ }
85
+ }
86
+ exports.KillRing = KillRing;
@@ -18,6 +18,9 @@ const react_1 = require("react");
18
18
  const ink_1 = require("ink");
19
19
  const streaming_markdown_1 = require("./streaming-markdown");
20
20
  const image_paste_1 = require("./image-paste");
21
+ const kill_ring_1 = require("./kill-ring");
22
+ const input_keys_1 = require("./input-keys");
23
+ const image_ref_navigation_1 = require("./image-ref-navigation");
21
24
  // ─── Ouroboros Brand Palette (ANSI RGB) ─────────────────────────────
22
25
  // From packages/outlook-ui/src/style.css and ouroboros.bot
23
26
  const OURO = {
@@ -153,8 +156,10 @@ function QueuedMessages({ items }) {
153
156
  return (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" });
154
157
  return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", children: items.map((text, i) => ((0, jsx_runtime_1.jsxs)(ink_1.Text, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, children: "\u231B queued: " }), (0, jsx_runtime_1.jsxs)(ink_1.Text, { color: OURO.mist, children: ["\"", text, "\""] })] }, i))) }));
155
158
  }
159
+ // ─── Kill Ring (session-scoped) ─────────────────────────────────────
160
+ const killRing = new kill_ring_1.KillRing();
156
161
  // ─── Input ──────────────────────────────────────────────────────────
157
- function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agentName, model, onImageMap }) {
162
+ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agentName, model, onImageMap, onHistoryAdd }) {
158
163
  const [input, setInput] = (0, react_1.useState)("");
159
164
  const [cursorPos, setCursorPos] = (0, react_1.useState)(0); // cursor position within input
160
165
  const [tooltip, setTooltip] = (0, react_1.useState)("");
@@ -219,6 +224,9 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
219
224
  escTimerRef.current = setTimeout(() => {
220
225
  escTimerRef.current = null;
221
226
  if (inputRef.current) {
227
+ // Save to history before clearing (so Up arrow can retrieve it)
228
+ if (onHistoryAdd)
229
+ onHistoryAdd(inputRef.current);
222
230
  updateInput("");
223
231
  historyIdx.current = -1;
224
232
  setTooltip("Esc again to clear");
@@ -242,6 +250,9 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
242
250
  clearTimeout(escTimerRef.current);
243
251
  escTimerRef.current = null;
244
252
  }
253
+ // PageUp/PageDown: suppress (no text insertion, no action)
254
+ if (key.pageUp || key.pageDown)
255
+ return;
245
256
  if (key.return) {
246
257
  // Alt+Enter: detect via key.meta OR recent ESC (within 50ms — Ink splits \x1b\r)
247
258
  const recentEsc = (Date.now() - lastEscTime.current) < 50;
@@ -271,19 +282,43 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
271
282
  historyIdx.current = -1;
272
283
  return;
273
284
  }
274
- if (key.backspace || key.delete) {
285
+ // Forward delete key (fn+Backspace on macOS): delete character at cursor
286
+ if (key.delete && !key.backspace) {
287
+ if (key.meta) {
288
+ // Meta+Delete: kill to end of line (push to ring)
289
+ const result = (0, input_keys_1.handleKillToEnd)(inputRef.current, cursorRef.current, killRing);
290
+ updateInput(result.text, result.cursorPos);
291
+ }
292
+ else {
293
+ // Token-aware: check for image ref chip at cursor
294
+ const chip = (0, image_ref_navigation_1.deleteTokenAfter)(inputRef.current, cursorRef.current);
295
+ if (chip) {
296
+ updateInput(chip.text, chip.pos);
297
+ }
298
+ else {
299
+ const result = (0, input_keys_1.handleForwardDelete)(inputRef.current, cursorRef.current);
300
+ updateInput(result.text, result.cursorPos);
301
+ }
302
+ }
303
+ historyIdx.current = -1;
304
+ return;
305
+ }
306
+ // Backspace: delete character before cursor
307
+ if (key.backspace) {
275
308
  if (cursorRef.current > 0) {
276
- // Option+Backspace: delete word
277
- if (key.meta) {
278
- const before = inputRef.current.slice(0, cursorRef.current);
279
- const after = inputRef.current.slice(cursorRef.current);
280
- const wordStart = before.replace(/\s*\S+\s*$/, "");
281
- updateInput(wordStart + after, wordStart.length);
309
+ // Token-aware: check for image ref chip before cursor
310
+ const chip = (0, image_ref_navigation_1.deleteTokenBefore)(inputRef.current, cursorRef.current);
311
+ if (chip) {
312
+ updateInput(chip.text, chip.pos);
313
+ }
314
+ else if (key.meta) {
315
+ // Option+Backspace: delete word (also pushes to kill ring)
316
+ const result = (0, input_keys_1.handleKillWordBack)(inputRef.current, cursorRef.current, killRing);
317
+ updateInput(result.text, result.cursorPos);
282
318
  }
283
319
  else {
284
- const before = inputRef.current.slice(0, cursorRef.current - 1);
285
- const after = inputRef.current.slice(cursorRef.current);
286
- updateInput(before + after, cursorRef.current - 1);
320
+ const result = (0, input_keys_1.handleBackspace)(inputRef.current, cursorRef.current);
321
+ updateInput(result.text, result.cursorPos);
287
322
  }
288
323
  }
289
324
  historyIdx.current = -1;
@@ -300,7 +335,9 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
300
335
  setCursorPos(cursorRef.current);
301
336
  }
302
337
  else {
303
- cursorRef.current = Math.max(0, cursorRef.current - 1);
338
+ // Token-aware: hop over image ref chips
339
+ const chipStart = (0, image_ref_navigation_1.imageRefEndingAt)(inputRef.current, cursorRef.current);
340
+ cursorRef.current = chipStart !== undefined ? chipStart : Math.max(0, cursorRef.current - 1);
304
341
  setCursorPos(cursorRef.current);
305
342
  }
306
343
  return;
@@ -315,7 +352,9 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
315
352
  setCursorPos(cursorRef.current);
316
353
  }
317
354
  else {
318
- cursorRef.current = Math.min(inputRef.current.length, cursorRef.current + 1);
355
+ // Token-aware: hop over image ref chips
356
+ const chipEnd = (0, image_ref_navigation_1.imageRefStartingAt)(inputRef.current, cursorRef.current);
357
+ cursorRef.current = chipEnd !== undefined ? chipEnd : Math.min(inputRef.current.length, cursorRef.current + 1);
319
358
  setCursorPos(cursorRef.current);
320
359
  }
321
360
  return;
@@ -391,8 +430,134 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
391
430
  setCursorPos(inputRef.current.length);
392
431
  return;
393
432
  }
433
+ // ─── Emacs Navigation ─────────────────────────────────────────
434
+ // Ctrl+B: cursor left
435
+ if (key.ctrl && inputChar === "b") {
436
+ cursorRef.current = (0, input_keys_1.handleCursorLeft)(inputRef.current, cursorRef.current);
437
+ setCursorPos(cursorRef.current);
438
+ return;
439
+ }
440
+ // Ctrl+F: cursor right
441
+ if (key.ctrl && inputChar === "f") {
442
+ cursorRef.current = (0, input_keys_1.handleCursorRight)(inputRef.current, cursorRef.current);
443
+ setCursorPos(cursorRef.current);
444
+ return;
445
+ }
446
+ // Ctrl+P: history up (same as up arrow)
447
+ if (key.ctrl && inputChar === "p") {
448
+ if (historyIdx.current === -1 && queuedInputs.length > 0) {
449
+ const items = onPopQueue();
450
+ updateInput(items.join("\n"));
451
+ return;
452
+ }
453
+ if (history.length > 0) {
454
+ if (historyIdx.current === -1) {
455
+ savedInput.current = inputRef.current;
456
+ historyIdx.current = history.length - 1;
457
+ }
458
+ else if (historyIdx.current > 0) {
459
+ historyIdx.current--;
460
+ }
461
+ updateInput(history[historyIdx.current]);
462
+ }
463
+ return;
464
+ }
465
+ // Ctrl+N: history down (same as down arrow)
466
+ if (key.ctrl && inputChar === "n") {
467
+ if (historyIdx.current >= 0) {
468
+ if (historyIdx.current < history.length - 1) {
469
+ historyIdx.current++;
470
+ updateInput(history[historyIdx.current]);
471
+ }
472
+ else {
473
+ historyIdx.current = -1;
474
+ updateInput(savedInput.current);
475
+ }
476
+ }
477
+ return;
478
+ }
479
+ // Ctrl+H: token-aware backspace
480
+ if (key.ctrl && inputChar === "h") {
481
+ const chip = (0, image_ref_navigation_1.deleteTokenBefore)(inputRef.current, cursorRef.current);
482
+ if (chip) {
483
+ updateInput(chip.text, chip.pos);
484
+ }
485
+ else {
486
+ const result = (0, input_keys_1.handleBackspace)(inputRef.current, cursorRef.current);
487
+ updateInput(result.text, result.cursorPos);
488
+ }
489
+ historyIdx.current = -1;
490
+ return;
491
+ }
492
+ // Ctrl+D: forward-delete when input present, exit when empty
493
+ if (key.ctrl && inputChar === "d") {
494
+ if (inputRef.current.length === 0) {
495
+ handleCtrlC();
496
+ }
497
+ else {
498
+ const result = (0, input_keys_1.handleForwardDelete)(inputRef.current, cursorRef.current);
499
+ updateInput(result.text, result.cursorPos);
500
+ }
501
+ return;
502
+ }
503
+ // ─── Kill Ring Keybindings ─────────────────────────────────────
504
+ // Ctrl+K: kill from cursor to end of line
505
+ if (key.ctrl && inputChar === "k") {
506
+ const result = (0, input_keys_1.handleKillToEnd)(inputRef.current, cursorRef.current, killRing);
507
+ updateInput(result.text, result.cursorPos);
508
+ return;
509
+ }
510
+ // Ctrl+U: kill from start to cursor
511
+ if (key.ctrl && inputChar === "u") {
512
+ const result = (0, input_keys_1.handleKillToStart)(inputRef.current, cursorRef.current, killRing);
513
+ updateInput(result.text, result.cursorPos);
514
+ return;
515
+ }
516
+ // Ctrl+W: kill word before cursor (token-aware)
517
+ if (key.ctrl && inputChar === "w") {
518
+ const chip = (0, image_ref_navigation_1.deleteTokenBefore)(inputRef.current, cursorRef.current);
519
+ if (chip) {
520
+ updateInput(chip.text, chip.pos);
521
+ }
522
+ else {
523
+ const result = (0, input_keys_1.handleKillWordBack)(inputRef.current, cursorRef.current, killRing);
524
+ updateInput(result.text, result.cursorPos);
525
+ }
526
+ return;
527
+ }
528
+ // Ctrl+Y: yank from kill ring
529
+ if (key.ctrl && inputChar === "y") {
530
+ const result = (0, input_keys_1.handleYank)(inputRef.current, cursorRef.current, killRing);
531
+ if (result)
532
+ updateInput(result.text, result.cursorPos);
533
+ return;
534
+ }
535
+ // Alt+Y: yank-pop (cycle kill ring)
536
+ if (key.meta && inputChar === "y") {
537
+ const result = (0, input_keys_1.handleYankPop)(inputRef.current, cursorRef.current, killRing);
538
+ if (result)
539
+ updateInput(result.text, result.cursorPos);
540
+ return;
541
+ }
542
+ // ─── Non-kill/non-yank keystroke resets ────────────────────────
543
+ killRing.resetAccumulation();
544
+ killRing.resetYankState();
394
545
  // Regular character: insert at cursor position
395
546
  if (!key.ctrl && !key.meta && inputChar) {
547
+ // Detect raw escape sequences before inserting text
548
+ const escClass = (0, input_keys_1.classifyEscapeSequence)(inputChar);
549
+ if (escClass === "home") {
550
+ cursorRef.current = (0, input_keys_1.handleHome)(inputRef.current, cursorRef.current);
551
+ setCursorPos(cursorRef.current);
552
+ return;
553
+ }
554
+ if (escClass === "end") {
555
+ cursorRef.current = (0, input_keys_1.handleEnd)(inputRef.current, cursorRef.current);
556
+ setCursorPos(cursorRef.current);
557
+ return;
558
+ }
559
+ if (escClass === "ignore")
560
+ return; // PageUp/PageDown/mouse wheel fallback
396
561
  const before = inputRef.current.slice(0, cursorRef.current);
397
562
  const after = inputRef.current.slice(cursorRef.current);
398
563
  updateInput(before + inputChar + after, cursorRef.current + 1);
@@ -406,11 +571,11 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
406
571
  : (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: input[cursorPos] ?? " " }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: input.slice(cursorPos + 1) })] }))] })), (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: "─".repeat(termWidth()) }), (0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsxs)(ink_1.Text, { dimColor: true, children: [" ", agentName, model ? ` · ${model}` : "", " \u00B7 /help"] }), (0, jsx_runtime_1.jsx)(ink_1.Box, { flexGrow: 1 }), tooltip ? (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: tooltip }) : (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: "opt+enter for newline" })] })] }));
407
572
  }
408
573
  // ─── Main TUI Component ─────────────────────────────────────────────
409
- function OuroTui({ agentName, model, completedMessages, inputHistory, queuedInputs, live, elapsedSeconds, contextPercent, onSubmit, onCtrlC, onPopQueue, cwd, resumeInfo, onImageMap, }) {
574
+ function OuroTui({ agentName, model, completedMessages, inputHistory, queuedInputs, live, elapsedSeconds, contextPercent, onSubmit, onCtrlC, onPopQueue, cwd, resumeInfo, onImageMap, onHistoryAdd, }) {
410
575
  return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", children: [(0, jsx_runtime_1.jsx)(ink_1.Static, { items: [{ id: "__header__" }, ...completedMessages], children: (item, index) => {
411
576
  if (index === 0) {
412
577
  return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", marginBottom: 2, children: [(0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }), (0, jsx_runtime_1.jsx)(Header, { agentName: agentName, model: model, contextPercent: contextPercent, cwd: cwd, resumeInfo: resumeInfo }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, dimColor: true, children: " Ctrl-C twice to exit \u00b7 \u2191\u2193 history \u00b7 Esc clear \u00b7 opt+Enter newline" })] }, "header"));
413
578
  }
414
579
  return (0, jsx_runtime_1.jsx)(MessageBlock, { msg: item }, item.id);
415
- } }), (live.loading || live.streamingText || live.activeTool) ? (0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }) : null, (0, jsx_runtime_1.jsx)(LiveArea, { live: live, elapsed: elapsedSeconds }), (live.loading || live.streamingText || live.activeTool) ? (0, jsx_runtime_1.jsx)(ink_1.Box, { marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }) : null, queuedInputs.length > 0 ? (0, jsx_runtime_1.jsx)(QueuedMessages, { items: queuedInputs }) : null, (0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }), (0, jsx_runtime_1.jsx)(InputArea, { onSubmit: onSubmit, onCtrlC: onCtrlC, history: inputHistory, queuedInputs: queuedInputs, onPopQueue: onPopQueue, agentName: agentName, model: model, onImageMap: onImageMap })] }));
580
+ } }), (live.loading || live.streamingText || live.activeTool) ? (0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }) : null, (0, jsx_runtime_1.jsx)(LiveArea, { live: live, elapsed: elapsedSeconds }), (live.loading || live.streamingText || live.activeTool) ? (0, jsx_runtime_1.jsx)(ink_1.Box, { marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }) : null, queuedInputs.length > 0 ? (0, jsx_runtime_1.jsx)(QueuedMessages, { items: queuedInputs }) : null, (0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }), (0, jsx_runtime_1.jsx)(InputArea, { onSubmit: onSubmit, onCtrlC: onCtrlC, history: inputHistory, queuedInputs: queuedInputs, onPopQueue: onPopQueue, agentName: agentName, model: model, onImageMap: onImageMap, onHistoryAdd: onHistoryAdd })] }));
416
581
  }
@@ -169,6 +169,10 @@ class TuiStore {
169
169
  this._inputHistory.push(text);
170
170
  this.notify();
171
171
  }
172
+ /** Add text to input history only (no completed message, no notify). Used by ESC-to-clear. */
173
+ addToHistoryOnly(text) {
174
+ this._inputHistory.push(text);
175
+ }
172
176
  /** Seed input history from previous session messages (display only, no rendering) */
173
177
  seedHistory(texts) {
174
178
  this._inputHistory.push(...texts);
@@ -618,6 +618,7 @@ async function runCliSession(options) {
618
618
  cwd: process.cwd().replace(process.env.HOME ?? "", "~"),
619
619
  resumeInfo,
620
620
  onImageMap: (images) => { pendingImages = images; },
621
+ onHistoryAdd: (text) => { storeRef.addToHistoryOnly(text); },
621
622
  });
622
623
  }
623
624
  inkRef = ink.render(React.createElement(TuiRoot), { exitOnCtrlC: false, patchConsole: false });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.260",
3
+ "version": "0.1.0-alpha.261",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",