mu-harness 0.20.0 → 0.22.1

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.
@@ -1,5 +1,5 @@
1
1
  import { createWsClient } from './ws-client.js';
2
- import { textOf } from './wire.js';
2
+ import { partsToAttachments, textOf } from './wire.js';
3
3
  function deferred() {
4
4
  let resolve;
5
5
  const promise = new Promise((r) => (resolve = r));
@@ -31,6 +31,9 @@ export async function connectHarness(opts) {
31
31
  let models = [];
32
32
  let modelSelected = '';
33
33
  let commands = [];
34
+ // Mutated in place by the connect-time `capabilities` frame so the host (read once by
35
+ // ChatApp after connect) reflects the server's actual model modalities.
36
+ const features = { ...(opts.features ?? {}) };
34
37
  const sessionsById = new Map();
35
38
  const subSessions = new Map();
36
39
  const approvalListeners = new Set();
@@ -58,7 +61,18 @@ export async function connectHarness(opts) {
58
61
  tools: [],
59
62
  send: (input) => {
60
63
  if (!readonly) {
61
- client.send({ type: 'chat', sessionId: id, text: typeof input === 'string' ? input : textOf(input) });
64
+ if (typeof input === 'string') {
65
+ client.send({ type: 'chat', sessionId: id, text: input });
66
+ }
67
+ else {
68
+ const attachments = partsToAttachments(input);
69
+ client.send({
70
+ type: 'chat',
71
+ sessionId: id,
72
+ text: textOf(input),
73
+ ...(attachments.length > 0 ? { attachments } : {}),
74
+ });
75
+ }
62
76
  }
63
77
  return Promise.resolve();
64
78
  },
@@ -126,6 +140,10 @@ export async function connectHarness(opts) {
126
140
  case 'commands':
127
141
  commands = frame.commands;
128
142
  return;
143
+ case 'capabilities':
144
+ features.vision = frame.vision;
145
+ features.audio = frame.audio;
146
+ return;
129
147
  case 'models:listed': {
130
148
  models = frame.models.map((m) => ({ id: m.id, ownedBy: m.ownedBy }));
131
149
  modelSelected = frame.selected;
@@ -281,7 +299,7 @@ export async function connectHarness(opts) {
281
299
  initialThinking: opts.initialThinking ?? false,
282
300
  saveThinking: opts.saveThinking ?? (() => { }),
283
301
  history: opts.history,
284
- features: opts.features,
302
+ features,
285
303
  banner: opts.banner,
286
304
  minimal: opts.minimal,
287
305
  commands: () => commands.map((c) => ({ name: c.command.replace(/^\//, ''), description: c.description })),
@@ -1,6 +1,6 @@
1
1
  export * from './protocol.js';
2
2
  export { type SessionSummaryWire, type PersistedSessionWire, type SessionService, createSessionService } from './session-service.js';
3
- export { type WireMessage, messagesToWire } from './wire.js';
3
+ export { attachmentsToParts, messagesToWire, partsToAttachments, type WireAttachment, type WireMessage } from './wire.js';
4
4
  export { type WebSocketAdapter, type WebSocketAdapterOptions, webSocketAdapter } from './server.js';
5
5
  export { type ConnectHarnessOptions, connectHarness, type RemoteHarness } from './client.js';
6
6
  export { createWsClient, type WsClient, type WsClientOptions } from './ws-client.js';
@@ -1,6 +1,6 @@
1
1
  export * from './protocol.js';
2
2
  export { createSessionService } from './session-service.js';
3
- export { messagesToWire } from './wire.js';
3
+ export { attachmentsToParts, messagesToWire, partsToAttachments } from './wire.js';
4
4
  export { webSocketAdapter } from './server.js';
5
5
  export { connectHarness } from './client.js';
6
6
  export { createWsClient } from './ws-client.js';
@@ -1,5 +1,5 @@
1
1
  import type { PersistedSessionWire, SessionSummaryWire } from './session-service.js';
2
- import type { WireMessage } from './wire.js';
2
+ import type { WireAttachment, WireMessage } from './wire.js';
3
3
  import type { ApprovalAction, PendingApproval } from '../../permissions/index.js';
4
4
  import type { Message, Usage } from 'mu-core';
5
5
  export interface WireModel {
@@ -10,6 +10,7 @@ export type WsInbound = {
10
10
  type: 'chat';
11
11
  sessionId?: string;
12
12
  text: string;
13
+ attachments?: WireAttachment[];
13
14
  } | {
14
15
  type: 'command';
15
16
  sessionId?: string;
@@ -163,6 +164,10 @@ export type WireSchedulerEvent = {
163
164
  export type WsOutbound = {
164
165
  type: 'commands';
165
166
  commands: WireCommand[];
167
+ } | {
168
+ type: 'capabilities';
169
+ vision: boolean;
170
+ audio: boolean;
166
171
  } | {
167
172
  type: 'agents';
168
173
  agents: WireAgent[];
@@ -1,5 +1,19 @@
1
1
  const APPROVAL_ACTIONS = new Set(['approve', 'approve_always', 'deny']);
2
2
  const optionalString = (v) => (typeof v === 'string' && v ? v : undefined);
3
+ function parseAttachments(v) {
4
+ if (!Array.isArray(v))
5
+ return undefined;
6
+ const out = [];
7
+ for (const item of v) {
8
+ if (!item || typeof item !== 'object')
9
+ continue;
10
+ const a = item;
11
+ if ((a.kind === 'image' || a.kind === 'audio') && typeof a.mime === 'string' && typeof a.data === 'string') {
12
+ out.push({ kind: a.kind, mime: a.mime, data: a.data });
13
+ }
14
+ }
15
+ return out.length > 0 ? out : undefined;
16
+ }
3
17
  export function parseInbound(raw) {
4
18
  if (!raw || typeof raw !== 'object')
5
19
  return { error: 'not an object' };
@@ -9,7 +23,8 @@ export function parseInbound(raw) {
9
23
  case 'chat': {
10
24
  if (typeof o.text !== 'string')
11
25
  return { error: 'chat requires text:string' };
12
- return { type: 'chat', sessionId: optionalString(o.sessionId), text: o.text };
26
+ const attachments = parseAttachments(o.attachments);
27
+ return { type: 'chat', sessionId: optionalString(o.sessionId), text: o.text, ...(attachments ? { attachments } : {}) };
13
28
  }
14
29
  case 'command': {
15
30
  if (typeof o.text !== 'string')
@@ -6,10 +6,20 @@ export interface WebSocketAdapterOptions {
6
6
  authToken?: string;
7
7
  activeAgentId?: string;
8
8
  listModels?: () => Promise<WireModel[]>;
9
+ /** Modalities the configured model accepts. Image/audio attachments are dropped when off. */
10
+ capabilities?: {
11
+ vision?: boolean;
12
+ audio?: boolean;
13
+ };
9
14
  maxPayloadBytes?: number;
10
15
  log?: (msg: string) => void;
11
16
  }
12
17
  export type WebSocketAdapter = ChannelAdapter & {
13
18
  push(frame: WsOutbound): void;
19
+ /** Update the advertised model capabilities and broadcast a fresh `capabilities` frame. */
20
+ setCapabilities(caps: {
21
+ vision: boolean;
22
+ audio: boolean;
23
+ }): void;
14
24
  };
15
25
  export declare function webSocketAdapter(opts: WebSocketAdapterOptions): WebSocketAdapter;
@@ -1,6 +1,8 @@
1
1
  import { WebSocket, WebSocketServer } from 'ws';
2
+ import { text as textPart } from 'mu-core';
2
3
  import { createSessionService } from './session-service.js';
3
4
  import { observeSubAgent } from './sub-agent.js';
5
+ import { attachmentsToParts } from './wire.js';
4
6
  import { emitSessionEvent } from './wire-events.js';
5
7
  import { approvalRequestToWire, parseInbound, } from './protocol.js';
6
8
  const DEFAULT_MAX_PAYLOAD_BYTES = 1024 * 1024;
@@ -11,9 +13,15 @@ function toWireCommands(commands) {
11
13
  export function webSocketAdapter(opts) {
12
14
  const log = opts.log ?? (() => { });
13
15
  let pushFn = () => { };
16
+ const caps = { vision: opts.capabilities?.vision === true, audio: opts.capabilities?.audio === true };
14
17
  const adapter = {
15
18
  name: 'websocket',
16
19
  push: (frame) => pushFn(frame),
20
+ setCapabilities: (next) => {
21
+ caps.vision = next.vision;
22
+ caps.audio = next.audio;
23
+ pushFn({ type: 'capabilities', vision: caps.vision, audio: caps.audio });
24
+ },
17
25
  start: (ctx) => start(ctx),
18
26
  };
19
27
  return adapter;
@@ -74,6 +82,20 @@ export function webSocketAdapter(opts) {
74
82
  push({ type: 'sessions:changed', sessionId, kind });
75
83
  push({ type: 'sessions:listed', sessions: await service.list() });
76
84
  }
85
+ // Build the channel payload from text + attachments, dropping any modality the model lacks.
86
+ function buildChatPayload(sessionId, body, attachments) {
87
+ if (!attachments || attachments.length === 0)
88
+ return body;
89
+ const allowed = attachments.filter((a) => (a.kind === 'image' ? caps.vision : caps.audio));
90
+ if (allowed.length < attachments.length) {
91
+ const kinds = [...new Set(attachments.filter((a) => !allowed.includes(a)).map((a) => a.kind))].join('/');
92
+ push({ type: 'error', sessionId, message: `the active model has no ${kinds} capability — attachment(s) dropped` });
93
+ }
94
+ if (allowed.length === 0)
95
+ return body;
96
+ const parts = attachmentsToParts(allowed);
97
+ return body ? [textPart(body), ...parts] : parts;
98
+ }
77
99
  async function dispatch(client, msg) {
78
100
  switch (msg.type) {
79
101
  case 'chat': {
@@ -81,7 +103,8 @@ export function webSocketAdapter(opts) {
81
103
  client.sessionId = sessionId;
82
104
  currentApprovalSessionId = sessionId;
83
105
  const channel = manager.get(sessionId) ?? manager.open({ id: sessionId });
84
- void channel.send(msg.text).catch((err) => {
106
+ const payload = buildChatPayload(sessionId, msg.text, msg.attachments);
107
+ void channel.send(payload).catch((err) => {
85
108
  push({ type: 'error', sessionId, message: err instanceof Error ? err.message : String(err) });
86
109
  });
87
110
  return;
@@ -141,6 +164,13 @@ export function webSocketAdapter(opts) {
141
164
  const frame = await modelsFrame();
142
165
  if (frame)
143
166
  push(frame);
167
+ // Detect the new model's input modalities (loads the model) and re-advertise.
168
+ const modalities = await harness.models.capabilities(msg.ref).catch(() => undefined);
169
+ if (modalities) {
170
+ caps.vision = modalities.vision;
171
+ caps.audio = modalities.audio;
172
+ push({ type: 'capabilities', vision: caps.vision, audio: caps.audio });
173
+ }
144
174
  return;
145
175
  }
146
176
  case 'subagent:dispatch': {
@@ -239,6 +269,7 @@ export function webSocketAdapter(opts) {
239
269
  const client = { ws, sessionId };
240
270
  clients.set(ws, client);
241
271
  send(ws, { type: 'commands', commands: toWireCommands(commands.list()) });
272
+ send(ws, { type: 'capabilities', vision: caps.vision, audio: caps.audio });
242
273
  send(ws, agentsFrame());
243
274
  void modelsFrame().then((frame) => frame && send(ws, frame));
244
275
  void service.list().then((sessions) => send(ws, { type: 'sessions:listed', sessions }));
@@ -29,7 +29,7 @@ export function observeSubAgent(session, meta, broadcast) {
29
29
  });
30
30
  return;
31
31
  case 'message': {
32
- if (ev.message.role !== 'user')
32
+ if (ev.message.role !== 'tool')
33
33
  return;
34
34
  for (const part of ev.message.content) {
35
35
  if (part.type !== 'tool_result')
@@ -1,5 +1,13 @@
1
- import type { ContentPart, Message } from 'mu-core';
1
+ import { type ContentPart, type Message } from 'mu-core';
2
2
  export type WireRole = 'user' | 'assistant' | 'system' | 'tool';
3
+ /** A non-text content part carried over the wire as base64. */
4
+ export interface WireAttachment {
5
+ kind: 'image' | 'audio';
6
+ mime: string;
7
+ data: string;
8
+ }
9
+ export declare const partsToAttachments: (parts: readonly ContentPart[]) => WireAttachment[];
10
+ export declare const attachmentsToParts: (attachments: readonly WireAttachment[]) => ContentPart[];
3
11
  export interface WireToolCall {
4
12
  id: string;
5
13
  function: {
@@ -27,6 +35,7 @@ export interface WireMessage {
27
35
  toolCalls?: WireToolCall[];
28
36
  toolCallId?: string;
29
37
  toolResult?: WireToolResultInfo;
38
+ attachments?: WireAttachment[];
30
39
  meta?: WireMessageMeta;
31
40
  }
32
41
  export declare const textOf: (parts: readonly ContentPart[]) => string;
@@ -1,3 +1,16 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { audio, image } from 'mu-core';
3
+ export const partsToAttachments = (parts) => parts.flatMap((part) => part.type === 'image'
4
+ ? [{ kind: 'image', mime: part.mime, data: Buffer.from(part.data).toString('base64') }]
5
+ : part.type === 'audio'
6
+ ? [{ kind: 'audio', mime: part.mime, data: Buffer.from(part.data).toString('base64') }]
7
+ : part.type === 'tool_result'
8
+ ? partsToAttachments(part.content)
9
+ : []);
10
+ export const attachmentsToParts = (attachments) => attachments.map((a) => {
11
+ const data = new Uint8Array(Buffer.from(a.data, 'base64'));
12
+ return a.kind === 'image' ? image(a.mime, data) : audio(a.mime, data);
13
+ });
1
14
  export const textOf = (parts) => parts.map((part) => (part.type === 'text' ? part.text : '')).join('');
2
15
  export const argsToString = (input) => (typeof input === 'string' ? input : JSON.stringify(input ?? {}));
3
16
  export const toolResultText = (parts) => parts
@@ -24,7 +37,8 @@ export function messageToWire(message, idBase, ts, toolNames) {
24
37
  if (results.length > 0) {
25
38
  return results.map((result, index) => {
26
39
  const content = toolResultText(result.content);
27
- return {
40
+ const attachments = partsToAttachments(result.content);
41
+ const out = {
28
42
  id: `${idBase}:t${index}`,
29
43
  ts,
30
44
  role: 'tool',
@@ -32,9 +46,16 @@ export function messageToWire(message, idBase, ts, toolNames) {
32
46
  toolCallId: result.id,
33
47
  toolResult: { name: toolNames.get(result.id) ?? '', content },
34
48
  };
49
+ if (attachments.length > 0)
50
+ out.attachments = attachments;
51
+ return out;
35
52
  });
36
53
  }
37
- return [{ id: `${idBase}:u`, ts, role: 'user', content: textOf(message.content) }];
54
+ const userOut = { id: `${idBase}:u`, ts, role: 'user', content: textOf(message.content) };
55
+ const userAttachments = partsToAttachments(message.content);
56
+ if (userAttachments.length > 0)
57
+ userOut.attachments = userAttachments;
58
+ return [userOut];
38
59
  }
39
60
  export function messagesToWire(messages, baseTs = 0) {
40
61
  const toolNames = new Map();
@@ -1,4 +1,4 @@
1
- import type { Provider } from 'mu-core';
1
+ import type { ModelModalities, Provider } from 'mu-core';
2
2
  export interface ModelRegistryOptions {
3
3
  providers: Record<string, Provider>;
4
4
  default: string;
@@ -12,5 +12,7 @@ export interface ModelRegistry {
12
12
  readonly providers: string[];
13
13
  select(ref: string): void;
14
14
  resolve(ref?: string): ResolvedModel;
15
+ /** Probe a model's input modalities via its provider (may load the model). Undefined if unsupported. */
16
+ capabilities(ref?: string): Promise<ModelModalities | undefined>;
15
17
  }
16
18
  export declare const createModelRegistry: (options: ModelRegistryOptions) => ModelRegistry;
@@ -26,5 +26,9 @@ export const createModelRegistry = (options) => {
26
26
  selected = ref;
27
27
  },
28
28
  resolve,
29
+ capabilities: async (ref) => {
30
+ const { provider, model } = resolve(ref);
31
+ return provider.capabilities ? await provider.capabilities(model) : undefined;
32
+ },
29
33
  };
30
34
  };
@@ -10,6 +10,10 @@ export interface ChatFeatures {
10
10
  subAgents?: boolean;
11
11
  sessionPicker?: boolean;
12
12
  modelPicker?: boolean;
13
+ /** The active model accepts image input. Default off — must be opted in. */
14
+ vision?: boolean;
15
+ /** The active model accepts audio input. Default off — must be opted in. */
16
+ audio?: boolean;
13
17
  }
14
18
  export interface ChatHost {
15
19
  session?: AgentSession;
@@ -77,6 +81,8 @@ export declare class ChatApp {
77
81
  private readonly pendingShell;
78
82
  private readonly pastes;
79
83
  private pasteSeq;
84
+ private readonly attachments;
85
+ private attachSeq;
80
86
  private models;
81
87
  private paletteCursor;
82
88
  private paletteDismissedFor;
@@ -121,6 +127,12 @@ export declare class ChatApp {
121
127
  private send;
122
128
  private capturePaste;
123
129
  private expandPastes;
130
+ private capable;
131
+ private pasteClipboardImage;
132
+ private attachFromFile;
133
+ private addAttachment;
134
+ private stripAttachmentPlaceholders;
135
+ private clearAttachments;
124
136
  private flushShellContext;
125
137
  private submit;
126
138
  private clearPastes;
@@ -1,7 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { mkdir, writeFile } from 'node:fs/promises';
3
- import { dirname, isAbsolute, relative, resolve } from 'node:path';
4
- import { box, column, flex, measure, ProcessTerminal, scrollView, truncateToWidth, TUI, visibleWidth, } from 'mu-tui';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, extname, isAbsolute, relative, resolve } from 'node:path';
4
+ import { audio, image } from 'mu-core';
5
+ import { box, column, flex, measure, ProcessTerminal, readClipboardImage, scrollView, truncateToWidth, TUI, visibleWidth, } from 'mu-tui';
5
6
  import { buildCommands, filterCommands } from './commands.js';
6
7
  import { MultilineEditor } from './editor.js';
7
8
  import { activeMention, collectCandidates, mentionRanges, rank } from './picker.js';
@@ -21,6 +22,23 @@ const APPROVAL_OPTIONS = [
21
22
  { label: 'Deny', value: 'deny' },
22
23
  ];
23
24
  const DIM = '\x1b[2m';
25
+ const IMAGE_MIME = {
26
+ '.png': 'image/png',
27
+ '.jpg': 'image/jpeg',
28
+ '.jpeg': 'image/jpeg',
29
+ '.gif': 'image/gif',
30
+ '.webp': 'image/webp',
31
+ '.bmp': 'image/bmp',
32
+ };
33
+ const AUDIO_MIME = {
34
+ '.wav': 'audio/wav',
35
+ '.mp3': 'audio/mpeg',
36
+ '.m4a': 'audio/mp4',
37
+ '.ogg': 'audio/ogg',
38
+ '.flac': 'audio/flac',
39
+ '.webm': 'audio/webm',
40
+ };
41
+ const ATTACHMENT_PLACEHOLDER = /\[(?:image|audio) #\d+\]/g;
24
42
  const lastAssistantText = (messages) => {
25
43
  for (let i = messages.length - 1; i >= 0; i--) {
26
44
  const message = messages[i];
@@ -90,6 +108,8 @@ export class ChatApp {
90
108
  pendingShell = [];
91
109
  pastes = new Map();
92
110
  pasteSeq = 0;
111
+ attachments = new Map();
112
+ attachSeq = 0;
93
113
  models = [];
94
114
  paletteCursor = 0;
95
115
  paletteDismissedFor = '__none__';
@@ -167,6 +187,7 @@ export class ChatApp {
167
187
  this.tui.addGlobalKeybinding({ chord: { key: 't', ctrl: true }, handler: () => this.toggleTheme() });
168
188
  this.tui.addGlobalKeybinding({ chord: { key: 'o', ctrl: true }, handler: () => this.toggleExpand() });
169
189
  this.tui.addGlobalKeybinding({ chord: { key: 'end', ctrl: true }, handler: () => this.jumpToBottom() });
190
+ this.tui.addGlobalKeybinding({ chord: { key: 'v', ctrl: true }, handler: () => void this.pasteClipboardImage() });
170
191
  this.unsubscribeTheme = this.themeProvider.subscribe(() => {
171
192
  this.tui.setBackgroundColor(this.theme().colors.background);
172
193
  this.tui.setToastBackground(this.theme().colors.surface);
@@ -465,10 +486,13 @@ export class ChatApp {
465
486
  const agents = new Set(this.host.agentNames());
466
487
  return text.replace(/(^|\s)@(\S+)/g, (match, pre, token) => (agents.has(token) ? match : `${pre}${token}`));
467
488
  }
468
- send(value) {
489
+ send(value, attachments = []) {
469
490
  const session = this.ensureSession();
470
491
  this.transcript.appendUser(value);
471
- const content = this.flushShellContext(this.stripFileMentions(value));
492
+ const text = this.flushShellContext(this.stripFileMentions(this.stripAttachmentPlaceholders(value)));
493
+ const content = attachments.length > 0
494
+ ? [...(text ? [{ type: 'text', text }] : []), ...attachments]
495
+ : text;
472
496
  this.running = true;
473
497
  this.status.busy = true;
474
498
  this.setStatus('thinking…');
@@ -482,6 +506,20 @@ export class ChatApp {
482
506
  });
483
507
  }
484
508
  capturePaste(text) {
509
+ // Empty bracketed paste: the terminal swallowed binary clipboard data (e.g. an image).
510
+ if (!text.trim()) {
511
+ void this.pasteClipboardImage();
512
+ return '';
513
+ }
514
+ // A pasted path to a local image/audio file becomes an attachment, not literal text.
515
+ const filePath = text.trim().replace(/^['"]|['"]$/g, '').replace(/\\ /g, ' ');
516
+ if (!filePath.includes('\n')) {
517
+ const ext = extname(filePath).toLowerCase();
518
+ if (IMAGE_MIME[ext] || AUDIO_MIME[ext]) {
519
+ void this.attachFromFile(filePath, ext);
520
+ return '';
521
+ }
522
+ }
485
523
  const lines = text.split('\n').length;
486
524
  if (lines < 2 && text.length <= 200)
487
525
  return undefined;
@@ -498,6 +536,50 @@ export class ChatApp {
498
536
  }
499
537
  return out;
500
538
  }
539
+ capable(kind) {
540
+ return (kind === 'image' ? this.features.vision : this.features.audio) === true;
541
+ }
542
+ async pasteClipboardImage() {
543
+ const img = await readClipboardImage().catch(() => undefined);
544
+ if (!img) {
545
+ this.setStatus('no image in clipboard');
546
+ this.tui.requestRender();
547
+ return;
548
+ }
549
+ this.addAttachment('image', img.mime, img.data);
550
+ }
551
+ async attachFromFile(path, ext) {
552
+ const kind = IMAGE_MIME[ext] ? 'image' : 'audio';
553
+ const mime = IMAGE_MIME[ext] ?? AUDIO_MIME[ext];
554
+ try {
555
+ const buf = await readFile(resolve(this.host.cwd, path));
556
+ this.addAttachment(kind, mime, new Uint8Array(buf));
557
+ }
558
+ catch {
559
+ this.setStatus(`could not read ${path}`);
560
+ this.tui.requestRender();
561
+ }
562
+ }
563
+ addAttachment(kind, mime, data) {
564
+ if (!this.capable(kind)) {
565
+ this.setStatus(`the active model has no ${kind} capability — ${kind} not attached`);
566
+ this.tui.requestRender();
567
+ return;
568
+ }
569
+ const placeholder = `[${kind} #${++this.attachSeq}]`;
570
+ this.attachments.set(placeholder, kind === 'image' ? image(mime, data) : audio(mime, data));
571
+ const value = this.editor.getValue();
572
+ this.editor.setValue(value ? `${value} ${placeholder} ` : `${placeholder} `);
573
+ this.onInputChange(this.editor.getValue());
574
+ this.tui.requestRender();
575
+ }
576
+ stripAttachmentPlaceholders(text) {
577
+ return text.replace(ATTACHMENT_PLACEHOLDER, '').replace(/[ \t]{2,}/g, ' ').trim();
578
+ }
579
+ clearAttachments() {
580
+ this.attachments.clear();
581
+ this.attachSeq = 0;
582
+ }
501
583
  flushShellContext(userText) {
502
584
  if (this.pendingShell.length === 0)
503
585
  return userText;
@@ -515,8 +597,10 @@ export class ChatApp {
515
597
  return;
516
598
  this.clearError();
517
599
  const text = this.expandPastes(trimmed);
600
+ const attachments = [...this.attachments.values()];
518
601
  this.editor.setValue('');
519
602
  this.clearPastes();
603
+ this.clearAttachments();
520
604
  this.pushHistory(text);
521
605
  if (text.startsWith('!') || text.startsWith('$')) {
522
606
  this.runShell(text.slice(1).trim());
@@ -529,11 +613,13 @@ export class ChatApp {
529
613
  if (this.tryDispatch(text))
530
614
  return;
531
615
  if (this.running) {
616
+ if (attachments.length > 0)
617
+ this.setStatus('attachments are dropped for messages queued mid-turn');
532
618
  this.queue.push(text);
533
619
  this.tui.requestRender();
534
620
  return;
535
621
  }
536
- this.send(text);
622
+ this.send(text, attachments);
537
623
  }
538
624
  clearPastes() {
539
625
  this.pastes.clear();
@@ -546,6 +632,7 @@ export class ChatApp {
546
632
  const text = this.expandPastes(value);
547
633
  this.editor.setValue('');
548
634
  this.clearPastes();
635
+ this.clearAttachments();
549
636
  this.pushHistory(text);
550
637
  this.queue.push(text);
551
638
  this.tui.requestRender();
@@ -555,6 +642,10 @@ export class ChatApp {
555
642
  if (!value.includes(placeholder))
556
643
  this.pastes.delete(placeholder);
557
644
  }
645
+ for (const placeholder of this.attachments.keys()) {
646
+ if (!value.includes(placeholder))
647
+ this.attachments.delete(placeholder);
648
+ }
558
649
  if (value !== this.paletteDismissedFor)
559
650
  this.paletteDismissedFor = '__none__';
560
651
  const items = this.paletteItems();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mu-harness",
3
- "version": "0.20.0",
3
+ "version": "0.22.1",
4
4
  "description": "Agent harness: createHarness wires mu-core into a host — XDG paths, model registry, plugins, disk-loaded agents & skills, sub-agents, sessions (JSONL + SQLite catalog), slash commands, permission/approval hooks, an optional scheduler, and a composable TUI chat app",
5
5
  "license": "MIT",
6
6
  "main": "./script/index.js",
@@ -23,8 +23,8 @@
23
23
  "@swc/wasm-typescript": "^1.15.0",
24
24
  "cli-highlight": "^2.1.11",
25
25
  "croner": "^9.0.0",
26
- "mu-core": "^0.20.0",
27
- "mu-tui": "^0.20.0",
26
+ "mu-core": "^0.22.1",
27
+ "mu-tui": "^0.22.1",
28
28
  "ws": "^8.18.0"
29
29
  },
30
30
  "_generatedBy": "dnt@dev",
@@ -34,6 +34,9 @@ async function connectHarness(opts) {
34
34
  let models = [];
35
35
  let modelSelected = '';
36
36
  let commands = [];
37
+ // Mutated in place by the connect-time `capabilities` frame so the host (read once by
38
+ // ChatApp after connect) reflects the server's actual model modalities.
39
+ const features = { ...(opts.features ?? {}) };
37
40
  const sessionsById = new Map();
38
41
  const subSessions = new Map();
39
42
  const approvalListeners = new Set();
@@ -61,7 +64,18 @@ async function connectHarness(opts) {
61
64
  tools: [],
62
65
  send: (input) => {
63
66
  if (!readonly) {
64
- client.send({ type: 'chat', sessionId: id, text: typeof input === 'string' ? input : (0, wire_js_1.textOf)(input) });
67
+ if (typeof input === 'string') {
68
+ client.send({ type: 'chat', sessionId: id, text: input });
69
+ }
70
+ else {
71
+ const attachments = (0, wire_js_1.partsToAttachments)(input);
72
+ client.send({
73
+ type: 'chat',
74
+ sessionId: id,
75
+ text: (0, wire_js_1.textOf)(input),
76
+ ...(attachments.length > 0 ? { attachments } : {}),
77
+ });
78
+ }
65
79
  }
66
80
  return Promise.resolve();
67
81
  },
@@ -129,6 +143,10 @@ async function connectHarness(opts) {
129
143
  case 'commands':
130
144
  commands = frame.commands;
131
145
  return;
146
+ case 'capabilities':
147
+ features.vision = frame.vision;
148
+ features.audio = frame.audio;
149
+ return;
132
150
  case 'models:listed': {
133
151
  models = frame.models.map((m) => ({ id: m.id, ownedBy: m.ownedBy }));
134
152
  modelSelected = frame.selected;
@@ -284,7 +302,7 @@ async function connectHarness(opts) {
284
302
  initialThinking: opts.initialThinking ?? false,
285
303
  saveThinking: opts.saveThinking ?? (() => { }),
286
304
  history: opts.history,
287
- features: opts.features,
305
+ features,
288
306
  banner: opts.banner,
289
307
  minimal: opts.minimal,
290
308
  commands: () => commands.map((c) => ({ name: c.command.replace(/^\//, ''), description: c.description })),
@@ -1,6 +1,6 @@
1
1
  export * from './protocol.js';
2
2
  export { type SessionSummaryWire, type PersistedSessionWire, type SessionService, createSessionService } from './session-service.js';
3
- export { type WireMessage, messagesToWire } from './wire.js';
3
+ export { attachmentsToParts, messagesToWire, partsToAttachments, type WireAttachment, type WireMessage } from './wire.js';
4
4
  export { type WebSocketAdapter, type WebSocketAdapterOptions, webSocketAdapter } from './server.js';
5
5
  export { type ConnectHarnessOptions, connectHarness, type RemoteHarness } from './client.js';
6
6
  export { createWsClient, type WsClient, type WsClientOptions } from './ws-client.js';
@@ -14,12 +14,14 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.createWsClient = exports.connectHarness = exports.webSocketAdapter = exports.messagesToWire = exports.createSessionService = void 0;
17
+ exports.createWsClient = exports.connectHarness = exports.webSocketAdapter = exports.partsToAttachments = exports.messagesToWire = exports.attachmentsToParts = exports.createSessionService = void 0;
18
18
  __exportStar(require("./protocol.js"), exports);
19
19
  var session_service_js_1 = require("./session-service.js");
20
20
  Object.defineProperty(exports, "createSessionService", { enumerable: true, get: function () { return session_service_js_1.createSessionService; } });
21
21
  var wire_js_1 = require("./wire.js");
22
+ Object.defineProperty(exports, "attachmentsToParts", { enumerable: true, get: function () { return wire_js_1.attachmentsToParts; } });
22
23
  Object.defineProperty(exports, "messagesToWire", { enumerable: true, get: function () { return wire_js_1.messagesToWire; } });
24
+ Object.defineProperty(exports, "partsToAttachments", { enumerable: true, get: function () { return wire_js_1.partsToAttachments; } });
23
25
  var server_js_1 = require("./server.js");
24
26
  Object.defineProperty(exports, "webSocketAdapter", { enumerable: true, get: function () { return server_js_1.webSocketAdapter; } });
25
27
  var client_js_1 = require("./client.js");
@@ -1,5 +1,5 @@
1
1
  import type { PersistedSessionWire, SessionSummaryWire } from './session-service.js';
2
- import type { WireMessage } from './wire.js';
2
+ import type { WireAttachment, WireMessage } from './wire.js';
3
3
  import type { ApprovalAction, PendingApproval } from '../../permissions/index.js';
4
4
  import type { Message, Usage } from 'mu-core';
5
5
  export interface WireModel {
@@ -10,6 +10,7 @@ export type WsInbound = {
10
10
  type: 'chat';
11
11
  sessionId?: string;
12
12
  text: string;
13
+ attachments?: WireAttachment[];
13
14
  } | {
14
15
  type: 'command';
15
16
  sessionId?: string;
@@ -163,6 +164,10 @@ export type WireSchedulerEvent = {
163
164
  export type WsOutbound = {
164
165
  type: 'commands';
165
166
  commands: WireCommand[];
167
+ } | {
168
+ type: 'capabilities';
169
+ vision: boolean;
170
+ audio: boolean;
166
171
  } | {
167
172
  type: 'agents';
168
173
  agents: WireAgent[];
@@ -4,6 +4,20 @@ exports.parseInbound = parseInbound;
4
4
  exports.approvalRequestToWire = approvalRequestToWire;
5
5
  const APPROVAL_ACTIONS = new Set(['approve', 'approve_always', 'deny']);
6
6
  const optionalString = (v) => (typeof v === 'string' && v ? v : undefined);
7
+ function parseAttachments(v) {
8
+ if (!Array.isArray(v))
9
+ return undefined;
10
+ const out = [];
11
+ for (const item of v) {
12
+ if (!item || typeof item !== 'object')
13
+ continue;
14
+ const a = item;
15
+ if ((a.kind === 'image' || a.kind === 'audio') && typeof a.mime === 'string' && typeof a.data === 'string') {
16
+ out.push({ kind: a.kind, mime: a.mime, data: a.data });
17
+ }
18
+ }
19
+ return out.length > 0 ? out : undefined;
20
+ }
7
21
  function parseInbound(raw) {
8
22
  if (!raw || typeof raw !== 'object')
9
23
  return { error: 'not an object' };
@@ -13,7 +27,8 @@ function parseInbound(raw) {
13
27
  case 'chat': {
14
28
  if (typeof o.text !== 'string')
15
29
  return { error: 'chat requires text:string' };
16
- return { type: 'chat', sessionId: optionalString(o.sessionId), text: o.text };
30
+ const attachments = parseAttachments(o.attachments);
31
+ return { type: 'chat', sessionId: optionalString(o.sessionId), text: o.text, ...(attachments ? { attachments } : {}) };
17
32
  }
18
33
  case 'command': {
19
34
  if (typeof o.text !== 'string')
@@ -6,10 +6,20 @@ export interface WebSocketAdapterOptions {
6
6
  authToken?: string;
7
7
  activeAgentId?: string;
8
8
  listModels?: () => Promise<WireModel[]>;
9
+ /** Modalities the configured model accepts. Image/audio attachments are dropped when off. */
10
+ capabilities?: {
11
+ vision?: boolean;
12
+ audio?: boolean;
13
+ };
9
14
  maxPayloadBytes?: number;
10
15
  log?: (msg: string) => void;
11
16
  }
12
17
  export type WebSocketAdapter = ChannelAdapter & {
13
18
  push(frame: WsOutbound): void;
19
+ /** Update the advertised model capabilities and broadcast a fresh `capabilities` frame. */
20
+ setCapabilities(caps: {
21
+ vision: boolean;
22
+ audio: boolean;
23
+ }): void;
14
24
  };
15
25
  export declare function webSocketAdapter(opts: WebSocketAdapterOptions): WebSocketAdapter;
@@ -2,8 +2,10 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.webSocketAdapter = webSocketAdapter;
4
4
  const ws_1 = require("ws");
5
+ const mu_core_1 = require("mu-core");
5
6
  const session_service_js_1 = require("./session-service.js");
6
7
  const sub_agent_js_1 = require("./sub-agent.js");
8
+ const wire_js_1 = require("./wire.js");
7
9
  const wire_events_js_1 = require("./wire-events.js");
8
10
  const protocol_js_1 = require("./protocol.js");
9
11
  const DEFAULT_MAX_PAYLOAD_BYTES = 1024 * 1024;
@@ -14,9 +16,15 @@ function toWireCommands(commands) {
14
16
  function webSocketAdapter(opts) {
15
17
  const log = opts.log ?? (() => { });
16
18
  let pushFn = () => { };
19
+ const caps = { vision: opts.capabilities?.vision === true, audio: opts.capabilities?.audio === true };
17
20
  const adapter = {
18
21
  name: 'websocket',
19
22
  push: (frame) => pushFn(frame),
23
+ setCapabilities: (next) => {
24
+ caps.vision = next.vision;
25
+ caps.audio = next.audio;
26
+ pushFn({ type: 'capabilities', vision: caps.vision, audio: caps.audio });
27
+ },
20
28
  start: (ctx) => start(ctx),
21
29
  };
22
30
  return adapter;
@@ -77,6 +85,20 @@ function webSocketAdapter(opts) {
77
85
  push({ type: 'sessions:changed', sessionId, kind });
78
86
  push({ type: 'sessions:listed', sessions: await service.list() });
79
87
  }
88
+ // Build the channel payload from text + attachments, dropping any modality the model lacks.
89
+ function buildChatPayload(sessionId, body, attachments) {
90
+ if (!attachments || attachments.length === 0)
91
+ return body;
92
+ const allowed = attachments.filter((a) => (a.kind === 'image' ? caps.vision : caps.audio));
93
+ if (allowed.length < attachments.length) {
94
+ const kinds = [...new Set(attachments.filter((a) => !allowed.includes(a)).map((a) => a.kind))].join('/');
95
+ push({ type: 'error', sessionId, message: `the active model has no ${kinds} capability — attachment(s) dropped` });
96
+ }
97
+ if (allowed.length === 0)
98
+ return body;
99
+ const parts = (0, wire_js_1.attachmentsToParts)(allowed);
100
+ return body ? [(0, mu_core_1.text)(body), ...parts] : parts;
101
+ }
80
102
  async function dispatch(client, msg) {
81
103
  switch (msg.type) {
82
104
  case 'chat': {
@@ -84,7 +106,8 @@ function webSocketAdapter(opts) {
84
106
  client.sessionId = sessionId;
85
107
  currentApprovalSessionId = sessionId;
86
108
  const channel = manager.get(sessionId) ?? manager.open({ id: sessionId });
87
- void channel.send(msg.text).catch((err) => {
109
+ const payload = buildChatPayload(sessionId, msg.text, msg.attachments);
110
+ void channel.send(payload).catch((err) => {
88
111
  push({ type: 'error', sessionId, message: err instanceof Error ? err.message : String(err) });
89
112
  });
90
113
  return;
@@ -144,6 +167,13 @@ function webSocketAdapter(opts) {
144
167
  const frame = await modelsFrame();
145
168
  if (frame)
146
169
  push(frame);
170
+ // Detect the new model's input modalities (loads the model) and re-advertise.
171
+ const modalities = await harness.models.capabilities(msg.ref).catch(() => undefined);
172
+ if (modalities) {
173
+ caps.vision = modalities.vision;
174
+ caps.audio = modalities.audio;
175
+ push({ type: 'capabilities', vision: caps.vision, audio: caps.audio });
176
+ }
147
177
  return;
148
178
  }
149
179
  case 'subagent:dispatch': {
@@ -242,6 +272,7 @@ function webSocketAdapter(opts) {
242
272
  const client = { ws, sessionId };
243
273
  clients.set(ws, client);
244
274
  send(ws, { type: 'commands', commands: toWireCommands(commands.list()) });
275
+ send(ws, { type: 'capabilities', vision: caps.vision, audio: caps.audio });
245
276
  send(ws, agentsFrame());
246
277
  void modelsFrame().then((frame) => frame && send(ws, frame));
247
278
  void service.list().then((sessions) => send(ws, { type: 'sessions:listed', sessions }));
@@ -32,7 +32,7 @@ function observeSubAgent(session, meta, broadcast) {
32
32
  });
33
33
  return;
34
34
  case 'message': {
35
- if (ev.message.role !== 'user')
35
+ if (ev.message.role !== 'tool')
36
36
  return;
37
37
  for (const part of ev.message.content) {
38
38
  if (part.type !== 'tool_result')
@@ -1,5 +1,13 @@
1
- import type { ContentPart, Message } from 'mu-core';
1
+ import { type ContentPart, type Message } from 'mu-core';
2
2
  export type WireRole = 'user' | 'assistant' | 'system' | 'tool';
3
+ /** A non-text content part carried over the wire as base64. */
4
+ export interface WireAttachment {
5
+ kind: 'image' | 'audio';
6
+ mime: string;
7
+ data: string;
8
+ }
9
+ export declare const partsToAttachments: (parts: readonly ContentPart[]) => WireAttachment[];
10
+ export declare const attachmentsToParts: (attachments: readonly WireAttachment[]) => ContentPart[];
3
11
  export interface WireToolCall {
4
12
  id: string;
5
13
  function: {
@@ -27,6 +35,7 @@ export interface WireMessage {
27
35
  toolCalls?: WireToolCall[];
28
36
  toolCallId?: string;
29
37
  toolResult?: WireToolResultInfo;
38
+ attachments?: WireAttachment[];
30
39
  meta?: WireMessageMeta;
31
40
  }
32
41
  export declare const textOf: (parts: readonly ContentPart[]) => string;
@@ -1,8 +1,23 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.toolResultText = exports.argsToString = exports.textOf = void 0;
3
+ exports.toolResultText = exports.argsToString = exports.textOf = exports.attachmentsToParts = exports.partsToAttachments = void 0;
4
4
  exports.messageToWire = messageToWire;
5
5
  exports.messagesToWire = messagesToWire;
6
+ const node_buffer_1 = require("node:buffer");
7
+ const mu_core_1 = require("mu-core");
8
+ const partsToAttachments = (parts) => parts.flatMap((part) => part.type === 'image'
9
+ ? [{ kind: 'image', mime: part.mime, data: node_buffer_1.Buffer.from(part.data).toString('base64') }]
10
+ : part.type === 'audio'
11
+ ? [{ kind: 'audio', mime: part.mime, data: node_buffer_1.Buffer.from(part.data).toString('base64') }]
12
+ : part.type === 'tool_result'
13
+ ? (0, exports.partsToAttachments)(part.content)
14
+ : []);
15
+ exports.partsToAttachments = partsToAttachments;
16
+ const attachmentsToParts = (attachments) => attachments.map((a) => {
17
+ const data = new Uint8Array(node_buffer_1.Buffer.from(a.data, 'base64'));
18
+ return a.kind === 'image' ? (0, mu_core_1.image)(a.mime, data) : (0, mu_core_1.audio)(a.mime, data);
19
+ });
20
+ exports.attachmentsToParts = attachmentsToParts;
6
21
  const textOf = (parts) => parts.map((part) => (part.type === 'text' ? part.text : '')).join('');
7
22
  exports.textOf = textOf;
8
23
  const argsToString = (input) => (typeof input === 'string' ? input : JSON.stringify(input ?? {}));
@@ -32,7 +47,8 @@ function messageToWire(message, idBase, ts, toolNames) {
32
47
  if (results.length > 0) {
33
48
  return results.map((result, index) => {
34
49
  const content = (0, exports.toolResultText)(result.content);
35
- return {
50
+ const attachments = (0, exports.partsToAttachments)(result.content);
51
+ const out = {
36
52
  id: `${idBase}:t${index}`,
37
53
  ts,
38
54
  role: 'tool',
@@ -40,9 +56,16 @@ function messageToWire(message, idBase, ts, toolNames) {
40
56
  toolCallId: result.id,
41
57
  toolResult: { name: toolNames.get(result.id) ?? '', content },
42
58
  };
59
+ if (attachments.length > 0)
60
+ out.attachments = attachments;
61
+ return out;
43
62
  });
44
63
  }
45
- return [{ id: `${idBase}:u`, ts, role: 'user', content: (0, exports.textOf)(message.content) }];
64
+ const userOut = { id: `${idBase}:u`, ts, role: 'user', content: (0, exports.textOf)(message.content) };
65
+ const userAttachments = (0, exports.partsToAttachments)(message.content);
66
+ if (userAttachments.length > 0)
67
+ userOut.attachments = userAttachments;
68
+ return [userOut];
46
69
  }
47
70
  function messagesToWire(messages, baseTs = 0) {
48
71
  const toolNames = new Map();
@@ -1,4 +1,4 @@
1
- import type { Provider } from 'mu-core';
1
+ import type { ModelModalities, Provider } from 'mu-core';
2
2
  export interface ModelRegistryOptions {
3
3
  providers: Record<string, Provider>;
4
4
  default: string;
@@ -12,5 +12,7 @@ export interface ModelRegistry {
12
12
  readonly providers: string[];
13
13
  select(ref: string): void;
14
14
  resolve(ref?: string): ResolvedModel;
15
+ /** Probe a model's input modalities via its provider (may load the model). Undefined if unsupported. */
16
+ capabilities(ref?: string): Promise<ModelModalities | undefined>;
15
17
  }
16
18
  export declare const createModelRegistry: (options: ModelRegistryOptions) => ModelRegistry;
@@ -29,6 +29,10 @@ const createModelRegistry = (options) => {
29
29
  selected = ref;
30
30
  },
31
31
  resolve,
32
+ capabilities: async (ref) => {
33
+ const { provider, model } = resolve(ref);
34
+ return provider.capabilities ? await provider.capabilities(model) : undefined;
35
+ },
32
36
  };
33
37
  };
34
38
  exports.createModelRegistry = createModelRegistry;
@@ -10,6 +10,10 @@ export interface ChatFeatures {
10
10
  subAgents?: boolean;
11
11
  sessionPicker?: boolean;
12
12
  modelPicker?: boolean;
13
+ /** The active model accepts image input. Default off — must be opted in. */
14
+ vision?: boolean;
15
+ /** The active model accepts audio input. Default off — must be opted in. */
16
+ audio?: boolean;
13
17
  }
14
18
  export interface ChatHost {
15
19
  session?: AgentSession;
@@ -77,6 +81,8 @@ export declare class ChatApp {
77
81
  private readonly pendingShell;
78
82
  private readonly pastes;
79
83
  private pasteSeq;
84
+ private readonly attachments;
85
+ private attachSeq;
80
86
  private models;
81
87
  private paletteCursor;
82
88
  private paletteDismissedFor;
@@ -121,6 +127,12 @@ export declare class ChatApp {
121
127
  private send;
122
128
  private capturePaste;
123
129
  private expandPastes;
130
+ private capable;
131
+ private pasteClipboardImage;
132
+ private attachFromFile;
133
+ private addAttachment;
134
+ private stripAttachmentPlaceholders;
135
+ private clearAttachments;
124
136
  private flushShellContext;
125
137
  private submit;
126
138
  private clearPastes;
@@ -4,6 +4,7 @@ exports.ChatApp = void 0;
4
4
  const node_child_process_1 = require("node:child_process");
5
5
  const promises_1 = require("node:fs/promises");
6
6
  const node_path_1 = require("node:path");
7
+ const mu_core_1 = require("mu-core");
7
8
  const mu_tui_1 = require("mu-tui");
8
9
  const commands_js_1 = require("./commands.js");
9
10
  const editor_js_1 = require("./editor.js");
@@ -24,6 +25,23 @@ const APPROVAL_OPTIONS = [
24
25
  { label: 'Deny', value: 'deny' },
25
26
  ];
26
27
  const DIM = '\x1b[2m';
28
+ const IMAGE_MIME = {
29
+ '.png': 'image/png',
30
+ '.jpg': 'image/jpeg',
31
+ '.jpeg': 'image/jpeg',
32
+ '.gif': 'image/gif',
33
+ '.webp': 'image/webp',
34
+ '.bmp': 'image/bmp',
35
+ };
36
+ const AUDIO_MIME = {
37
+ '.wav': 'audio/wav',
38
+ '.mp3': 'audio/mpeg',
39
+ '.m4a': 'audio/mp4',
40
+ '.ogg': 'audio/ogg',
41
+ '.flac': 'audio/flac',
42
+ '.webm': 'audio/webm',
43
+ };
44
+ const ATTACHMENT_PLACEHOLDER = /\[(?:image|audio) #\d+\]/g;
27
45
  const lastAssistantText = (messages) => {
28
46
  for (let i = messages.length - 1; i >= 0; i--) {
29
47
  const message = messages[i];
@@ -93,6 +111,8 @@ class ChatApp {
93
111
  pendingShell = [];
94
112
  pastes = new Map();
95
113
  pasteSeq = 0;
114
+ attachments = new Map();
115
+ attachSeq = 0;
96
116
  models = [];
97
117
  paletteCursor = 0;
98
118
  paletteDismissedFor = '__none__';
@@ -170,6 +190,7 @@ class ChatApp {
170
190
  this.tui.addGlobalKeybinding({ chord: { key: 't', ctrl: true }, handler: () => this.toggleTheme() });
171
191
  this.tui.addGlobalKeybinding({ chord: { key: 'o', ctrl: true }, handler: () => this.toggleExpand() });
172
192
  this.tui.addGlobalKeybinding({ chord: { key: 'end', ctrl: true }, handler: () => this.jumpToBottom() });
193
+ this.tui.addGlobalKeybinding({ chord: { key: 'v', ctrl: true }, handler: () => void this.pasteClipboardImage() });
173
194
  this.unsubscribeTheme = this.themeProvider.subscribe(() => {
174
195
  this.tui.setBackgroundColor(this.theme().colors.background);
175
196
  this.tui.setToastBackground(this.theme().colors.surface);
@@ -468,10 +489,13 @@ class ChatApp {
468
489
  const agents = new Set(this.host.agentNames());
469
490
  return text.replace(/(^|\s)@(\S+)/g, (match, pre, token) => (agents.has(token) ? match : `${pre}${token}`));
470
491
  }
471
- send(value) {
492
+ send(value, attachments = []) {
472
493
  const session = this.ensureSession();
473
494
  this.transcript.appendUser(value);
474
- const content = this.flushShellContext(this.stripFileMentions(value));
495
+ const text = this.flushShellContext(this.stripFileMentions(this.stripAttachmentPlaceholders(value)));
496
+ const content = attachments.length > 0
497
+ ? [...(text ? [{ type: 'text', text }] : []), ...attachments]
498
+ : text;
475
499
  this.running = true;
476
500
  this.status.busy = true;
477
501
  this.setStatus('thinking…');
@@ -485,6 +509,20 @@ class ChatApp {
485
509
  });
486
510
  }
487
511
  capturePaste(text) {
512
+ // Empty bracketed paste: the terminal swallowed binary clipboard data (e.g. an image).
513
+ if (!text.trim()) {
514
+ void this.pasteClipboardImage();
515
+ return '';
516
+ }
517
+ // A pasted path to a local image/audio file becomes an attachment, not literal text.
518
+ const filePath = text.trim().replace(/^['"]|['"]$/g, '').replace(/\\ /g, ' ');
519
+ if (!filePath.includes('\n')) {
520
+ const ext = (0, node_path_1.extname)(filePath).toLowerCase();
521
+ if (IMAGE_MIME[ext] || AUDIO_MIME[ext]) {
522
+ void this.attachFromFile(filePath, ext);
523
+ return '';
524
+ }
525
+ }
488
526
  const lines = text.split('\n').length;
489
527
  if (lines < 2 && text.length <= 200)
490
528
  return undefined;
@@ -501,6 +539,50 @@ class ChatApp {
501
539
  }
502
540
  return out;
503
541
  }
542
+ capable(kind) {
543
+ return (kind === 'image' ? this.features.vision : this.features.audio) === true;
544
+ }
545
+ async pasteClipboardImage() {
546
+ const img = await (0, mu_tui_1.readClipboardImage)().catch(() => undefined);
547
+ if (!img) {
548
+ this.setStatus('no image in clipboard');
549
+ this.tui.requestRender();
550
+ return;
551
+ }
552
+ this.addAttachment('image', img.mime, img.data);
553
+ }
554
+ async attachFromFile(path, ext) {
555
+ const kind = IMAGE_MIME[ext] ? 'image' : 'audio';
556
+ const mime = IMAGE_MIME[ext] ?? AUDIO_MIME[ext];
557
+ try {
558
+ const buf = await (0, promises_1.readFile)((0, node_path_1.resolve)(this.host.cwd, path));
559
+ this.addAttachment(kind, mime, new Uint8Array(buf));
560
+ }
561
+ catch {
562
+ this.setStatus(`could not read ${path}`);
563
+ this.tui.requestRender();
564
+ }
565
+ }
566
+ addAttachment(kind, mime, data) {
567
+ if (!this.capable(kind)) {
568
+ this.setStatus(`the active model has no ${kind} capability — ${kind} not attached`);
569
+ this.tui.requestRender();
570
+ return;
571
+ }
572
+ const placeholder = `[${kind} #${++this.attachSeq}]`;
573
+ this.attachments.set(placeholder, kind === 'image' ? (0, mu_core_1.image)(mime, data) : (0, mu_core_1.audio)(mime, data));
574
+ const value = this.editor.getValue();
575
+ this.editor.setValue(value ? `${value} ${placeholder} ` : `${placeholder} `);
576
+ this.onInputChange(this.editor.getValue());
577
+ this.tui.requestRender();
578
+ }
579
+ stripAttachmentPlaceholders(text) {
580
+ return text.replace(ATTACHMENT_PLACEHOLDER, '').replace(/[ \t]{2,}/g, ' ').trim();
581
+ }
582
+ clearAttachments() {
583
+ this.attachments.clear();
584
+ this.attachSeq = 0;
585
+ }
504
586
  flushShellContext(userText) {
505
587
  if (this.pendingShell.length === 0)
506
588
  return userText;
@@ -518,8 +600,10 @@ class ChatApp {
518
600
  return;
519
601
  this.clearError();
520
602
  const text = this.expandPastes(trimmed);
603
+ const attachments = [...this.attachments.values()];
521
604
  this.editor.setValue('');
522
605
  this.clearPastes();
606
+ this.clearAttachments();
523
607
  this.pushHistory(text);
524
608
  if (text.startsWith('!') || text.startsWith('$')) {
525
609
  this.runShell(text.slice(1).trim());
@@ -532,11 +616,13 @@ class ChatApp {
532
616
  if (this.tryDispatch(text))
533
617
  return;
534
618
  if (this.running) {
619
+ if (attachments.length > 0)
620
+ this.setStatus('attachments are dropped for messages queued mid-turn');
535
621
  this.queue.push(text);
536
622
  this.tui.requestRender();
537
623
  return;
538
624
  }
539
- this.send(text);
625
+ this.send(text, attachments);
540
626
  }
541
627
  clearPastes() {
542
628
  this.pastes.clear();
@@ -549,6 +635,7 @@ class ChatApp {
549
635
  const text = this.expandPastes(value);
550
636
  this.editor.setValue('');
551
637
  this.clearPastes();
638
+ this.clearAttachments();
552
639
  this.pushHistory(text);
553
640
  this.queue.push(text);
554
641
  this.tui.requestRender();
@@ -558,6 +645,10 @@ class ChatApp {
558
645
  if (!value.includes(placeholder))
559
646
  this.pastes.delete(placeholder);
560
647
  }
648
+ for (const placeholder of this.attachments.keys()) {
649
+ if (!value.includes(placeholder))
650
+ this.attachments.delete(placeholder);
651
+ }
561
652
  if (value !== this.paletteDismissedFor)
562
653
  this.paletteDismissedFor = '__none__';
563
654
  const items = this.paletteItems();