@ouro.bot/cli 0.1.0-alpha.254 → 0.1.0-alpha.256

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,6 +1,22 @@
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.256",
6
+ "changes": [
7
+ "fix(tui): cursor renders as inverse character (not inserted block) \u2014 matches standard terminal cursor behavior.",
8
+ "fix(tui): Alt+Enter via ESC-timing (50ms window) instead of raw stdin handler that interfered with arrow keys.",
9
+ "fix(tui): image path regex now matches backslash-escaped spaces from macOS drag-drop."
10
+ ]
11
+ },
12
+ {
13
+ "version": "0.1.0-alpha.255",
14
+ "changes": [
15
+ "feat(tui): session resume shows last messages as regular chat (no dimmed preview) with teal resume banner in header.",
16
+ "feat(tui): image/file drag-and-drop \u2014 detects image paths in pasted text, reads to base64, inserts [Image #N] references, sends as image_url content blocks to model.",
17
+ "refactor(tui): removed custom history-* roles and addSessionHistory (KISS/DRY)."
18
+ ]
19
+ },
4
20
  {
5
21
  "version": "0.1.0-alpha.254",
6
22
  "changes": [
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.IMAGE_EXTENSION_REGEX = void 0;
37
+ exports.isImagePath = isImagePath;
38
+ exports.unescapePath = unescapePath;
39
+ exports.tryReadImage = tryReadImage;
40
+ exports.formatImageRef = formatImageRef;
41
+ exports.replacePathsWithRefs = replacePathsWithRefs;
42
+ exports.processSubmitInput = processSubmitInput;
43
+ exports.resolveImageContent = resolveImageContent;
44
+ /**
45
+ * Image paste detection and resolution for the TUI.
46
+ *
47
+ * When a user drags an image file onto the terminal, macOS pastes the absolute
48
+ * path (often with backslash-escaped spaces). This module detects image paths
49
+ * in the input text, replaces them with `[Image #N]` references, reads the
50
+ * files, and produces OpenAI-compatible `image_url` content blocks.
51
+ */
52
+ const promises_1 = require("fs/promises");
53
+ const path = __importStar(require("path"));
54
+ const runtime_1 = require("../../nerves/runtime");
55
+ /** Matches image file extensions (case-insensitive) */
56
+ exports.IMAGE_EXTENSION_REGEX = /\.(png|jpe?g|gif|webp)$/i;
57
+ /** Check whether a string (possibly backslash-escaped) ends with an image extension */
58
+ function isImagePath(str) {
59
+ return exports.IMAGE_EXTENSION_REGEX.test(unescapePath(str));
60
+ }
61
+ /** Convert backslash-escaped spaces to plain spaces (macOS drag-drop format) */
62
+ function unescapePath(str) {
63
+ return str.replace(/\\ /g, " ");
64
+ }
65
+ /** Map file extension to MIME media type */
66
+ function extensionToMediaType(ext) {
67
+ switch (ext.toLowerCase()) {
68
+ case ".jpg":
69
+ case ".jpeg":
70
+ return "image/jpeg";
71
+ case ".png":
72
+ return "image/png";
73
+ case ".gif":
74
+ return "image/gif";
75
+ case ".webp":
76
+ return "image/webp";
77
+ default:
78
+ return "image/png";
79
+ }
80
+ }
81
+ /**
82
+ * Read an image file and return base64 + media type.
83
+ * Returns null on any error (file not found, permission denied, etc.)
84
+ */
85
+ async function tryReadImage(absolutePath) {
86
+ try {
87
+ const buffer = await (0, promises_1.readFile)(absolutePath);
88
+ const ext = path.extname(absolutePath);
89
+ return {
90
+ base64: Buffer.from(buffer).toString("base64"),
91
+ mediaType: extensionToMediaType(ext),
92
+ };
93
+ }
94
+ catch {
95
+ (0, runtime_1.emitNervesEvent)({
96
+ component: "senses",
97
+ event: "senses.image_read_error",
98
+ message: `Failed to read image: ${absolutePath}`,
99
+ meta: { path: absolutePath },
100
+ });
101
+ return null;
102
+ }
103
+ }
104
+ /** Format an image reference placeholder */
105
+ function formatImageRef(n) {
106
+ return `[Image #${n}]`;
107
+ }
108
+ /**
109
+ * Scan input for absolute path tokens ending with image extensions.
110
+ * Matches `/` followed by non-whitespace chars (allowing `\ ` for escaped spaces)
111
+ * ending with an image extension.
112
+ */
113
+ function replacePathsWithRefs(input) {
114
+ const images = new Map();
115
+ // Match absolute paths starting with / that end with an image extension.
116
+ // Allow backslash-escaped spaces (like `\ ` from macOS drag-drop).
117
+ const pathRegex = /(\/(?:[^\s]|\\ )+\.(?:png|jpe?g|gif|webp))/gi;
118
+ let counter = 0;
119
+ const text = input.replace(pathRegex, (match) => {
120
+ counter++;
121
+ images.set(counter, unescapePath(match));
122
+ return formatImageRef(counter);
123
+ });
124
+ return { text, images };
125
+ }
126
+ /**
127
+ * Process submit input: detect image paths, replace with refs, return map.
128
+ * This is the entry point called by InputArea on submit.
129
+ */
130
+ function processSubmitInput(text) {
131
+ return replacePathsWithRefs(text);
132
+ }
133
+ /**
134
+ * Build OpenAI content parts from text + image map.
135
+ * For each image in the map, reads the file and creates an `image_url` content part.
136
+ * Images that fail to read are silently skipped.
137
+ * Returns `[{ type: "text", text }]` when no images resolve.
138
+ */
139
+ async function resolveImageContent(text, images) {
140
+ if (images.size === 0) {
141
+ return [{ type: "text", text }];
142
+ }
143
+ const parts = [];
144
+ // Read all images in parallel
145
+ const entries = Array.from(images.entries());
146
+ const results = await Promise.all(entries.map(async ([, absolutePath]) => tryReadImage(absolutePath)));
147
+ for (const result of results) {
148
+ if (result) {
149
+ parts.push({
150
+ type: "image_url",
151
+ image_url: { url: `data:${result.mediaType};base64,${result.base64}` },
152
+ });
153
+ }
154
+ }
155
+ // Always include the text part
156
+ parts.push({ type: "text", text });
157
+ // If no images resolved, return just text
158
+ if (parts.length === 1) {
159
+ return [{ type: "text", text }];
160
+ }
161
+ return parts;
162
+ }
@@ -17,6 +17,7 @@ const jsx_runtime_1 = require("react/jsx-runtime");
17
17
  const react_1 = require("react");
18
18
  const ink_1 = require("ink");
19
19
  const streaming_markdown_1 = require("./streaming-markdown");
20
+ const image_paste_1 = require("./image-paste");
20
21
  // ─── Ouroboros Brand Palette (ANSI RGB) ─────────────────────────────
21
22
  // From packages/outlook-ui/src/style.css and ouroboros.bot
22
23
  const OURO = {
@@ -49,7 +50,7 @@ function safeWidth() {
49
50
  function termWidth() {
50
51
  return process.stdout.columns || 80;
51
52
  }
52
- function Header({ agentName, model, contextPercent, cwd }) {
53
+ function Header({ agentName, model, contextPercent, cwd, resumeInfo }) {
53
54
  const showCtx = contextPercent > 0;
54
55
  const info = [agentName, model, cwd, showCtx ? `ctx ${contextPercent}%` : ""].filter(Boolean).join(" · ");
55
56
  // 3-segment snake: TAIL (fixed) + MIDDLE (stretches) + HEAD (fixed)
@@ -66,7 +67,7 @@ function Header({ agentName, model, contextPercent, cwd }) {
66
67
  const line1 = TAIL1 + " ".repeat(middleLen) + HEAD1;
67
68
  const line2 = TAIL2 + "_".repeat(middleLen) + HEAD2;
68
69
  const line3text = (info.length > middleLen ? info.slice(0, middleLen) : info) + "_".repeat(textPad);
69
- return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: line1 }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: line2 }), (0, jsx_runtime_1.jsxs)(ink_1.Text, { color: OURO.scale, children: [TAIL3, (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.glow, children: line3text }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: HEAD3 })] })] }));
70
+ return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: line1 }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: line2 }), (0, jsx_runtime_1.jsxs)(ink_1.Text, { color: OURO.scale, children: [TAIL3, (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.glow, children: line3text }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: HEAD3 })] }), resumeInfo ? ((0, jsx_runtime_1.jsxs)(ink_1.Text, { color: OURO.teal, children: [" resuming \u00b7 ", resumeInfo.messageCount, " messages \u00b7 last active ", resumeInfo.timeAgo] })) : null] }));
70
71
  }
71
72
  // ─── Message Rendering ──────────────────────────────────────────────
72
73
  // Flow control tools are invisible to the user — they are internal agent mechanics
@@ -85,19 +86,6 @@ function MessageBlock({ msg }) {
85
86
  return (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" });
86
87
  return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", children: visibleCalls.map((tc, i) => (0, jsx_runtime_1.jsx)(ToolResultLine, { tc: tc }, i)) }));
87
88
  }
88
- // ── Session history (dimmed recap of recent exchanges) ──
89
- if (msg.role === "history-summary") {
90
- return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, children: msg.content }) }));
91
- }
92
- if (msg.role === "history-user") {
93
- return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", marginTop: 1, children: [(0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, bold: true, children: ") " }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, bold: true, children: msg.content })] }), (0, jsx_runtime_1.jsx)(ink_1.Box, { marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) })] }));
94
- }
95
- if (msg.role === "history-assistant") {
96
- return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", marginBottom: 1, children: [msg.content ? (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, children: msg.content }) : null, (0, jsx_runtime_1.jsx)(ink_1.Box, { marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) })] }));
97
- }
98
- if (msg.role === "history-end") {
99
- return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.separator, children: "─".repeat(termWidth()) }) }));
100
- }
101
89
  if (msg.role === "user") {
102
90
  return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", marginTop: 1, children: [msg.content ? (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, bold: true, children: msg.content }) : null, (0, jsx_runtime_1.jsx)(ink_1.Box, { marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) })] }));
103
91
  }
@@ -166,7 +154,7 @@ function QueuedMessages({ items }) {
166
154
  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))) }));
167
155
  }
168
156
  // ─── Input ──────────────────────────────────────────────────────────
169
- function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agentName, model }) {
157
+ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agentName, model, onImageMap }) {
170
158
  const [input, setInput] = (0, react_1.useState)("");
171
159
  const [cursorPos, setCursorPos] = (0, react_1.useState)(0); // cursor position within input
172
160
  const [tooltip, setTooltip] = (0, react_1.useState)("");
@@ -175,23 +163,10 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
175
163
  const cursorRef = (0, react_1.useRef)(0);
176
164
  const historyIdx = (0, react_1.useRef)(-1);
177
165
  const savedInput = (0, react_1.useRef)("");
178
- // Raw stdin handler for Alt+Enter (ESC + CR/LF) — Ink 3.2's useInput splits
179
- // \x1b\r into separate events, so we catch the combined sequence here.
180
- const { stdin } = (0, ink_1.useStdin)();
181
- (0, react_1.useEffect)(() => {
182
- if (!stdin)
183
- return;
184
- const onData = (data) => {
185
- const str = data.toString("utf-8");
186
- if (str === "\x1b\r" || str === "\x1b\n") {
187
- const before = inputRef.current.slice(0, cursorRef.current);
188
- const after = inputRef.current.slice(cursorRef.current);
189
- updateInput(before + "\n" + after, cursorRef.current + 1);
190
- }
191
- };
192
- stdin.on("data", onData);
193
- return () => { stdin.off("data", onData); };
194
- }, [stdin]); // eslint-disable-line react-hooks/exhaustive-deps
166
+ // Track last ESC timestamp for Alt+Enter detection.
167
+ // Ink 3.2 splits \x1b\r into separate escape + return events.
168
+ // We detect the pattern by checking if Return arrives within 50ms of Escape.
169
+ const lastEscTime = (0, react_1.useRef)(0);
195
170
  const tooltipTimerRef = (0, react_1.useRef)(null);
196
171
  (0, react_1.useEffect)(() => () => {
197
172
  if (tooltipTimerRef.current)
@@ -235,6 +210,7 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
235
210
  // Any non-Ctrl-C input clears tooltip
236
211
  setTooltip("");
237
212
  if (key.escape) {
213
+ lastEscTime.current = Date.now();
238
214
  if (inputRef.current) {
239
215
  updateInput("");
240
216
  historyIdx.current = -1;
@@ -255,8 +231,10 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
255
231
  return;
256
232
  }
257
233
  if (key.return) {
258
- // Alt+Enter or Shift+Enter: insert newline
259
- if (key.meta || key.shift) {
234
+ // Alt+Enter: detect via key.meta OR recent ESC (within 50ms — Ink splits \x1b\r)
235
+ const recentEsc = (Date.now() - lastEscTime.current) < 50;
236
+ if (key.meta || key.shift || recentEsc) {
237
+ lastEscTime.current = 0;
260
238
  const before = inputRef.current.slice(0, cursorRef.current);
261
239
  const after = inputRef.current.slice(cursorRef.current);
262
240
  updateInput(before + "\n" + after, cursorRef.current + 1);
@@ -270,8 +248,13 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
270
248
  return;
271
249
  }
272
250
  const text = inputRef.current;
273
- if (text.trim())
274
- onSubmit(text);
251
+ if (text.trim()) {
252
+ const { text: processedText, images } = (0, image_paste_1.processSubmitInput)(text);
253
+ if (images.size > 0 && onImageMap) {
254
+ onImageMap(images);
255
+ }
256
+ onSubmit(images.size > 0 ? processedText : text);
257
+ }
275
258
  updateInput("");
276
259
  historyIdx.current = -1;
277
260
  return;
@@ -406,14 +389,16 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
406
389
  });
407
390
  const isMultiline = input.includes("\n");
408
391
  const inputLines = input.split("\n");
409
- return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: "─".repeat(termWidth()) }), isMultiline ? ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", children: inputLines.map((line, i) => ((0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.teal, bold: true, children: i === 0 ? ") " : "· " }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: line }), i === inputLines.length - 1 ? (cursorVisible ? (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: "█" }) : (0, jsx_runtime_1.jsx)(ink_1.Text, { children: " " })) : null] }, i))) })) : ((0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.teal, bold: true, children: ") " }), !input && queuedInputs.length > 0 ? ((0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, children: "Press up to edit queued messages" })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: input.slice(0, cursorPos) }), cursorVisible ? (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: "█" }) : (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 + (cursorVisible ? 0 : 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" })] })] }));
392
+ return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: "─".repeat(termWidth()) }), isMultiline ? ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", children: inputLines.map((line, i) => ((0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.teal, bold: true, children: i === 0 ? ") " : "· " }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: line }), i === inputLines.length - 1 ? (cursorVisible ? (0, jsx_runtime_1.jsx)(ink_1.Text, { inverse: true, children: " " }) : (0, jsx_runtime_1.jsx)(ink_1.Text, { children: " " })) : null] }, i))) })) : ((0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.teal, bold: true, children: ") " }), !input && queuedInputs.length > 0 ? ((0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, children: "Press up to edit queued messages" })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: input.slice(0, cursorPos) }), cursorVisible
393
+ ? (0, jsx_runtime_1.jsx)(ink_1.Text, { inverse: true, children: input[cursorPos] ?? " " })
394
+ : (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" })] })] }));
410
395
  }
411
396
  // ─── Main TUI Component ─────────────────────────────────────────────
412
- function OuroTui({ agentName, model, completedMessages, inputHistory, queuedInputs, live, elapsedSeconds, contextPercent, onSubmit, onCtrlC, onPopQueue, cwd, }) {
397
+ function OuroTui({ agentName, model, completedMessages, inputHistory, queuedInputs, live, elapsedSeconds, contextPercent, onSubmit, onCtrlC, onPopQueue, cwd, resumeInfo, onImageMap, }) {
413
398
  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) => {
414
399
  if (index === 0) {
415
- 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 }), (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"));
400
+ 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"));
416
401
  }
417
402
  return (0, jsx_runtime_1.jsx)(MessageBlock, { msg: item }, item.id);
418
- } }), (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 })] }));
403
+ } }), (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 })] }));
419
404
  }
@@ -173,17 +173,17 @@ class TuiStore {
173
173
  seedHistory(texts) {
174
174
  this._inputHistory.push(...texts);
175
175
  }
176
- /** Show session resume context: summary line + last N exchanges (dimmed), then separator */
177
- addSessionHistory(summary, exchanges) {
178
- const msgs = [
179
- { id: "history-summary", role: "history-summary", content: summary },
180
- ...exchanges.map((ex, i) => ({
181
- id: `history-${i}`,
182
- role: (ex.role === "user" ? "history-user" : "history-assistant"),
183
- content: ex.content,
184
- })),
185
- { id: "history-end", role: "history-end", content: "" },
186
- ];
176
+ /** Push previous session exchanges as normal user/assistant messages (for resume display) */
177
+ addResumeMessages(exchanges) {
178
+ if (exchanges.length === 0) {
179
+ this.notify();
180
+ return;
181
+ }
182
+ const msgs = exchanges.map((ex, i) => ({
183
+ id: `resume-${i}`,
184
+ role: ex.role,
185
+ content: ex.content,
186
+ }));
187
187
  this._completed = [...msgs, ...this._completed];
188
188
  this.notify();
189
189
  }
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.InputQueue = exports.MarkdownStreamer = exports.InputController = exports.Spinner = exports.StreamingWordWrapper = exports.wrapCliText = exports.formatEchoedInputSummary = void 0;
37
37
  exports.formatPendingPrefix = formatPendingPrefix;
38
38
  exports.getCliContinuityIngressTexts = getCliContinuityIngressTexts;
39
+ exports.formatTimeAgo = formatTimeAgo;
39
40
  exports.writeCliAsyncAssistantMessage = writeCliAsyncAssistantMessage;
40
41
  exports.pauseActiveSpinner = pauseActiveSpinner;
41
42
  exports.resumeActiveSpinner = resumeActiveSpinner;
@@ -75,6 +76,7 @@ const bundle_meta_1 = require("../heart/daemon/hooks/bundle-meta");
75
76
  const agent_config_v2_1 = require("../heart/daemon/hooks/agent-config-v2");
76
77
  const bundle_manifest_1 = require("../mind/bundle-manifest");
77
78
  const cli_layout_1 = require("./cli-layout");
79
+ const image_paste_1 = require("./cli/image-paste");
78
80
  const spinner_imperative_1 = require("./cli/spinner-imperative");
79
81
  const tool_display_1 = require("./cli/tool-display");
80
82
  var cli_layout_2 = require("./cli-layout");
@@ -513,6 +515,8 @@ async function runCliSession(options) {
513
515
  const useTui = !options._testInputSource && process.stdin.isTTY === true;
514
516
  let currentAbort = null;
515
517
  let closed = false;
518
+ // eslint-disable-next-line prefer-const -- set by onImageMap callback during input
519
+ let pendingImages = null;
516
520
  let cliCallbacks;
517
521
  let tuiStore = null;
518
522
  let inkRef = null;
@@ -535,8 +539,7 @@ async function runCliSession(options) {
535
539
  .filter((msg) => msg.role === "user" && typeof msg.content === "string")
536
540
  .map(msg => msg.content);
537
541
  tuiStore.seedHistory(prevUserMsgs);
538
- // Show session resume context: summary + last few exchanges (dimmed)
539
- /* v8 ignore start -- session resume display: integration-only, tested visually @preserve */
542
+ // Show session resume context: last 2 exchanges as normal messages
540
543
  if (messages.length > 1) {
541
544
  const userAssistantMsgs = messages.filter((m) => (m.role === "user" || m.role === "assistant") && typeof m.content === "string" && m.content.trim().length > 0);
542
545
  // Extract last 2 exchanges (up to 4 messages)
@@ -544,12 +547,15 @@ async function runCliSession(options) {
544
547
  for (let i = userAssistantMsgs.length - 1; i >= 0 && lastExchanges.length < 4; i--) {
545
548
  lastExchanges.unshift({ role: userAssistantMsgs[i].role, content: userAssistantMsgs[i].content });
546
549
  }
547
- const msgCount = messages.filter(m => m.role === "user" || m.role === "assistant").length;
548
- const timeAgo = options.lastActivityAt ? formatTimeAgo(new Date(options.lastActivityAt)) : null;
549
- const summary = ` resuming session · ${msgCount} messages${timeAgo ? ` · last active ${timeAgo}` : ""} · showing recent`;
550
- tuiStore.addSessionHistory(summary, lastExchanges);
550
+ tuiStore.addResumeMessages(lastExchanges);
551
551
  }
552
- /* v8 ignore stop */
552
+ // Compute resumeInfo for header banner
553
+ const resumeInfo = messages.length > 1
554
+ ? {
555
+ messageCount: messages.filter(m => m.role === "user" || m.role === "assistant").length,
556
+ timeAgo: options.lastActivityAt ? formatTimeAgo(new Date(options.lastActivityAt)) : "unknown",
557
+ }
558
+ : undefined;
553
559
  // Ctrl-C state machine (Claude Code behavior):
554
560
  // During generation: abort current request
555
561
  // Idle with text: clear input
@@ -610,6 +616,8 @@ async function runCliSession(options) {
610
616
  onPopQueue: () => { const items = storeRef.popAllQueuedForEditing(); inputQueue.drainAll(); return items; },
611
617
  headerShown: storeRef.headerShown,
612
618
  cwd: process.cwd().replace(process.env.HOME ?? "", "~"),
619
+ resumeInfo,
620
+ onImageMap: (images) => { pendingImages = images; },
613
621
  });
614
622
  }
615
623
  inkRef = ink.render(React.createElement(TuiRoot), { exitOnCtrlC: false, patchConsole: false });
@@ -811,12 +819,19 @@ async function runCliSession(options) {
811
819
  if (tuiStore)
812
820
  tuiStore.suppressInput();
813
821
  inputCtrl?.suppress(() => { currentAbort?.abort(); });
822
+ // Resolve pending image content before the turn executes
823
+ let contentParts = null;
824
+ const currentImages = pendingImages;
825
+ if (currentImages && currentImages.size > 0) {
826
+ contentParts = await (0, image_paste_1.resolveImageContent)(input, currentImages);
827
+ pendingImages = null;
828
+ }
814
829
  let result;
815
830
  try {
816
831
  if (options.runTurn) {
817
832
  // Pipeline-based turn: the runTurn callback handles user message assembly,
818
833
  // pending drain, trust gate, runAgent, postTurn, and token accumulation.
819
- result = await options.runTurn(messages, input, cliCallbacks, currentAbort.signal, effectiveToolContext);
834
+ result = await options.runTurn(messages, input, cliCallbacks, currentAbort.signal, effectiveToolContext, contentParts ?? undefined);
820
835
  // Handle pipeline-intercepted commands with loop-control side effects
821
836
  if (result?.turnOutcome === "command") {
822
837
  if (result.commandAction === "exit") {
@@ -838,7 +853,10 @@ async function runCliSession(options) {
838
853
  else {
839
854
  // Legacy path: inline runAgent (used by serpent guide and tests)
840
855
  const prefix = options.getContentPrefix?.();
841
- messages.push({ role: "user", content: prefix ? `${prefix}\n\n${input}` : input });
856
+ const userContent = contentParts
857
+ ? contentParts
858
+ : (prefix ? `${prefix}\n\n${input}` : input);
859
+ messages.push({ role: "user", content: userContent });
842
860
  const traceId = (0, nerves_1.createTraceId)();
843
861
  result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
844
862
  toolChoiceRequired: getEffectiveToolChoiceRequired(),
@@ -973,7 +991,7 @@ async function main(agentName, options) {
973
991
  onAsyncAssistantMessage: async (messages, _assistantMessage) => {
974
992
  (0, context_1.postTurn)(messages, sessPath, undefined, undefined, sessionState);
975
993
  },
976
- runTurn: async (messages, userInput, callbacks, signal, toolContext) => {
994
+ runTurn: async (messages, userInput, callbacks, signal, toolContext, userContent) => {
977
995
  // Run the full per-turn pipeline: resolve -> gate -> session -> drain -> runAgent -> postTurn -> tokens
978
996
  // User message passed via input.messages so the pipeline can prepend pending messages to it.
979
997
  const failoverState = cliFailoverState;
@@ -997,7 +1015,7 @@ async function main(agentName, options) {
997
1015
  channel: "cli",
998
1016
  sessionKey: "session",
999
1017
  capabilities: cliCapabilities,
1000
- messages: [{ role: "user", content: userInput }],
1018
+ messages: [{ role: "user", content: userContent ?? userInput }],
1001
1019
  continuityIngressTexts: getCliContinuityIngressTexts(userInput),
1002
1020
  callbacks: failoverAwareCallbacks,
1003
1021
  friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.254",
3
+ "version": "0.1.0-alpha.256",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",