mu-harness 0.16.22 → 0.17.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.
@@ -54,12 +54,12 @@ export interface ChatHost {
54
54
  */
55
55
  banner?: string;
56
56
  /**
57
- * Lean input presentation: drop the surface-background input frame, the
58
- * model/provider/agent footer inside the input, and the context readout on
59
- * the status bar — leaving a bare prompt + editor. The {@link ChatHost.banner}
57
+ * Lean input presentation: keep the surface-background input frame but drop
58
+ * the model/provider/agent footer inside the input and the context readout on
59
+ * the status bar — leaving a framed prompt + editor. The {@link ChatHost.banner}
60
60
  * splash is independent and still shown if set. Hosts that want the full
61
- * information-rich input (surface frame, model · provider · @agent footer,
62
- * token/context usage in the status line) leave this unset (the default).
61
+ * information-rich input (model · provider · @agent footer, token/context usage
62
+ * in the status line) leave this unset (the default).
63
63
  */
64
64
  minimal?: boolean;
65
65
  onExit(code: number): void;
@@ -89,6 +89,8 @@ export declare class ChatApp {
89
89
  private running;
90
90
  private readonly queue;
91
91
  private readonly pendingShell;
92
+ private readonly pastes;
93
+ private pasteSeq;
92
94
  private models;
93
95
  private paletteCursor;
94
96
  private paletteDismissedFor;
@@ -132,8 +134,11 @@ export declare class ChatApp {
132
134
  private onTurnComplete;
133
135
  private stripFileMentions;
134
136
  private send;
137
+ private capturePaste;
138
+ private expandPastes;
135
139
  private flushShellContext;
136
140
  private submit;
141
+ private clearPastes;
137
142
  private enqueueFromInput;
138
143
  private onInputChange;
139
144
  private intercept;
@@ -148,7 +153,6 @@ export declare class ChatApp {
148
153
  private pickerVisible;
149
154
  private pickerMove;
150
155
  private pickerAccept;
151
- private deleteMention;
152
156
  private pushHistory;
153
157
  private navigateHistory;
154
158
  private newSession;
@@ -4,9 +4,9 @@ import { dirname, isAbsolute, relative, resolve } from 'node:path';
4
4
  import { box, column, flex, measure, ProcessTerminal, scrollView, truncateToWidth, TUI, visibleWidth, } from 'mu-tui';
5
5
  import { buildCommands, filterCommands } from './commands.js';
6
6
  import { MultilineEditor } from './editor.js';
7
- import { activeMention, collectCandidates, rank } from './picker.js';
7
+ import { activeMention, collectCandidates, mentionRanges, rank } from './picker.js';
8
8
  import { formatTokens, statusComponent, statusFromEvent } from './status.js';
9
- import { asHexColor, styleToAnsi, ThemeProvider, themesByName } from './theme.js';
9
+ import { asHexColor, fgToAnsi, styleToAnsi, ThemeProvider, themesByName } from './theme.js';
10
10
  import { entryComponent, formatToolArgs, stickyHeader, Transcript, transcriptComponent, } from './transcript.js';
11
11
  const RESET = '\x1b[0m';
12
12
  const PROMPT_WIDTH = 2;
@@ -88,6 +88,8 @@ export class ChatApp {
88
88
  running = false;
89
89
  queue = [];
90
90
  pendingShell = [];
91
+ pastes = new Map();
92
+ pasteSeq = 0;
91
93
  models = [];
92
94
  paletteCursor = 0;
93
95
  paletteDismissedFor = '__none__';
@@ -132,6 +134,20 @@ export class ChatApp {
132
134
  onSubmit: (value) => this.submit(value),
133
135
  onChange: (value) => this.onInputChange(value),
134
136
  });
137
+ this.editor.mentionRanges = (value, cursor) => {
138
+ const ranges = mentionRanges(value, activeMention(value, cursor)?.start);
139
+ for (const placeholder of this.pastes.keys()) {
140
+ for (let i = value.indexOf(placeholder); i !== -1; i = value.indexOf(placeholder, i + placeholder.length)) {
141
+ ranges.push({ start: i, end: i + placeholder.length });
142
+ }
143
+ }
144
+ return ranges;
145
+ };
146
+ this.editor.onPaste = (text) => this.capturePaste(text);
147
+ this.editor.chipColor = () => {
148
+ const bg = this.theme().styles.commandPaletteSelected.bg;
149
+ return bg ? fgToAnsi(bg) : '';
150
+ };
135
151
  this.scroll = scrollView({ render: (s) => transcriptComponent(this.transcript, this.theme()).render(s) }, { stickyHeader: (info) => this.stickyHeaderView(info), footer: () => this.jumpToBottomHint() });
136
152
  this.subScroll = scrollView({ render: (s) => transcriptComponent(this.subTranscript, this.theme()).render(s) });
137
153
  this.commands = buildCommands(this.commandHost()).filter((c) => {
@@ -143,6 +159,8 @@ export class ChatApp {
143
159
  });
144
160
  this.tui.setRoot({ render: (s) => this.root().render(s) });
145
161
  this.tui.setBackgroundColor(this.theme().colors.background);
162
+ this.tui.setToastBackground(this.theme().colors.surface);
163
+ this.tui.setToastForeground(this.theme().colors.text);
146
164
  this.tui.setFocus(this.editor);
147
165
  this.tui.addInputInterceptor((event) => this.intercept(event));
148
166
  this.tui.addGlobalKeybinding({ chord: { key: 'c', ctrl: true }, handler: () => this.onCtrlC() });
@@ -151,6 +169,8 @@ export class ChatApp {
151
169
  this.tui.addGlobalKeybinding({ chord: { key: 'end', ctrl: true }, handler: () => this.jumpToBottom() });
152
170
  this.unsubscribeTheme = this.themeProvider.subscribe(() => {
153
171
  this.tui.setBackgroundColor(this.theme().colors.background);
172
+ this.tui.setToastBackground(this.theme().colors.surface);
173
+ this.tui.setToastForeground(this.theme().colors.text);
154
174
  this.tui.requestRender(true);
155
175
  });
156
176
  this.bindSession();
@@ -462,6 +482,23 @@ export class ChatApp {
462
482
  this.showError(err instanceof Error ? err.message : String(err));
463
483
  });
464
484
  }
485
+ capturePaste(text) {
486
+ const lines = text.split('\n').length;
487
+ if (lines < 2 && text.length <= 200)
488
+ return undefined;
489
+ const id = ++this.pasteSeq;
490
+ const summary = lines > 1 ? `${lines} lines` : `${text.length} chars`;
491
+ const placeholder = `[pasted #${id}, ${summary}]`;
492
+ this.pastes.set(placeholder, text);
493
+ return placeholder;
494
+ }
495
+ expandPastes(text) {
496
+ let out = text;
497
+ for (const [placeholder, content] of this.pastes) {
498
+ out = out.split(placeholder).join(content);
499
+ }
500
+ return out;
501
+ }
465
502
  flushShellContext(userText) {
466
503
  if (this.pendingShell.length === 0)
467
504
  return userText;
@@ -478,35 +515,47 @@ export class ChatApp {
478
515
  if (this.modelPickerOpen || this.sessionPickerOpen)
479
516
  return;
480
517
  this.clearError();
481
- this.pushHistory(trimmed);
518
+ const text = this.expandPastes(trimmed);
482
519
  this.editor.setValue('');
483
- if (trimmed.startsWith('!') || trimmed.startsWith('$')) {
484
- this.runShell(trimmed.slice(1).trim());
520
+ this.clearPastes();
521
+ this.pushHistory(text);
522
+ if (text.startsWith('!') || text.startsWith('$')) {
523
+ this.runShell(text.slice(1).trim());
485
524
  return;
486
525
  }
487
- if (trimmed.startsWith('/')) {
488
- this.runCommand(trimmed);
526
+ if (text.startsWith('/')) {
527
+ this.runCommand(text);
489
528
  return;
490
529
  }
491
- if (this.tryDispatch(trimmed))
530
+ if (this.tryDispatch(text))
492
531
  return;
493
532
  if (this.running) {
494
- this.queue.push(trimmed);
533
+ this.queue.push(text);
495
534
  this.tui.requestRender();
496
535
  return;
497
536
  }
498
- this.send(trimmed);
537
+ this.send(text);
538
+ }
539
+ clearPastes() {
540
+ this.pastes.clear();
541
+ this.pasteSeq = 0;
499
542
  }
500
543
  enqueueFromInput() {
501
544
  const value = this.editor.getValue().trim();
502
545
  if (!value)
503
546
  return;
504
- this.pushHistory(value);
547
+ const text = this.expandPastes(value);
505
548
  this.editor.setValue('');
506
- this.queue.push(value);
549
+ this.clearPastes();
550
+ this.pushHistory(text);
551
+ this.queue.push(text);
507
552
  this.tui.requestRender();
508
553
  }
509
554
  onInputChange(value) {
555
+ for (const placeholder of this.pastes.keys()) {
556
+ if (!value.includes(placeholder))
557
+ this.pastes.delete(placeholder);
558
+ }
510
559
  if (value !== this.paletteDismissedFor)
511
560
  this.paletteDismissedFor = '__none__';
512
561
  const items = this.paletteItems();
@@ -554,11 +603,6 @@ export class ChatApp {
554
603
  }
555
604
  if (key === 'escape' || key === 'esc')
556
605
  return this.onEscape();
557
- if (key === 'backspace') {
558
- if (this.deleteMention())
559
- return true;
560
- return false;
561
- }
562
606
  if (key === 'enter' && event.alt) {
563
607
  if (this.running) {
564
608
  this.enqueueFromInput();
@@ -716,25 +760,6 @@ export class ChatApp {
716
760
  this.tui.requestRender();
717
761
  return true;
718
762
  }
719
- deleteMention() {
720
- if (this.pickerMention !== undefined)
721
- return false;
722
- const value = this.editor.getValue();
723
- const cursor = this.editor.cursorPos;
724
- const re = /@[^\s]+/g;
725
- let match;
726
- while ((match = re.exec(value)) !== null) {
727
- const start = match.index;
728
- const end = start + match[0].length;
729
- if (cursor > start && cursor <= end) {
730
- this.editor.setValue(value.slice(0, start) + value.slice(end));
731
- this.editor.setCursor(start);
732
- this.tui.requestRender();
733
- return true;
734
- }
735
- }
736
- return false;
737
- }
738
763
  pushHistory(text) {
739
764
  this.host.history?.append(text);
740
765
  if (this.history[this.history.length - 1] !== text)
@@ -1091,8 +1116,6 @@ export class ChatApp {
1091
1116
  }
1092
1117
  inputPanel() {
1093
1118
  const inner = this.approvalView() ?? this.editorInner();
1094
- if (this.minimal)
1095
- return box(inner, { padding: 0 });
1096
1119
  return box(inner, { background: this.theme().colors.surface, padding: 1 });
1097
1120
  }
1098
1121
  editorInner() {
@@ -5,12 +5,19 @@ export interface MultilineEditorOptions {
5
5
  onSubmit?: (value: string) => void;
6
6
  onChange?: (value: string) => void;
7
7
  }
8
+ interface ChipRange {
9
+ start: number;
10
+ end: number;
11
+ }
8
12
  export declare class MultilineEditor implements Component {
9
13
  private value;
10
14
  private cursor;
11
15
  private readonly placeholder;
12
16
  private readonly maxRows;
13
17
  hiddenPrefix: string;
18
+ chipColor?: () => string;
19
+ mentionRanges?: (value: string, cursor: number) => ChipRange[];
20
+ onPaste?: (text: string) => string | undefined;
14
21
  onSubmit?: (value: string) => void;
15
22
  onChange?: (value: string) => void;
16
23
  constructor(opts?: MultilineEditorOptions);
@@ -24,8 +31,13 @@ export declare class MultilineEditor implements Component {
24
31
  private lineStart;
25
32
  private lineEnd;
26
33
  private insert;
34
+ private chips;
35
+ private moveLeft;
36
+ private moveRight;
27
37
  private backspace;
28
38
  private deleteForward;
29
39
  private cursorRowCol;
30
40
  render(s: Surface): void;
41
+ private renderRow;
31
42
  }
43
+ export {};
@@ -2,12 +2,16 @@ import { truncateToWidth, visibleWidth } from 'mu-tui';
2
2
  const CURSOR = '\x1b[7m';
3
3
  const RESET = '\x1b[0m';
4
4
  const DIM = '\x1b[2m';
5
+ const CHIP = '\x1b[33m';
5
6
  export class MultilineEditor {
6
7
  value = '';
7
8
  cursor = 0;
8
9
  placeholder;
9
10
  maxRows;
10
11
  hiddenPrefix = '';
12
+ chipColor;
13
+ mentionRanges;
14
+ onPaste;
11
15
  onSubmit;
12
16
  onChange;
13
17
  constructor(opts = {}) {
@@ -36,7 +40,7 @@ export class MultilineEditor {
36
40
  }
37
41
  handleInput(event) {
38
42
  if (event.type === 'paste') {
39
- this.insert(event.text);
43
+ this.insert(this.onPaste?.(event.text) ?? event.text);
40
44
  return;
41
45
  }
42
46
  if (event.type === 'text') {
@@ -62,10 +66,10 @@ export class MultilineEditor {
62
66
  this.deleteForward();
63
67
  return;
64
68
  case 'left':
65
- this.cursor = Math.max(0, this.cursor - 1);
69
+ this.moveLeft();
66
70
  return;
67
71
  case 'right':
68
- this.cursor = Math.min(this.value.length, this.cursor + 1);
72
+ this.moveRight();
69
73
  return;
70
74
  case 'home':
71
75
  this.cursor = this.lineStart();
@@ -90,17 +94,35 @@ export class MultilineEditor {
90
94
  this.cursor += text.length;
91
95
  this.onChange?.(this.value);
92
96
  }
97
+ chips() {
98
+ return this.mentionRanges ? this.mentionRanges(this.value, this.cursor) : [];
99
+ }
100
+ moveLeft() {
101
+ const chip = this.chips().find((c) => c.start < this.cursor && this.cursor <= c.end);
102
+ this.cursor = chip ? chip.start : Math.max(0, this.cursor - 1);
103
+ }
104
+ moveRight() {
105
+ const chip = this.chips().find((c) => c.start <= this.cursor && this.cursor < c.end);
106
+ this.cursor = chip ? chip.end : Math.min(this.value.length, this.cursor + 1);
107
+ }
93
108
  backspace() {
94
109
  if (this.cursor === 0)
95
110
  return;
96
- this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor);
97
- this.cursor -= 1;
111
+ const chip = this.chips().find((c) => c.start < this.cursor && this.cursor <= c.end);
112
+ const from = chip ? chip.start : this.cursor - 1;
113
+ const to = chip ? chip.end : this.cursor;
114
+ this.value = this.value.slice(0, from) + this.value.slice(to);
115
+ this.cursor = from;
98
116
  this.onChange?.(this.value);
99
117
  }
100
118
  deleteForward() {
101
119
  if (this.cursor >= this.value.length)
102
120
  return;
103
- this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1);
121
+ const chip = this.chips().find((c) => c.start <= this.cursor && this.cursor < c.end);
122
+ const from = chip ? chip.start : this.cursor;
123
+ const to = chip ? chip.end : this.cursor + 1;
124
+ this.value = this.value.slice(0, from) + this.value.slice(to);
125
+ this.cursor = from;
104
126
  this.onChange?.(this.value);
105
127
  }
106
128
  cursorRowCol(lines, cursor) {
@@ -124,24 +146,60 @@ export class MultilineEditor {
124
146
  s.text(0, 0, `${DIM}${ph}${RESET}`);
125
147
  return;
126
148
  }
149
+ const off = hidden ? 1 : 0;
150
+ const chipColor = this.chipColor?.() || CHIP;
151
+ const chips = this.chips()
152
+ .map((c) => ({ start: c.start - off, end: c.end - off }))
153
+ .filter((c) => c.end > 0);
127
154
  const lines = value.split('\n');
128
155
  const { row: cr, col: cc } = this.cursorRowCol(lines, cursorIdx);
129
156
  const height = Math.max(1, s.height);
130
157
  const top = cr >= height ? cr - height + 1 : 0;
158
+ const lineStarts = [];
159
+ let offset = 0;
160
+ for (const line of lines) {
161
+ lineStarts.push(offset);
162
+ offset += line.length + 1;
163
+ }
131
164
  for (let r = 0; r < height && top + r < lines.length; r++) {
132
- const line = lines[top + r];
133
- if (top + r === cr && s.focused) {
134
- const hscroll = cc >= width ? cc - width + 1 : 0;
135
- const visible = line.slice(hscroll, hscroll + width);
136
- const col = cc - hscroll;
137
- const before = visible.slice(0, col);
138
- const at = visible.slice(col, col + 1) || ' ';
139
- const after = visible.slice(col + 1);
140
- s.text(0, r, `${before}${CURSOR}${at}${RESET}${after}`);
165
+ const idx = top + r;
166
+ const line = lines[idx];
167
+ const isCursorRow = idx === cr && s.focused;
168
+ const hscroll = isCursorRow && cc >= width ? cc - width + 1 : 0;
169
+ s.text(0, r, this.renderRow(line, lineStarts[idx], chips, chipColor, hscroll, width, isCursorRow ? cc : null));
170
+ }
171
+ }
172
+ renderRow(line, lineStart, chips, chipColor, hscroll, width, cursorCol) {
173
+ const inChip = (abs) => chips.some((c) => abs >= c.start && abs < c.end);
174
+ let out = '';
175
+ let yellow = false;
176
+ for (let c = hscroll; c < hscroll + width; c++) {
177
+ const isCursor = cursorCol !== null && c === cursorCol;
178
+ const hasChar = c < line.length;
179
+ if (!hasChar && !isCursor)
180
+ break;
181
+ const ch = hasChar ? line[c] : ' ';
182
+ if (isCursor) {
183
+ if (yellow) {
184
+ out += RESET;
185
+ yellow = false;
186
+ }
187
+ out += `${CURSOR}${ch}${RESET}`;
188
+ continue;
189
+ }
190
+ const wantYellow = hasChar && inChip(lineStart + c);
191
+ if (wantYellow && !yellow) {
192
+ out += chipColor;
193
+ yellow = true;
141
194
  }
142
- else {
143
- s.text(0, r, line.length > width ? line.slice(0, width) : line);
195
+ else if (!wantYellow && yellow) {
196
+ out += RESET;
197
+ yellow = false;
144
198
  }
199
+ out += ch;
145
200
  }
201
+ if (yellow)
202
+ out += RESET;
203
+ return out;
146
204
  }
147
205
  }
@@ -5,6 +5,11 @@ export interface Candidate {
5
5
  }
6
6
  export declare function collectCandidates(cwd: string, agentNames: string[]): Candidate[];
7
7
  export declare function rank(query: string, candidates: Candidate[], limit?: number): Candidate[];
8
+ export interface MentionRange {
9
+ start: number;
10
+ end: number;
11
+ }
12
+ export declare function mentionRanges(value: string, excludeStart?: number): MentionRange[];
8
13
  export interface ActiveMention {
9
14
  start: number;
10
15
  query: string;
@@ -169,6 +169,20 @@ export function rank(query, candidates, limit = 8) {
169
169
  scored.sort((a, b) => b.score - a.score);
170
170
  return scored.slice(0, limit).map((entry) => entry.candidate);
171
171
  }
172
+ export function mentionRanges(value, excludeStart) {
173
+ const ranges = [];
174
+ const re = /@[^\s]+/g;
175
+ let match;
176
+ while ((match = re.exec(value)) !== null) {
177
+ const start = match.index;
178
+ if (start === excludeStart)
179
+ continue;
180
+ if (start > 0 && !/\s/.test(value[start - 1] ?? ' '))
181
+ continue;
182
+ ranges.push({ start, end: start + match[0].length });
183
+ }
184
+ return ranges;
185
+ }
172
186
  export function activeMention(value, cursor) {
173
187
  let start = -1;
174
188
  for (let i = cursor - 1; i >= 0; i--) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mu-harness",
3
- "version": "0.16.22",
3
+ "version": "0.17.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.16.22",
27
- "mu-tui": "^0.16.22"
26
+ "mu-core": "^0.17.1",
27
+ "mu-tui": "^0.17.1"
28
28
  },
29
29
  "_generatedBy": "dnt@dev",
30
30
  "types": "./esm/index.d.ts"
@@ -54,12 +54,12 @@ export interface ChatHost {
54
54
  */
55
55
  banner?: string;
56
56
  /**
57
- * Lean input presentation: drop the surface-background input frame, the
58
- * model/provider/agent footer inside the input, and the context readout on
59
- * the status bar — leaving a bare prompt + editor. The {@link ChatHost.banner}
57
+ * Lean input presentation: keep the surface-background input frame but drop
58
+ * the model/provider/agent footer inside the input and the context readout on
59
+ * the status bar — leaving a framed prompt + editor. The {@link ChatHost.banner}
60
60
  * splash is independent and still shown if set. Hosts that want the full
61
- * information-rich input (surface frame, model · provider · @agent footer,
62
- * token/context usage in the status line) leave this unset (the default).
61
+ * information-rich input (model · provider · @agent footer, token/context usage
62
+ * in the status line) leave this unset (the default).
63
63
  */
64
64
  minimal?: boolean;
65
65
  onExit(code: number): void;
@@ -89,6 +89,8 @@ export declare class ChatApp {
89
89
  private running;
90
90
  private readonly queue;
91
91
  private readonly pendingShell;
92
+ private readonly pastes;
93
+ private pasteSeq;
92
94
  private models;
93
95
  private paletteCursor;
94
96
  private paletteDismissedFor;
@@ -132,8 +134,11 @@ export declare class ChatApp {
132
134
  private onTurnComplete;
133
135
  private stripFileMentions;
134
136
  private send;
137
+ private capturePaste;
138
+ private expandPastes;
135
139
  private flushShellContext;
136
140
  private submit;
141
+ private clearPastes;
137
142
  private enqueueFromInput;
138
143
  private onInputChange;
139
144
  private intercept;
@@ -148,7 +153,6 @@ export declare class ChatApp {
148
153
  private pickerVisible;
149
154
  private pickerMove;
150
155
  private pickerAccept;
151
- private deleteMention;
152
156
  private pushHistory;
153
157
  private navigateHistory;
154
158
  private newSession;
@@ -91,6 +91,8 @@ class ChatApp {
91
91
  running = false;
92
92
  queue = [];
93
93
  pendingShell = [];
94
+ pastes = new Map();
95
+ pasteSeq = 0;
94
96
  models = [];
95
97
  paletteCursor = 0;
96
98
  paletteDismissedFor = '__none__';
@@ -135,6 +137,20 @@ class ChatApp {
135
137
  onSubmit: (value) => this.submit(value),
136
138
  onChange: (value) => this.onInputChange(value),
137
139
  });
140
+ this.editor.mentionRanges = (value, cursor) => {
141
+ const ranges = (0, picker_js_1.mentionRanges)(value, (0, picker_js_1.activeMention)(value, cursor)?.start);
142
+ for (const placeholder of this.pastes.keys()) {
143
+ for (let i = value.indexOf(placeholder); i !== -1; i = value.indexOf(placeholder, i + placeholder.length)) {
144
+ ranges.push({ start: i, end: i + placeholder.length });
145
+ }
146
+ }
147
+ return ranges;
148
+ };
149
+ this.editor.onPaste = (text) => this.capturePaste(text);
150
+ this.editor.chipColor = () => {
151
+ const bg = this.theme().styles.commandPaletteSelected.bg;
152
+ return bg ? (0, theme_js_1.fgToAnsi)(bg) : '';
153
+ };
138
154
  this.scroll = (0, mu_tui_1.scrollView)({ render: (s) => (0, transcript_js_1.transcriptComponent)(this.transcript, this.theme()).render(s) }, { stickyHeader: (info) => this.stickyHeaderView(info), footer: () => this.jumpToBottomHint() });
139
155
  this.subScroll = (0, mu_tui_1.scrollView)({ render: (s) => (0, transcript_js_1.transcriptComponent)(this.subTranscript, this.theme()).render(s) });
140
156
  this.commands = (0, commands_js_1.buildCommands)(this.commandHost()).filter((c) => {
@@ -146,6 +162,8 @@ class ChatApp {
146
162
  });
147
163
  this.tui.setRoot({ render: (s) => this.root().render(s) });
148
164
  this.tui.setBackgroundColor(this.theme().colors.background);
165
+ this.tui.setToastBackground(this.theme().colors.surface);
166
+ this.tui.setToastForeground(this.theme().colors.text);
149
167
  this.tui.setFocus(this.editor);
150
168
  this.tui.addInputInterceptor((event) => this.intercept(event));
151
169
  this.tui.addGlobalKeybinding({ chord: { key: 'c', ctrl: true }, handler: () => this.onCtrlC() });
@@ -154,6 +172,8 @@ class ChatApp {
154
172
  this.tui.addGlobalKeybinding({ chord: { key: 'end', ctrl: true }, handler: () => this.jumpToBottom() });
155
173
  this.unsubscribeTheme = this.themeProvider.subscribe(() => {
156
174
  this.tui.setBackgroundColor(this.theme().colors.background);
175
+ this.tui.setToastBackground(this.theme().colors.surface);
176
+ this.tui.setToastForeground(this.theme().colors.text);
157
177
  this.tui.requestRender(true);
158
178
  });
159
179
  this.bindSession();
@@ -465,6 +485,23 @@ class ChatApp {
465
485
  this.showError(err instanceof Error ? err.message : String(err));
466
486
  });
467
487
  }
488
+ capturePaste(text) {
489
+ const lines = text.split('\n').length;
490
+ if (lines < 2 && text.length <= 200)
491
+ return undefined;
492
+ const id = ++this.pasteSeq;
493
+ const summary = lines > 1 ? `${lines} lines` : `${text.length} chars`;
494
+ const placeholder = `[pasted #${id}, ${summary}]`;
495
+ this.pastes.set(placeholder, text);
496
+ return placeholder;
497
+ }
498
+ expandPastes(text) {
499
+ let out = text;
500
+ for (const [placeholder, content] of this.pastes) {
501
+ out = out.split(placeholder).join(content);
502
+ }
503
+ return out;
504
+ }
468
505
  flushShellContext(userText) {
469
506
  if (this.pendingShell.length === 0)
470
507
  return userText;
@@ -481,35 +518,47 @@ class ChatApp {
481
518
  if (this.modelPickerOpen || this.sessionPickerOpen)
482
519
  return;
483
520
  this.clearError();
484
- this.pushHistory(trimmed);
521
+ const text = this.expandPastes(trimmed);
485
522
  this.editor.setValue('');
486
- if (trimmed.startsWith('!') || trimmed.startsWith('$')) {
487
- this.runShell(trimmed.slice(1).trim());
523
+ this.clearPastes();
524
+ this.pushHistory(text);
525
+ if (text.startsWith('!') || text.startsWith('$')) {
526
+ this.runShell(text.slice(1).trim());
488
527
  return;
489
528
  }
490
- if (trimmed.startsWith('/')) {
491
- this.runCommand(trimmed);
529
+ if (text.startsWith('/')) {
530
+ this.runCommand(text);
492
531
  return;
493
532
  }
494
- if (this.tryDispatch(trimmed))
533
+ if (this.tryDispatch(text))
495
534
  return;
496
535
  if (this.running) {
497
- this.queue.push(trimmed);
536
+ this.queue.push(text);
498
537
  this.tui.requestRender();
499
538
  return;
500
539
  }
501
- this.send(trimmed);
540
+ this.send(text);
541
+ }
542
+ clearPastes() {
543
+ this.pastes.clear();
544
+ this.pasteSeq = 0;
502
545
  }
503
546
  enqueueFromInput() {
504
547
  const value = this.editor.getValue().trim();
505
548
  if (!value)
506
549
  return;
507
- this.pushHistory(value);
550
+ const text = this.expandPastes(value);
508
551
  this.editor.setValue('');
509
- this.queue.push(value);
552
+ this.clearPastes();
553
+ this.pushHistory(text);
554
+ this.queue.push(text);
510
555
  this.tui.requestRender();
511
556
  }
512
557
  onInputChange(value) {
558
+ for (const placeholder of this.pastes.keys()) {
559
+ if (!value.includes(placeholder))
560
+ this.pastes.delete(placeholder);
561
+ }
513
562
  if (value !== this.paletteDismissedFor)
514
563
  this.paletteDismissedFor = '__none__';
515
564
  const items = this.paletteItems();
@@ -557,11 +606,6 @@ class ChatApp {
557
606
  }
558
607
  if (key === 'escape' || key === 'esc')
559
608
  return this.onEscape();
560
- if (key === 'backspace') {
561
- if (this.deleteMention())
562
- return true;
563
- return false;
564
- }
565
609
  if (key === 'enter' && event.alt) {
566
610
  if (this.running) {
567
611
  this.enqueueFromInput();
@@ -719,25 +763,6 @@ class ChatApp {
719
763
  this.tui.requestRender();
720
764
  return true;
721
765
  }
722
- deleteMention() {
723
- if (this.pickerMention !== undefined)
724
- return false;
725
- const value = this.editor.getValue();
726
- const cursor = this.editor.cursorPos;
727
- const re = /@[^\s]+/g;
728
- let match;
729
- while ((match = re.exec(value)) !== null) {
730
- const start = match.index;
731
- const end = start + match[0].length;
732
- if (cursor > start && cursor <= end) {
733
- this.editor.setValue(value.slice(0, start) + value.slice(end));
734
- this.editor.setCursor(start);
735
- this.tui.requestRender();
736
- return true;
737
- }
738
- }
739
- return false;
740
- }
741
766
  pushHistory(text) {
742
767
  this.host.history?.append(text);
743
768
  if (this.history[this.history.length - 1] !== text)
@@ -1094,8 +1119,6 @@ class ChatApp {
1094
1119
  }
1095
1120
  inputPanel() {
1096
1121
  const inner = this.approvalView() ?? this.editorInner();
1097
- if (this.minimal)
1098
- return (0, mu_tui_1.box)(inner, { padding: 0 });
1099
1122
  return (0, mu_tui_1.box)(inner, { background: this.theme().colors.surface, padding: 1 });
1100
1123
  }
1101
1124
  editorInner() {
@@ -5,12 +5,19 @@ export interface MultilineEditorOptions {
5
5
  onSubmit?: (value: string) => void;
6
6
  onChange?: (value: string) => void;
7
7
  }
8
+ interface ChipRange {
9
+ start: number;
10
+ end: number;
11
+ }
8
12
  export declare class MultilineEditor implements Component {
9
13
  private value;
10
14
  private cursor;
11
15
  private readonly placeholder;
12
16
  private readonly maxRows;
13
17
  hiddenPrefix: string;
18
+ chipColor?: () => string;
19
+ mentionRanges?: (value: string, cursor: number) => ChipRange[];
20
+ onPaste?: (text: string) => string | undefined;
14
21
  onSubmit?: (value: string) => void;
15
22
  onChange?: (value: string) => void;
16
23
  constructor(opts?: MultilineEditorOptions);
@@ -24,8 +31,13 @@ export declare class MultilineEditor implements Component {
24
31
  private lineStart;
25
32
  private lineEnd;
26
33
  private insert;
34
+ private chips;
35
+ private moveLeft;
36
+ private moveRight;
27
37
  private backspace;
28
38
  private deleteForward;
29
39
  private cursorRowCol;
30
40
  render(s: Surface): void;
41
+ private renderRow;
31
42
  }
43
+ export {};
@@ -5,12 +5,16 @@ const mu_tui_1 = require("mu-tui");
5
5
  const CURSOR = '\x1b[7m';
6
6
  const RESET = '\x1b[0m';
7
7
  const DIM = '\x1b[2m';
8
+ const CHIP = '\x1b[33m';
8
9
  class MultilineEditor {
9
10
  value = '';
10
11
  cursor = 0;
11
12
  placeholder;
12
13
  maxRows;
13
14
  hiddenPrefix = '';
15
+ chipColor;
16
+ mentionRanges;
17
+ onPaste;
14
18
  onSubmit;
15
19
  onChange;
16
20
  constructor(opts = {}) {
@@ -39,7 +43,7 @@ class MultilineEditor {
39
43
  }
40
44
  handleInput(event) {
41
45
  if (event.type === 'paste') {
42
- this.insert(event.text);
46
+ this.insert(this.onPaste?.(event.text) ?? event.text);
43
47
  return;
44
48
  }
45
49
  if (event.type === 'text') {
@@ -65,10 +69,10 @@ class MultilineEditor {
65
69
  this.deleteForward();
66
70
  return;
67
71
  case 'left':
68
- this.cursor = Math.max(0, this.cursor - 1);
72
+ this.moveLeft();
69
73
  return;
70
74
  case 'right':
71
- this.cursor = Math.min(this.value.length, this.cursor + 1);
75
+ this.moveRight();
72
76
  return;
73
77
  case 'home':
74
78
  this.cursor = this.lineStart();
@@ -93,17 +97,35 @@ class MultilineEditor {
93
97
  this.cursor += text.length;
94
98
  this.onChange?.(this.value);
95
99
  }
100
+ chips() {
101
+ return this.mentionRanges ? this.mentionRanges(this.value, this.cursor) : [];
102
+ }
103
+ moveLeft() {
104
+ const chip = this.chips().find((c) => c.start < this.cursor && this.cursor <= c.end);
105
+ this.cursor = chip ? chip.start : Math.max(0, this.cursor - 1);
106
+ }
107
+ moveRight() {
108
+ const chip = this.chips().find((c) => c.start <= this.cursor && this.cursor < c.end);
109
+ this.cursor = chip ? chip.end : Math.min(this.value.length, this.cursor + 1);
110
+ }
96
111
  backspace() {
97
112
  if (this.cursor === 0)
98
113
  return;
99
- this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor);
100
- this.cursor -= 1;
114
+ const chip = this.chips().find((c) => c.start < this.cursor && this.cursor <= c.end);
115
+ const from = chip ? chip.start : this.cursor - 1;
116
+ const to = chip ? chip.end : this.cursor;
117
+ this.value = this.value.slice(0, from) + this.value.slice(to);
118
+ this.cursor = from;
101
119
  this.onChange?.(this.value);
102
120
  }
103
121
  deleteForward() {
104
122
  if (this.cursor >= this.value.length)
105
123
  return;
106
- this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1);
124
+ const chip = this.chips().find((c) => c.start <= this.cursor && this.cursor < c.end);
125
+ const from = chip ? chip.start : this.cursor;
126
+ const to = chip ? chip.end : this.cursor + 1;
127
+ this.value = this.value.slice(0, from) + this.value.slice(to);
128
+ this.cursor = from;
107
129
  this.onChange?.(this.value);
108
130
  }
109
131
  cursorRowCol(lines, cursor) {
@@ -127,25 +149,61 @@ class MultilineEditor {
127
149
  s.text(0, 0, `${DIM}${ph}${RESET}`);
128
150
  return;
129
151
  }
152
+ const off = hidden ? 1 : 0;
153
+ const chipColor = this.chipColor?.() || CHIP;
154
+ const chips = this.chips()
155
+ .map((c) => ({ start: c.start - off, end: c.end - off }))
156
+ .filter((c) => c.end > 0);
130
157
  const lines = value.split('\n');
131
158
  const { row: cr, col: cc } = this.cursorRowCol(lines, cursorIdx);
132
159
  const height = Math.max(1, s.height);
133
160
  const top = cr >= height ? cr - height + 1 : 0;
161
+ const lineStarts = [];
162
+ let offset = 0;
163
+ for (const line of lines) {
164
+ lineStarts.push(offset);
165
+ offset += line.length + 1;
166
+ }
134
167
  for (let r = 0; r < height && top + r < lines.length; r++) {
135
- const line = lines[top + r];
136
- if (top + r === cr && s.focused) {
137
- const hscroll = cc >= width ? cc - width + 1 : 0;
138
- const visible = line.slice(hscroll, hscroll + width);
139
- const col = cc - hscroll;
140
- const before = visible.slice(0, col);
141
- const at = visible.slice(col, col + 1) || ' ';
142
- const after = visible.slice(col + 1);
143
- s.text(0, r, `${before}${CURSOR}${at}${RESET}${after}`);
168
+ const idx = top + r;
169
+ const line = lines[idx];
170
+ const isCursorRow = idx === cr && s.focused;
171
+ const hscroll = isCursorRow && cc >= width ? cc - width + 1 : 0;
172
+ s.text(0, r, this.renderRow(line, lineStarts[idx], chips, chipColor, hscroll, width, isCursorRow ? cc : null));
173
+ }
174
+ }
175
+ renderRow(line, lineStart, chips, chipColor, hscroll, width, cursorCol) {
176
+ const inChip = (abs) => chips.some((c) => abs >= c.start && abs < c.end);
177
+ let out = '';
178
+ let yellow = false;
179
+ for (let c = hscroll; c < hscroll + width; c++) {
180
+ const isCursor = cursorCol !== null && c === cursorCol;
181
+ const hasChar = c < line.length;
182
+ if (!hasChar && !isCursor)
183
+ break;
184
+ const ch = hasChar ? line[c] : ' ';
185
+ if (isCursor) {
186
+ if (yellow) {
187
+ out += RESET;
188
+ yellow = false;
189
+ }
190
+ out += `${CURSOR}${ch}${RESET}`;
191
+ continue;
192
+ }
193
+ const wantYellow = hasChar && inChip(lineStart + c);
194
+ if (wantYellow && !yellow) {
195
+ out += chipColor;
196
+ yellow = true;
144
197
  }
145
- else {
146
- s.text(0, r, line.length > width ? line.slice(0, width) : line);
198
+ else if (!wantYellow && yellow) {
199
+ out += RESET;
200
+ yellow = false;
147
201
  }
202
+ out += ch;
148
203
  }
204
+ if (yellow)
205
+ out += RESET;
206
+ return out;
149
207
  }
150
208
  }
151
209
  exports.MultilineEditor = MultilineEditor;
@@ -5,6 +5,11 @@ export interface Candidate {
5
5
  }
6
6
  export declare function collectCandidates(cwd: string, agentNames: string[]): Candidate[];
7
7
  export declare function rank(query: string, candidates: Candidate[], limit?: number): Candidate[];
8
+ export interface MentionRange {
9
+ start: number;
10
+ end: number;
11
+ }
12
+ export declare function mentionRanges(value: string, excludeStart?: number): MentionRange[];
8
13
  export interface ActiveMention {
9
14
  start: number;
10
15
  query: string;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.collectCandidates = collectCandidates;
4
4
  exports.rank = rank;
5
+ exports.mentionRanges = mentionRanges;
5
6
  exports.activeMention = activeMention;
6
7
  const node_child_process_1 = require("node:child_process");
7
8
  const node_fs_1 = require("node:fs");
@@ -174,6 +175,20 @@ function rank(query, candidates, limit = 8) {
174
175
  scored.sort((a, b) => b.score - a.score);
175
176
  return scored.slice(0, limit).map((entry) => entry.candidate);
176
177
  }
178
+ function mentionRanges(value, excludeStart) {
179
+ const ranges = [];
180
+ const re = /@[^\s]+/g;
181
+ let match;
182
+ while ((match = re.exec(value)) !== null) {
183
+ const start = match.index;
184
+ if (start === excludeStart)
185
+ continue;
186
+ if (start > 0 && !/\s/.test(value[start - 1] ?? ' '))
187
+ continue;
188
+ ranges.push({ start, end: start + match[0].length });
189
+ }
190
+ return ranges;
191
+ }
177
192
  function activeMention(value, cursor) {
178
193
  let start = -1;
179
194
  for (let i = cursor - 1; i >= 0; i--) {