mu-coding 0.13.0 → 0.16.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.
- package/README.md +9 -123
- package/bin/coding-agent.ts +95 -0
- package/package.json +10 -21
- package/src/config.ts +122 -0
- package/src/harness.test.ts +159 -0
- package/src/main.ts +53 -3
- package/src/plugins.ts +49 -0
- package/src/systemPrompt.ts +22 -0
- package/src/ui/ChatApp.ts +959 -0
- package/src/ui/commands.ts +35 -0
- package/src/ui/editor.ts +166 -0
- package/src/ui/markdown.ts +363 -0
- package/src/ui/picker.ts +126 -0
- package/src/ui/status.ts +61 -0
- package/src/ui/theme.ts +241 -0
- package/src/ui/transcript.test.ts +121 -0
- package/src/ui/transcript.ts +399 -0
- package/tsconfig.json +8 -0
- package/bin/mu.js +0 -2
- package/prompts/SYSTEM.md +0 -16
- package/src/app/shutdown.ts +0 -94
- package/src/app/startApp.ts +0 -49
- package/src/cli/args.ts +0 -133
- package/src/cli/install.ts +0 -107
- package/src/cli/subcommands.ts +0 -29
- package/src/cli/update.ts +0 -205
- package/src/config/index.test.ts +0 -77
- package/src/config/index.ts +0 -199
- package/src/plugin.ts +0 -124
- package/src/runtime/codingTools/bash.ts +0 -114
- package/src/runtime/codingTools/edit-file.ts +0 -60
- package/src/runtime/codingTools/index.ts +0 -39
- package/src/runtime/codingTools/read-file.ts +0 -83
- package/src/runtime/codingTools/utils.ts +0 -21
- package/src/runtime/codingTools/write-file.ts +0 -42
- package/src/runtime/createRegistry.test.ts +0 -147
- package/src/runtime/createRegistry.ts +0 -195
- package/src/runtime/fileMentionProvider.ts +0 -117
- package/src/runtime/messageBus.test.ts +0 -62
- package/src/runtime/messageBus.ts +0 -78
- package/src/runtime/pluginLoader.ts +0 -153
- package/src/runtime/startupUpdateCheck.ts +0 -163
- package/src/runtime/updateCheck.ts +0 -136
- package/src/sessions/index.test.ts +0 -66
- package/src/sessions/index.ts +0 -183
- package/src/sessions/peek.test.ts +0 -88
- package/src/sessions/project.ts +0 -51
- package/src/tui/channel/tuiChannel.test.ts +0 -107
- package/src/tui/channel/tuiChannel.ts +0 -62
- package/src/tui/chat/ChatContext.ts +0 -10
- package/src/tui/chat/MessageRendererContext.ts +0 -44
- package/src/tui/chat/ToolDisplayContext.ts +0 -33
- package/src/tui/chat/useAbort.ts +0 -85
- package/src/tui/chat/useAttachment.ts +0 -74
- package/src/tui/chat/useChat.ts +0 -113
- package/src/tui/chat/useChatPanel.ts +0 -119
- package/src/tui/chat/useChatSession.ts +0 -382
- package/src/tui/chat/useModels.ts +0 -83
- package/src/tui/chat/usePluginStatus.ts +0 -44
- package/src/tui/chat/useSessionPersistence.ts +0 -84
- package/src/tui/chat/useStatusSegments.ts +0 -82
- package/src/tui/chat/useSubagentBrowser.ts +0 -133
- package/src/tui/components/chat/ChatPanel.tsx +0 -54
- package/src/tui/components/chat/ChatPanelBody.tsx +0 -86
- package/src/tui/components/chat/Pickers.tsx +0 -44
- package/src/tui/components/chat/SubagentBrowserPanel.tsx +0 -145
- package/src/tui/components/messageView.tsx +0 -72
- package/src/tui/components/messages/EditOutput.tsx +0 -112
- package/src/tui/components/messages/ReadOutput.tsx +0 -48
- package/src/tui/components/messages/ToolHeader.tsx +0 -30
- package/src/tui/components/messages/WebFetchOutput.tsx +0 -30
- package/src/tui/components/messages/WriteOutput.tsx +0 -64
- package/src/tui/components/messages/assistantMessage.tsx +0 -72
- package/src/tui/components/messages/markdown.tsx +0 -407
- package/src/tui/components/messages/messageItem.tsx +0 -43
- package/src/tui/components/messages/reasoningBlock.tsx +0 -18
- package/src/tui/components/messages/streamingOutput.tsx +0 -18
- package/src/tui/components/messages/toolCallBlock.tsx +0 -125
- package/src/tui/components/messages/userMessage.tsx +0 -44
- package/src/tui/components/primitives/dropdown.tsx +0 -125
- package/src/tui/components/primitives/modal.tsx +0 -47
- package/src/tui/components/primitives/pickerModal.tsx +0 -47
- package/src/tui/components/primitives/scrollbar.tsx +0 -27
- package/src/tui/components/primitives/toast.tsx +0 -100
- package/src/tui/components/statusBar.tsx +0 -41
- package/src/tui/components/ui/dialogLayer.tsx +0 -175
- package/src/tui/context/ThemeContext.tsx +0 -18
- package/src/tui/hooks/useChordKeyboard.ts +0 -87
- package/src/tui/hooks/useInputInfoSegments.ts +0 -22
- package/src/tui/hooks/useScroll.ts +0 -64
- package/src/tui/hooks/useTerminal.ts +0 -40
- package/src/tui/hooks/useUI.ts +0 -15
- package/src/tui/input/InputBox.tsx +0 -6
- package/src/tui/input/InputBoxView.tsx +0 -293
- package/src/tui/input/commands.test.ts +0 -71
- package/src/tui/input/commands.ts +0 -55
- package/src/tui/input/cursor.test.ts +0 -136
- package/src/tui/input/cursor.ts +0 -214
- package/src/tui/input/dumpContext.ts +0 -107
- package/src/tui/input/sanitize.ts +0 -33
- package/src/tui/input/useCommandExecutor.ts +0 -32
- package/src/tui/input/useInputBox.ts +0 -265
- package/src/tui/input/useInputHandler.ts +0 -455
- package/src/tui/input/useMentionPicker.ts +0 -133
- package/src/tui/input/usePluginShortcuts.ts +0 -29
- package/src/tui/plugins/InkApprovalChannel.test.ts +0 -51
- package/src/tui/plugins/InkApprovalChannel.ts +0 -30
- package/src/tui/plugins/InkUIService.ts +0 -188
- package/src/tui/renderApp.tsx +0 -64
- package/src/tui/theme/index.ts +0 -1
- package/src/tui/theme/merge.test.ts +0 -49
- package/src/tui/theme/merge.ts +0 -43
- package/src/tui/theme/presets.ts +0 -90
- package/src/tui/theme/types.ts +0 -138
- package/src/tui/update/runUpdateInTui.ts +0 -127
- package/src/utils/clipboard.ts +0 -97
- package/src/utils/diff.test.ts +0 -56
- package/src/utils/diff.ts +0 -81
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import type { AgentSession, AgentSessionEvent, SubAgentRegistry, SubAgentResult, SubAgentRun } from 'mu-harness';
|
|
5
|
+
import type { Message } from 'mu-core';
|
|
6
|
+
import {
|
|
7
|
+
box,
|
|
8
|
+
column,
|
|
9
|
+
type Component,
|
|
10
|
+
flex,
|
|
11
|
+
type InputEvent,
|
|
12
|
+
ProcessTerminal,
|
|
13
|
+
type ScrollView,
|
|
14
|
+
scrollView,
|
|
15
|
+
truncateToWidth,
|
|
16
|
+
TUI,
|
|
17
|
+
visibleWidth,
|
|
18
|
+
} from 'mu-tui';
|
|
19
|
+
import { appendHistory, loadHistory } from '../config';
|
|
20
|
+
import { MultilineEditor } from './editor';
|
|
21
|
+
import { buildCommands, type ChatCommand, type CommandHost, filterCommands } from './commands';
|
|
22
|
+
import { activeMention, type Candidate, collectCandidates, rank } from './picker';
|
|
23
|
+
import { formatTokens, statusComponent, statusFromEvent, type StatusState } from './status';
|
|
24
|
+
import { styleToAnsi, type Theme, ThemeProvider, themesByName } from './theme';
|
|
25
|
+
import { formatToolArgs, Transcript, transcriptComponent } from './transcript';
|
|
26
|
+
|
|
27
|
+
const RESET = '\x1b[0m';
|
|
28
|
+
const PROMPT_WIDTH = 2;
|
|
29
|
+
const SPINNER_INTERVAL_MS = 100;
|
|
30
|
+
const MAX_LIST_ROWS = 8;
|
|
31
|
+
|
|
32
|
+
export interface ModelInfo {
|
|
33
|
+
id: string;
|
|
34
|
+
ownedBy?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ChatHost {
|
|
38
|
+
session: AgentSession;
|
|
39
|
+
cwd: string;
|
|
40
|
+
createSession(): AgentSession;
|
|
41
|
+
forkSession(id: string, upToIndex: number): Promise<AgentSession>;
|
|
42
|
+
selectModel(ref: string): void;
|
|
43
|
+
modelRef(): string;
|
|
44
|
+
listModels(): Promise<ModelInfo[]>;
|
|
45
|
+
agentNames(): string[];
|
|
46
|
+
subAgents: SubAgentRegistry;
|
|
47
|
+
dispatchSubAgent(agent: string, task: string, parentId: string): Promise<SubAgentResult>;
|
|
48
|
+
initialTheme: string;
|
|
49
|
+
saveTheme(name: string): void;
|
|
50
|
+
initialThinking: boolean;
|
|
51
|
+
saveThinking(visible: boolean): void;
|
|
52
|
+
onExit(code: number): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DIM = '\x1b[2m';
|
|
56
|
+
|
|
57
|
+
const lastAssistantText = (messages: readonly Message[]): string => {
|
|
58
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
59
|
+
const message = messages[i];
|
|
60
|
+
if (message.role === 'assistant') {
|
|
61
|
+
return message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return '';
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const padTo = (value: string, width: number): string => {
|
|
68
|
+
if (width <= 0) return '';
|
|
69
|
+
const fitted = visibleWidth(value) > width ? truncateToWidth(value, width) : value;
|
|
70
|
+
return fitted + ' '.repeat(Math.max(0, width - visibleWidth(fitted)));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
interface ListRow {
|
|
74
|
+
left: string;
|
|
75
|
+
right: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const listView = (rows: ListRow[], selected: number, theme: Theme): Component => ({
|
|
79
|
+
render: (s) => {
|
|
80
|
+
if (s.width <= 0) return;
|
|
81
|
+
const normal = styleToAnsi(theme.styles.commandPaletteItem);
|
|
82
|
+
const sel = styleToAnsi(theme.styles.commandPaletteSelected);
|
|
83
|
+
const maxLeft = rows.reduce((max, r) => Math.max(max, r.left.length), 0);
|
|
84
|
+
for (let i = 0; i < rows.length && i < s.height; i++) {
|
|
85
|
+
const isSel = i === selected;
|
|
86
|
+
const prefix = isSel ? '› ' : ' ';
|
|
87
|
+
const leftPart = `${prefix}${rows[i].left}${' '.repeat(Math.max(0, maxLeft - rows[i].left.length))}`;
|
|
88
|
+
if (visibleWidth(leftPart) >= s.width) {
|
|
89
|
+
s.text(0, i, `${isSel ? sel : normal}${padTo(leftPart, s.width)}${RESET}`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const rightAvail = s.width - visibleWidth(leftPart);
|
|
93
|
+
const rightText = rows[i].right ? ` ${rows[i].right}` : '';
|
|
94
|
+
const right = padTo(rightText, rightAvail);
|
|
95
|
+
if (isSel) {
|
|
96
|
+
s.text(0, i, `${sel}${leftPart}${right}${RESET}`);
|
|
97
|
+
} else {
|
|
98
|
+
s.text(0, i, `${normal}${leftPart}${DIM}${right}${RESET}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export class ChatApp {
|
|
105
|
+
private readonly tui: TUI;
|
|
106
|
+
private readonly terminal: ProcessTerminal;
|
|
107
|
+
private readonly editor: MultilineEditor;
|
|
108
|
+
private readonly scroll: ScrollView;
|
|
109
|
+
private readonly transcript = new Transcript();
|
|
110
|
+
private readonly themeProvider: ThemeProvider;
|
|
111
|
+
private readonly commands: ChatCommand[];
|
|
112
|
+
|
|
113
|
+
private session: AgentSession;
|
|
114
|
+
private unsubscribe: (() => void) | undefined;
|
|
115
|
+
private unsubscribeTheme: (() => void) | undefined;
|
|
116
|
+
private unsubscribeSubAgents: (() => void) | undefined;
|
|
117
|
+
private readonly runUnsubs = new Set<() => void>();
|
|
118
|
+
|
|
119
|
+
private readonly status: StatusState = { label: 'ready', busy: false, spinnerTick: 0, context: '' };
|
|
120
|
+
private running = false;
|
|
121
|
+
private readonly queue: string[] = [];
|
|
122
|
+
private readonly pendingShell: { cmd: string; output: string }[] = [];
|
|
123
|
+
private models: ModelInfo[] = [];
|
|
124
|
+
|
|
125
|
+
private paletteCursor = 0;
|
|
126
|
+
private paletteDismissedFor = '__none__';
|
|
127
|
+
private pickerMention: { start: number; query: string } | undefined;
|
|
128
|
+
private pickerRanked: Candidate[] = [];
|
|
129
|
+
private pickerCursor = 0;
|
|
130
|
+
|
|
131
|
+
private history: string[];
|
|
132
|
+
private historyIndex: number;
|
|
133
|
+
private historyDraft = '';
|
|
134
|
+
|
|
135
|
+
private spinnerTimer: ReturnType<typeof setInterval> | undefined;
|
|
136
|
+
private lastEsc = 0;
|
|
137
|
+
private modelPickerOpen = false;
|
|
138
|
+
private modelHandle: { close(): void } | undefined;
|
|
139
|
+
private errorText: string | undefined;
|
|
140
|
+
private errorTimer: ReturnType<typeof setTimeout> | undefined;
|
|
141
|
+
private stopped = false;
|
|
142
|
+
|
|
143
|
+
constructor(private readonly host: ChatHost) {
|
|
144
|
+
this.session = host.session;
|
|
145
|
+
this.transcript.thinkingVisible = host.initialThinking;
|
|
146
|
+
this.history = loadHistory();
|
|
147
|
+
this.historyIndex = this.history.length;
|
|
148
|
+
|
|
149
|
+
this.themeProvider = new ThemeProvider(themesByName[host.initialTheme] ?? themesByName.dark);
|
|
150
|
+
|
|
151
|
+
this.terminal = new ProcessTerminal({
|
|
152
|
+
alternateScreen: true,
|
|
153
|
+
bracketedPaste: true,
|
|
154
|
+
focusEvents: true,
|
|
155
|
+
keyboard: true,
|
|
156
|
+
mouse: { drag: true, motion: true },
|
|
157
|
+
});
|
|
158
|
+
this.tui = new TUI(this.terminal);
|
|
159
|
+
|
|
160
|
+
this.editor = new MultilineEditor({
|
|
161
|
+
placeholder: 'type a message…',
|
|
162
|
+
onSubmit: (value) => this.submit(value),
|
|
163
|
+
onChange: (value) => this.onInputChange(value),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
this.scroll = scrollView({ render: (s) => transcriptComponent(this.transcript, this.theme()).render(s) });
|
|
167
|
+
|
|
168
|
+
this.commands = buildCommands(this.commandHost());
|
|
169
|
+
|
|
170
|
+
this.tui.setRoot({ render: (s) => this.root().render(s) });
|
|
171
|
+
this.tui.setBackgroundColor(this.theme().colors.background);
|
|
172
|
+
this.tui.setFocus(this.editor);
|
|
173
|
+
this.tui.addInputInterceptor((event) => this.intercept(event));
|
|
174
|
+
this.tui.addGlobalKeybinding({ chord: { key: 'c', ctrl: true }, handler: () => this.onCtrlC() });
|
|
175
|
+
this.tui.addGlobalKeybinding({ chord: { key: 't', ctrl: true }, handler: () => this.toggleTheme() });
|
|
176
|
+
this.tui.addGlobalKeybinding({ chord: { key: 'o', ctrl: true }, handler: () => this.toggleExpand() });
|
|
177
|
+
|
|
178
|
+
this.unsubscribeTheme = this.themeProvider.subscribe(() => {
|
|
179
|
+
this.tui.setBackgroundColor(this.theme().colors.background);
|
|
180
|
+
this.tui.requestRender(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
this.bindSession();
|
|
184
|
+
this.unsubscribeSubAgents = this.host.subAgents.subscribe((run) => this.onSubAgentRun(run));
|
|
185
|
+
this.transcript.seed(this.session.messages);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async start(): Promise<void> {
|
|
189
|
+
this.tui.start();
|
|
190
|
+
await this.loadModels();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async stop(): Promise<void> {
|
|
194
|
+
if (this.stopped) return;
|
|
195
|
+
this.stopped = true;
|
|
196
|
+
this.unsubscribe?.();
|
|
197
|
+
this.unsubscribeTheme?.();
|
|
198
|
+
this.unsubscribeSubAgents?.();
|
|
199
|
+
this.clearRuns();
|
|
200
|
+
this.stopSpinner();
|
|
201
|
+
this.clearError();
|
|
202
|
+
this.session.abort();
|
|
203
|
+
this.tui.stop();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private theme(): Theme {
|
|
207
|
+
return this.themeProvider.current();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private commandHost(): CommandHost {
|
|
211
|
+
return {
|
|
212
|
+
newSession: () => this.newSession(),
|
|
213
|
+
openModelPicker: () => this.openModelPicker(),
|
|
214
|
+
toggleExpand: () => this.toggleExpand(),
|
|
215
|
+
toggleThinking: () => this.toggleThinking(),
|
|
216
|
+
exportContext: (args) => void this.exportContext(args),
|
|
217
|
+
quit: () => void this.stop().then(() => this.host.onExit(0)),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private bindSession(): void {
|
|
222
|
+
this.unsubscribe = this.session.subscribe((event) => this.handleEvent(event));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private clearRuns(): void {
|
|
226
|
+
for (const unsub of this.runUnsubs) unsub();
|
|
227
|
+
this.runUnsubs.clear();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private onSubAgentRun(run: SubAgentRun): void {
|
|
231
|
+
if (run.parentId !== this.session.id) return;
|
|
232
|
+
const handle = this.transcript.appendSubAgent(run.agent);
|
|
233
|
+
const toolNames = new Map<string, string>();
|
|
234
|
+
const unsub = run.session.subscribe((event) => {
|
|
235
|
+
switch (event.type) {
|
|
236
|
+
case 'tool_call': {
|
|
237
|
+
toolNames.set(event.id, event.name);
|
|
238
|
+
const args = formatToolArgs(event.name, event.input);
|
|
239
|
+
handle.addTool(args ? `${event.name} ${args}` : event.name);
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case 'turn_end':
|
|
243
|
+
handle.finish(lastAssistantText(run.session.messages));
|
|
244
|
+
unsub();
|
|
245
|
+
this.runUnsubs.delete(unsub);
|
|
246
|
+
break;
|
|
247
|
+
case 'error':
|
|
248
|
+
handle.fail(event.error instanceof Error ? event.error.message : String(event.error));
|
|
249
|
+
unsub();
|
|
250
|
+
this.runUnsubs.delete(unsub);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
this.tui.requestRender();
|
|
254
|
+
});
|
|
255
|
+
this.runUnsubs.add(unsub);
|
|
256
|
+
this.tui.requestRender();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private tryDispatch(text: string): boolean {
|
|
260
|
+
const match = /^@([^\s]+)\s+([\s\S]+)$/.exec(text);
|
|
261
|
+
if (!match) return false;
|
|
262
|
+
const [, agent, task] = match;
|
|
263
|
+
if (!this.host.agentNames().includes(agent)) return false;
|
|
264
|
+
this.transcript.appendUser(text);
|
|
265
|
+
this.tui.requestRender();
|
|
266
|
+
this.host.dispatchSubAgent(agent, task, this.session.id).catch((err) => {
|
|
267
|
+
this.showError(err instanceof Error ? err.message : String(err));
|
|
268
|
+
});
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private swapSession(next: AgentSession): void {
|
|
273
|
+
this.unsubscribe?.();
|
|
274
|
+
this.clearRuns();
|
|
275
|
+
this.session = next;
|
|
276
|
+
this.bindSession();
|
|
277
|
+
this.transcript.seed(next.messages);
|
|
278
|
+
this.queue.length = 0;
|
|
279
|
+
this.pendingShell.length = 0;
|
|
280
|
+
this.running = false;
|
|
281
|
+
this.status.busy = false;
|
|
282
|
+
this.status.context = '';
|
|
283
|
+
this.stopSpinner();
|
|
284
|
+
this.setStatus('ready');
|
|
285
|
+
this.tui.requestRender(true);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private handleEvent(event: AgentSessionEvent): void {
|
|
289
|
+
this.transcript.applyEvent(event);
|
|
290
|
+
const label = statusFromEvent(event);
|
|
291
|
+
if (label !== undefined) this.status.label = label;
|
|
292
|
+
|
|
293
|
+
switch (event.type) {
|
|
294
|
+
case 'turn_start':
|
|
295
|
+
this.running = true;
|
|
296
|
+
this.startSpinner();
|
|
297
|
+
break;
|
|
298
|
+
case 'turn_end':
|
|
299
|
+
this.onTurnComplete();
|
|
300
|
+
break;
|
|
301
|
+
case 'usage':
|
|
302
|
+
this.applyUsage(event.usage);
|
|
303
|
+
break;
|
|
304
|
+
case 'error': {
|
|
305
|
+
const message = event.error instanceof Error ? event.error.message : String(event.error);
|
|
306
|
+
this.showError(message);
|
|
307
|
+
this.onTurnComplete();
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
this.tui.requestRender();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private applyUsage(usage: { input?: number; output?: number; total?: number; contextWindow?: number }): void {
|
|
315
|
+
const used = usage.total ?? ((usage.input ?? 0) + (usage.output ?? 0) || undefined);
|
|
316
|
+
const total = usage.contextWindow;
|
|
317
|
+
if (used !== undefined && total) {
|
|
318
|
+
this.status.context = `${formatTokens(used)}/${formatTokens(total)} (${Math.round((used / total) * 100)}%)`;
|
|
319
|
+
} else if (used !== undefined) {
|
|
320
|
+
this.status.context = `${formatTokens(used)} tokens`;
|
|
321
|
+
} else if (total) {
|
|
322
|
+
this.status.context = `${formatTokens(total)} ctx`;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private onTurnComplete(): void {
|
|
327
|
+
this.running = false;
|
|
328
|
+
if (this.queue.length > 0) {
|
|
329
|
+
const next = this.queue.shift()!;
|
|
330
|
+
this.send(next);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
this.status.busy = false;
|
|
334
|
+
this.stopSpinner();
|
|
335
|
+
this.setStatus('ready');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private send(value: string): void {
|
|
339
|
+
this.transcript.appendUser(value);
|
|
340
|
+
const content = this.flushShellContext(value);
|
|
341
|
+
this.running = true;
|
|
342
|
+
this.status.busy = true;
|
|
343
|
+
this.setStatus('thinking…');
|
|
344
|
+
this.startSpinner();
|
|
345
|
+
this.tui.requestRender();
|
|
346
|
+
this.session.send(content).catch((err) => {
|
|
347
|
+
this.running = false;
|
|
348
|
+
this.status.busy = false;
|
|
349
|
+
this.stopSpinner();
|
|
350
|
+
this.showError(err instanceof Error ? err.message : String(err));
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private flushShellContext(userText: string): string {
|
|
355
|
+
if (this.pendingShell.length === 0) return userText;
|
|
356
|
+
const blocks = this.pendingShell
|
|
357
|
+
.map((entry) => `$ ${entry.cmd}\n${entry.output}`)
|
|
358
|
+
.join('\n\n');
|
|
359
|
+
this.pendingShell.length = 0;
|
|
360
|
+
return `<shell-output>\nShell commands the user ran locally since the last message:\n\n${blocks}\n</shell-output>\n\n${userText}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private submit(value: string): void {
|
|
364
|
+
const trimmed = value.trim();
|
|
365
|
+
if (!trimmed) return;
|
|
366
|
+
if (this.modelPickerOpen) return;
|
|
367
|
+
|
|
368
|
+
this.clearError();
|
|
369
|
+
this.pushHistory(trimmed);
|
|
370
|
+
this.editor.setValue('');
|
|
371
|
+
|
|
372
|
+
if (trimmed.startsWith('!') || trimmed.startsWith('$')) {
|
|
373
|
+
this.runShell(trimmed.slice(1).trim());
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (trimmed.startsWith('/')) {
|
|
377
|
+
this.runCommand(trimmed);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (this.tryDispatch(trimmed)) return;
|
|
381
|
+
|
|
382
|
+
if (this.running) {
|
|
383
|
+
this.queue.push(trimmed);
|
|
384
|
+
this.tui.requestRender();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
this.send(trimmed);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private enqueueFromInput(): void {
|
|
391
|
+
const value = this.editor.getValue().trim();
|
|
392
|
+
if (!value) return;
|
|
393
|
+
this.pushHistory(value);
|
|
394
|
+
this.editor.setValue('');
|
|
395
|
+
this.queue.push(value);
|
|
396
|
+
this.tui.requestRender();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private onInputChange(value: string): void {
|
|
400
|
+
this.editor.hiddenPrefix = value.startsWith('/') || value.startsWith('!') || value.startsWith('$') ? value[0] : '';
|
|
401
|
+
if (value !== this.paletteDismissedFor) this.paletteDismissedFor = '__none__';
|
|
402
|
+
const items = this.paletteItems();
|
|
403
|
+
this.paletteCursor = Math.max(0, Math.min(this.paletteCursor, Math.max(0, items.length - 1)));
|
|
404
|
+
|
|
405
|
+
if (items.length === 0) {
|
|
406
|
+
const mention = activeMention(value, this.editor.cursorPos);
|
|
407
|
+
if (mention) {
|
|
408
|
+
this.pickerMention = mention;
|
|
409
|
+
this.pickerRanked = rank(
|
|
410
|
+
mention.query,
|
|
411
|
+
collectCandidates(this.host.cwd, this.host.agentNames()),
|
|
412
|
+
MAX_LIST_ROWS,
|
|
413
|
+
);
|
|
414
|
+
this.pickerCursor = Math.max(0, Math.min(this.pickerCursor, Math.max(0, this.pickerRanked.length - 1)));
|
|
415
|
+
} else {
|
|
416
|
+
this.pickerMention = undefined;
|
|
417
|
+
this.pickerRanked = [];
|
|
418
|
+
}
|
|
419
|
+
} else {
|
|
420
|
+
this.pickerMention = undefined;
|
|
421
|
+
this.pickerRanked = [];
|
|
422
|
+
}
|
|
423
|
+
this.tui.requestRender();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private intercept(event: InputEvent): boolean {
|
|
427
|
+
if (this.modelPickerOpen) return false;
|
|
428
|
+
if (event.type !== 'key' || event.kind === 'release') return false;
|
|
429
|
+
const key = event.key;
|
|
430
|
+
|
|
431
|
+
if (key === 'escape' || key === 'esc') return this.onEscape();
|
|
432
|
+
|
|
433
|
+
if (key === 'backspace') {
|
|
434
|
+
if (this.deleteMention()) return true;
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (key === 'enter' && event.alt) {
|
|
439
|
+
if (this.running) {
|
|
440
|
+
this.enqueueFromInput();
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (this.paletteItems().length > 0) {
|
|
447
|
+
if (key === 'up') return this.paletteMove(-1);
|
|
448
|
+
if (key === 'down') return this.paletteMove(1);
|
|
449
|
+
if (key === 'tab') return this.paletteComplete();
|
|
450
|
+
if (key === 'enter') return this.paletteRun();
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (this.pickerVisible()) {
|
|
455
|
+
if (key === 'up') return this.pickerMove(-1);
|
|
456
|
+
if (key === 'down') return this.pickerMove(1);
|
|
457
|
+
if (key === 'tab' || key === 'enter') return this.pickerAccept();
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (key === 'up') return this.navigateHistory('up');
|
|
462
|
+
if (key === 'down') return this.navigateHistory('down');
|
|
463
|
+
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private onEscape(): boolean {
|
|
468
|
+
if (this.paletteItems().length > 0) {
|
|
469
|
+
this.paletteDismissedFor = this.editor.getValue();
|
|
470
|
+
this.tui.requestRender();
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
if (this.pickerVisible()) {
|
|
474
|
+
this.pickerMention = undefined;
|
|
475
|
+
this.pickerRanked = [];
|
|
476
|
+
this.tui.requestRender();
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
const value = this.editor.getValue();
|
|
480
|
+
if (value.startsWith('!') || value.startsWith('$') || value.startsWith('/')) {
|
|
481
|
+
this.editor.setValue('');
|
|
482
|
+
this.tui.requestRender();
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
if (this.running) {
|
|
486
|
+
const now = Date.now();
|
|
487
|
+
if (this.lastEsc > 0 && now - this.lastEsc < 1500) {
|
|
488
|
+
this.lastEsc = 0;
|
|
489
|
+
this.cancelGeneration();
|
|
490
|
+
} else {
|
|
491
|
+
this.lastEsc = now;
|
|
492
|
+
this.setStatus('press Esc again to cancel');
|
|
493
|
+
this.tui.requestRender();
|
|
494
|
+
}
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private cancelGeneration(): void {
|
|
501
|
+
this.queue.length = 0;
|
|
502
|
+
this.session.abort();
|
|
503
|
+
this.setStatus('cancelling…');
|
|
504
|
+
this.tui.requestRender();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private paletteItems(): ChatCommand[] {
|
|
508
|
+
return filterCommands(this.commands, this.editor.getValue(), this.paletteDismissedFor).slice(0, MAX_LIST_ROWS);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private paletteMove(delta: number): boolean {
|
|
512
|
+
const items = this.paletteItems();
|
|
513
|
+
if (items.length === 0) return true;
|
|
514
|
+
this.paletteCursor = (this.paletteCursor + delta + items.length) % items.length;
|
|
515
|
+
this.tui.requestRender();
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private paletteComplete(): boolean {
|
|
520
|
+
const items = this.paletteItems();
|
|
521
|
+
const command = items[this.paletteCursor];
|
|
522
|
+
if (!command) return true;
|
|
523
|
+
const next = `/${command.name} `;
|
|
524
|
+
this.editor.setValue(next);
|
|
525
|
+
this.editor.setCursor(next.length);
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private paletteRun(): boolean {
|
|
530
|
+
const items = this.paletteItems();
|
|
531
|
+
const command = items[this.paletteCursor];
|
|
532
|
+
if (!command) return true;
|
|
533
|
+
this.editor.setValue('');
|
|
534
|
+
void command.run('');
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private runCommand(value: string): void {
|
|
539
|
+
const body = value.trim().slice(1);
|
|
540
|
+
const space = body.indexOf(' ');
|
|
541
|
+
const name = (space === -1 ? body : body.slice(0, space)).toLowerCase();
|
|
542
|
+
const args = space === -1 ? '' : body.slice(space + 1).trim();
|
|
543
|
+
const command = this.commands.find((c) => c.name === name);
|
|
544
|
+
if (!command) {
|
|
545
|
+
this.transcript.note(`Unknown command: /${name}`, true);
|
|
546
|
+
this.tui.requestRender();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
void command.run(args);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private pickerVisible(): boolean {
|
|
553
|
+
return this.pickerMention !== undefined && this.pickerRanked.length > 0 && this.paletteItems().length === 0;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private pickerMove(delta: number): boolean {
|
|
557
|
+
if (this.pickerRanked.length === 0) return true;
|
|
558
|
+
this.pickerCursor = (this.pickerCursor + delta + this.pickerRanked.length) % this.pickerRanked.length;
|
|
559
|
+
this.tui.requestRender();
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private pickerAccept(): boolean {
|
|
564
|
+
const mention = this.pickerMention;
|
|
565
|
+
const candidate = this.pickerRanked[this.pickerCursor];
|
|
566
|
+
if (!mention || !candidate) return true;
|
|
567
|
+
const value = this.editor.getValue();
|
|
568
|
+
const cursor = this.editor.cursorPos;
|
|
569
|
+
const insertion = `@${candidate.insert} `;
|
|
570
|
+
const next = value.slice(0, mention.start) + insertion + value.slice(cursor);
|
|
571
|
+
this.editor.setValue(next);
|
|
572
|
+
this.editor.setCursor(mention.start + insertion.length);
|
|
573
|
+
this.pickerMention = undefined;
|
|
574
|
+
this.pickerRanked = [];
|
|
575
|
+
this.tui.requestRender();
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private deleteMention(): boolean {
|
|
580
|
+
const value = this.editor.getValue();
|
|
581
|
+
const cursor = this.editor.cursorPos;
|
|
582
|
+
const re = /@[^\s]+/g;
|
|
583
|
+
let match: RegExpExecArray | null;
|
|
584
|
+
while ((match = re.exec(value)) !== null) {
|
|
585
|
+
const start = match.index;
|
|
586
|
+
const end = start + match[0].length;
|
|
587
|
+
if (cursor > start && cursor <= end) {
|
|
588
|
+
this.editor.setValue(value.slice(0, start) + value.slice(end));
|
|
589
|
+
this.editor.setCursor(start);
|
|
590
|
+
this.tui.requestRender();
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private pushHistory(text: string): void {
|
|
598
|
+
appendHistory(text);
|
|
599
|
+
if (this.history[this.history.length - 1] !== text) this.history.push(text);
|
|
600
|
+
this.historyIndex = this.history.length;
|
|
601
|
+
this.historyDraft = '';
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private navigateHistory(direction: 'up' | 'down'): boolean {
|
|
605
|
+
if (this.history.length === 0) return false;
|
|
606
|
+
if (direction === 'up') {
|
|
607
|
+
if (this.historyIndex === 0) return true;
|
|
608
|
+
if (this.historyIndex === this.history.length) this.historyDraft = this.editor.getValue();
|
|
609
|
+
this.historyIndex -= 1;
|
|
610
|
+
} else {
|
|
611
|
+
if (this.historyIndex >= this.history.length) return true;
|
|
612
|
+
this.historyIndex += 1;
|
|
613
|
+
}
|
|
614
|
+
const value = this.historyIndex === this.history.length ? this.historyDraft : this.history[this.historyIndex];
|
|
615
|
+
this.editor.setValue(value);
|
|
616
|
+
this.editor.setCursor(value.length);
|
|
617
|
+
this.tui.requestRender();
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private newSession(): void {
|
|
622
|
+
if (this.running) {
|
|
623
|
+
this.showError('Cannot start a new session while a response is running.');
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
this.swapSession(this.host.createSession());
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private async openModelPicker(): Promise<void> {
|
|
630
|
+
if (this.running) {
|
|
631
|
+
this.showError('Cannot switch models while a response is running.');
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (this.models.length === 0) await this.loadModels();
|
|
635
|
+
if (this.models.length === 0) {
|
|
636
|
+
this.showError('No models available from the backend.');
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const ref = this.host.modelRef();
|
|
640
|
+
const currentId = ref.includes('/') ? ref.slice(ref.indexOf('/') + 1) : ref;
|
|
641
|
+
const content = this.buildModelPicker(currentId, (id) => {
|
|
642
|
+
this.modelHandle?.close();
|
|
643
|
+
void this.switchModel(`local/${id}`);
|
|
644
|
+
});
|
|
645
|
+
this.modelPickerOpen = true;
|
|
646
|
+
this.modelHandle = this.tui.showModal(content, {
|
|
647
|
+
width: 60,
|
|
648
|
+
border: false,
|
|
649
|
+
background: this.theme().colors.surface,
|
|
650
|
+
onClose: () => {
|
|
651
|
+
this.modelPickerOpen = false;
|
|
652
|
+
this.tui.setFocus(this.editor);
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private buildModelPicker(currentId: string, onPick: (id: string) => void): Component {
|
|
658
|
+
const models = this.models;
|
|
659
|
+
const maxId = models.reduce((max, m) => Math.max(max, m.id.length), 0);
|
|
660
|
+
const PADX = 0;
|
|
661
|
+
let cursor = Math.max(0, models.findIndex((m) => m.id === currentId));
|
|
662
|
+
return {
|
|
663
|
+
handleInput: (event) => {
|
|
664
|
+
if (event.type !== 'key' || event.kind === 'release' || models.length === 0) return;
|
|
665
|
+
if (event.key === 'up') cursor = (cursor - 1 + models.length) % models.length;
|
|
666
|
+
else if (event.key === 'down') cursor = (cursor + 1) % models.length;
|
|
667
|
+
else if (event.key === 'enter') onPick(models[cursor].id);
|
|
668
|
+
},
|
|
669
|
+
render: (s) => {
|
|
670
|
+
if (s.width <= 0) return;
|
|
671
|
+
const theme = this.theme();
|
|
672
|
+
const itemSgr = styleToAnsi(theme.styles.commandPaletteItem);
|
|
673
|
+
const selSgr = styleToAnsi(theme.styles.commandPaletteSelected);
|
|
674
|
+
const muted = styleToAnsi(theme.styles.muted);
|
|
675
|
+
const ITEM_INDENT = 2;
|
|
676
|
+
const textX = PADX + ITEM_INDENT;
|
|
677
|
+
const innerW = Math.max(1, s.width - PADX * 2);
|
|
678
|
+
|
|
679
|
+
s.text(textX, 1, `${styleToAnsi(theme.styles.title)}Model Picker${RESET}`);
|
|
680
|
+
|
|
681
|
+
const maxRows = Math.min(models.length, 10, Math.max(1, s.height - 6));
|
|
682
|
+
const top = cursor >= maxRows ? cursor - maxRows + 1 : 0;
|
|
683
|
+
for (let r = 0; r < maxRows; r++) {
|
|
684
|
+
const m = models[top + r];
|
|
685
|
+
if (!m) break;
|
|
686
|
+
const isSel = top + r === cursor;
|
|
687
|
+
const pad = ' '.repeat(Math.max(0, maxId - m.id.length));
|
|
688
|
+
const provider = m.ownedBy ? ` ${m.ownedBy}` : '';
|
|
689
|
+
const indent = ' '.repeat(ITEM_INDENT);
|
|
690
|
+
const body = isSel ? `${indent}${m.id}${pad}${provider}` : `${indent}${m.id}${pad}${DIM}${provider}`;
|
|
691
|
+
s.text(PADX, 3 + r, `${isSel ? selSgr : itemSgr}${padTo(body, innerW)}${RESET}`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const footerRow = 3 + maxRows + 1;
|
|
695
|
+
s.text(textX, footerRow, `${muted}↑/↓ move · Enter select · Esc close${RESET}`);
|
|
696
|
+
s.text(0, footerRow + 1, '');
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private async switchModel(ref: string): Promise<void> {
|
|
702
|
+
this.host.selectModel(ref);
|
|
703
|
+
const carry = this.session.messages.some((m) => m.role !== 'system');
|
|
704
|
+
let next: AgentSession;
|
|
705
|
+
try {
|
|
706
|
+
next = carry
|
|
707
|
+
? await this.host.forkSession(this.session.id, this.session.messages.length - 1)
|
|
708
|
+
: this.host.createSession();
|
|
709
|
+
} catch {
|
|
710
|
+
next = this.host.createSession();
|
|
711
|
+
}
|
|
712
|
+
this.swapSession(next);
|
|
713
|
+
this.transcript.note(`switched model to ${ref}`);
|
|
714
|
+
this.tui.requestRender();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private async exportContext(args: string): Promise<void> {
|
|
718
|
+
const messages = this.session.messages.filter((m) => m.role !== 'system');
|
|
719
|
+
if (messages.length === 0) {
|
|
720
|
+
this.showError('Nothing to export yet.');
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const outputPath = args.trim() || '.mu/context.json';
|
|
724
|
+
const resolved = resolve(this.host.cwd, outputPath);
|
|
725
|
+
const payload = { exportedAt: new Date().toISOString(), model: this.host.modelRef(), messages };
|
|
726
|
+
try {
|
|
727
|
+
await mkdir(dirname(resolved), { recursive: true });
|
|
728
|
+
await writeFile(resolved, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
|
|
729
|
+
this.transcript.note(`saved conversation to ${outputPath}`);
|
|
730
|
+
} catch (error) {
|
|
731
|
+
this.showError(`Failed to export: ${error instanceof Error ? error.message : String(error)}`);
|
|
732
|
+
}
|
|
733
|
+
this.tui.requestRender();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private toggleExpand(): void {
|
|
737
|
+
if (this.transcript.toggleExpanded()) this.tui.requestRender();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private toggleThinking(): void {
|
|
741
|
+
this.host.saveThinking(this.transcript.toggleReasoning());
|
|
742
|
+
this.tui.requestRender();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private toggleTheme(): void {
|
|
746
|
+
const next = this.theme().name === 'dark' ? themesByName.light : themesByName.dark;
|
|
747
|
+
this.themeProvider.setTheme(next);
|
|
748
|
+
this.host.saveTheme(next.name);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private onCtrlC(): void {
|
|
752
|
+
if (this.editor.getValue().length > 0) {
|
|
753
|
+
this.editor.setValue('');
|
|
754
|
+
this.tui.requestRender();
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
void this.stop().then(() => this.host.onExit(130));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private recordShell(cmd: string, output: string): void {
|
|
761
|
+
const MAX = 10_000;
|
|
762
|
+
const capped = output.length > MAX ? `${output.slice(0, MAX)}\n…[truncated]` : output;
|
|
763
|
+
this.pendingShell.push({ cmd, output: capped });
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private runShell(cmd: string): void {
|
|
767
|
+
if (!cmd) return;
|
|
768
|
+
const handle = this.transcript.appendShell(cmd);
|
|
769
|
+
this.tui.requestRender();
|
|
770
|
+
let stdout = '';
|
|
771
|
+
let stderr = '';
|
|
772
|
+
let proc: ReturnType<typeof spawn>;
|
|
773
|
+
try {
|
|
774
|
+
proc = spawn('bash', ['-c', cmd], { cwd: this.host.cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
775
|
+
} catch (err) {
|
|
776
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
777
|
+
handle.setOutput(message, true);
|
|
778
|
+
this.recordShell(cmd, message);
|
|
779
|
+
this.tui.requestRender();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
proc.stdout?.on('data', (data: Buffer) => {
|
|
783
|
+
stdout += data.toString('utf-8');
|
|
784
|
+
});
|
|
785
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
786
|
+
stderr += data.toString('utf-8');
|
|
787
|
+
});
|
|
788
|
+
proc.on('close', (code) => {
|
|
789
|
+
const output = code !== 0 || stderr ? [stdout, stderr].filter(Boolean).join('\n') : stdout;
|
|
790
|
+
const trimmed = output.trim() || '(no output)';
|
|
791
|
+
handle.setOutput(trimmed, code !== 0);
|
|
792
|
+
this.recordShell(cmd, code !== 0 ? `(exit ${code})\n${trimmed}` : trimmed);
|
|
793
|
+
this.tui.requestRender();
|
|
794
|
+
});
|
|
795
|
+
proc.on('error', (err) => {
|
|
796
|
+
handle.setOutput(err.message, true);
|
|
797
|
+
this.recordShell(cmd, err.message);
|
|
798
|
+
this.tui.requestRender();
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
private setStatus(label: string): void {
|
|
803
|
+
this.status.label = label;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
private showError(message: string): void {
|
|
807
|
+
this.errorText = message;
|
|
808
|
+
if (this.errorTimer) clearTimeout(this.errorTimer);
|
|
809
|
+
this.errorTimer = setTimeout(() => {
|
|
810
|
+
this.errorText = undefined;
|
|
811
|
+
this.errorTimer = undefined;
|
|
812
|
+
this.tui.requestRender();
|
|
813
|
+
}, 6000);
|
|
814
|
+
this.tui.requestRender();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private clearError(): void {
|
|
818
|
+
if (this.errorTimer) clearTimeout(this.errorTimer);
|
|
819
|
+
this.errorTimer = undefined;
|
|
820
|
+
this.errorText = undefined;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
private startSpinner(): void {
|
|
824
|
+
this.status.busy = true;
|
|
825
|
+
if (this.spinnerTimer) return;
|
|
826
|
+
this.spinnerTimer = setInterval(() => {
|
|
827
|
+
this.status.spinnerTick += 1;
|
|
828
|
+
this.tui.requestRender();
|
|
829
|
+
}, SPINNER_INTERVAL_MS);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private stopSpinner(): void {
|
|
833
|
+
if (this.spinnerTimer) {
|
|
834
|
+
clearInterval(this.spinnerTimer);
|
|
835
|
+
this.spinnerTimer = undefined;
|
|
836
|
+
}
|
|
837
|
+
this.status.busy = false;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
private async loadModels(): Promise<void> {
|
|
841
|
+
try {
|
|
842
|
+
this.models = await this.host.listModels();
|
|
843
|
+
this.tui.requestRender();
|
|
844
|
+
} catch {
|
|
845
|
+
// backend may be unreachable; surfaced on first send
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
private modelLabel(): string {
|
|
850
|
+
const ref = this.host.modelRef();
|
|
851
|
+
const slash = ref.indexOf('/');
|
|
852
|
+
const id = slash >= 0 ? ref.slice(slash + 1) : ref;
|
|
853
|
+
const providerName = slash >= 0 ? ref.slice(0, slash) : '';
|
|
854
|
+
const model = this.models.find((m) => m.id === id);
|
|
855
|
+
const provider = model?.ownedBy ?? providerName;
|
|
856
|
+
const theme = this.theme();
|
|
857
|
+
const bold = styleToAnsi({ fg: theme.colors.text, bold: true });
|
|
858
|
+
const dim = styleToAnsi({ fg: theme.colors.textMuted });
|
|
859
|
+
return provider ? `${bold}${id}${RESET} ${dim}${provider}${RESET}` : `${bold}${id}${RESET}`;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private promptGlyph(): string {
|
|
863
|
+
const theme = this.theme();
|
|
864
|
+
const muted = styleToAnsi(theme.styles.muted);
|
|
865
|
+
const value = this.editor.getValue();
|
|
866
|
+
if (value.startsWith('!') || value.startsWith('$')) return `${styleToAnsi(theme.styles.bashPrompt)}$ ${RESET}`;
|
|
867
|
+
if (value.startsWith('/')) return `${muted}/ ${RESET}`;
|
|
868
|
+
if (this.pickerVisible()) return `${muted}@ ${RESET}`;
|
|
869
|
+
return `${muted}❯ ${RESET}`;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private inputPanel(): Component {
|
|
873
|
+
const prompt = this.promptGlyph();
|
|
874
|
+
const editor = this.editor;
|
|
875
|
+
const label = this.modelLabel();
|
|
876
|
+
const editorRows = editor.rows();
|
|
877
|
+
const inner: Component = {
|
|
878
|
+
render: (s) => {
|
|
879
|
+
if (s.width <= 0 || s.height <= 0) return;
|
|
880
|
+
s.text(0, 0, prompt);
|
|
881
|
+
const rows = Math.min(editorRows, Math.max(1, s.height - 2));
|
|
882
|
+
s.child(editor, { x: PROMPT_WIDTH, y: 0, width: Math.max(1, s.width - PROMPT_WIDTH), height: rows });
|
|
883
|
+
const labelRow = rows + 1;
|
|
884
|
+
s.text(0, labelRow, visibleWidth(label) > s.width ? truncateToWidth(label, s.width) : label);
|
|
885
|
+
},
|
|
886
|
+
};
|
|
887
|
+
return box(inner, { background: this.theme().colors.surface, padding: 1 });
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private errorView(): Component | undefined {
|
|
891
|
+
const message = this.errorText;
|
|
892
|
+
if (!message) return undefined;
|
|
893
|
+
const theme = this.theme();
|
|
894
|
+
const head = styleToAnsi(theme.styles.errorPrefix);
|
|
895
|
+
const body = styleToAnsi(theme.styles.errorLine);
|
|
896
|
+
return {
|
|
897
|
+
render: (s) => {
|
|
898
|
+
if (s.width <= 0) return;
|
|
899
|
+
const text = visibleWidth(message) > s.width - 2 ? truncateToWidth(message, Math.max(1, s.width - 2)) : message;
|
|
900
|
+
s.text(0, 0, `${head}!${RESET} ${body}${text}${RESET}`);
|
|
901
|
+
s.text(0, 1, '');
|
|
902
|
+
},
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
private waitingView(): Component | undefined {
|
|
907
|
+
if (this.queue.length === 0) return undefined;
|
|
908
|
+
const theme = this.theme();
|
|
909
|
+
const muted = styleToAnsi(theme.styles.muted);
|
|
910
|
+
const body = styleToAnsi(theme.styles.body);
|
|
911
|
+
const rows = this.queue.slice(0, 6).map((entry) => entry.replace(/\s+/g, ' '));
|
|
912
|
+
return {
|
|
913
|
+
render: (s) => {
|
|
914
|
+
for (let i = 0; i < rows.length; i++) {
|
|
915
|
+
const tag = '[follow-up] ';
|
|
916
|
+
const text = padTo(rows[i], Math.max(0, s.width - tag.length));
|
|
917
|
+
s.text(0, i, `${muted}${tag}${RESET}${body}${text}${RESET}`);
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
private dock(): Component {
|
|
924
|
+
const children: Component[] = [];
|
|
925
|
+
|
|
926
|
+
const error = this.errorView();
|
|
927
|
+
if (error) children.push(error);
|
|
928
|
+
|
|
929
|
+
const palette = this.paletteItems();
|
|
930
|
+
if (palette.length > 0) {
|
|
931
|
+
const rows = palette.map((c) => ({ left: `/${c.name}`, right: c.description }));
|
|
932
|
+
children.push(listView(rows, this.paletteCursor, this.theme()));
|
|
933
|
+
} else if (this.pickerVisible()) {
|
|
934
|
+
const rows = this.pickerRanked.map((c) => ({ left: c.label, right: c.kind === 'agent' ? 'agent' : '' }));
|
|
935
|
+
children.push(listView(rows, this.pickerCursor, this.theme()));
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
children.push(this.inputPanel());
|
|
939
|
+
|
|
940
|
+
const waiting = this.waitingView();
|
|
941
|
+
if (waiting) children.push(waiting);
|
|
942
|
+
|
|
943
|
+
children.push(statusComponent(this.status, this.theme()));
|
|
944
|
+
return column(children);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
private root(): Component {
|
|
948
|
+
const inner = column([flex(this.scroll), this.dock()]);
|
|
949
|
+
return {
|
|
950
|
+
render: (s) => {
|
|
951
|
+
if (s.width <= 2) {
|
|
952
|
+
inner.render(s);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
s.child(inner, { x: 1, y: 0, width: s.width - 2, height: s.height });
|
|
956
|
+
},
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
}
|