@almyty/chat 0.2.0 → 1.3.0
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 +168 -25
- package/dist/app.d.ts +12 -2
- package/dist/app.js +448 -159
- package/dist/args.d.ts +64 -0
- package/dist/args.js +207 -0
- package/dist/commands.d.ts +41 -1
- package/dist/commands.js +91 -2
- package/dist/components.d.ts +33 -9
- package/dist/components.js +42 -26
- package/dist/errors.d.ts +49 -0
- package/dist/errors.js +144 -0
- package/dist/exit-codes.d.ts +32 -0
- package/dist/exit-codes.js +65 -0
- package/dist/headless.d.ts +44 -0
- package/dist/headless.js +106 -0
- package/dist/history.d.ts +51 -0
- package/dist/history.js +146 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +112 -40
- package/dist/stream.d.ts +95 -0
- package/dist/stream.js +276 -0
- package/dist/turn.d.ts +53 -0
- package/dist/turn.js +147 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +27 -0
- package/dist/viewport.d.ts +61 -0
- package/dist/viewport.js +118 -0
- package/package.json +23 -7
package/dist/app.js
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useCallback, useEffect } from 'react';
|
|
2
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
3
3
|
import { Box, Text, useApp, useInput } from 'ink';
|
|
4
4
|
import TextInput from 'ink-text-input';
|
|
5
|
-
import { Header,
|
|
6
|
-
import { SLASH_COMMANDS, COMMAND_DESCS, resolveSlash,
|
|
5
|
+
import { Header, MessageWindow, AgentSelector, CodingModeIndicator, ChoiceSelector, } from './components.js';
|
|
6
|
+
import { SLASH_COMMANDS, COMMAND_DESCS, resolveSlash, ALIASES, classifyInput, continuationOf, buildCodeChoices, } from './commands.js';
|
|
7
|
+
import { explainError } from './errors.js';
|
|
8
|
+
import { addUsage, formatUsage } from './stream.js';
|
|
9
|
+
import { runTurn } from './turn.js';
|
|
10
|
+
import { appendHistory, loadHistory, walkHistory } from './history.js';
|
|
11
|
+
import { usableRows } from './viewport.js';
|
|
7
12
|
// Mutable module-level variable; written by ChatApp, read by main() after exit
|
|
8
13
|
export let exitMessage = '';
|
|
14
|
+
/** Rows the header, separator, prompt and status bar take off the top. */
|
|
15
|
+
const CHROME_ROWS = 10;
|
|
9
16
|
// ── Chat app ────────────────────────────────────────────────────
|
|
10
|
-
export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
|
|
17
|
+
export function ChatApp({ client, initialAgent, gw, resumeConversationId, errorContext }) {
|
|
11
18
|
const { exit } = useApp();
|
|
19
|
+
const agentRef = `${gw.orgSlug}/${gw.agentSlug}`;
|
|
20
|
+
const errCtx = { agentRef, ...errorContext };
|
|
12
21
|
const [state, setState] = useState({
|
|
13
22
|
agent: initialAgent,
|
|
14
23
|
messages: [],
|
|
@@ -17,6 +26,15 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
|
|
|
17
26
|
conversationId: resumeConversationId ?? null,
|
|
18
27
|
pendingRunId: null,
|
|
19
28
|
});
|
|
29
|
+
// Assistant text arriving right now, drawn below the transcript and
|
|
30
|
+
// committed as a message when the turn ends.
|
|
31
|
+
const [streaming, setStreaming] = useState('');
|
|
32
|
+
const [sessionUsage, setSessionUsage] = useState({ cost: 0, tokens: 0, steps: 0 });
|
|
33
|
+
const [lastRunId, setLastRunId] = useState(null);
|
|
34
|
+
// Lines of a message being typed across several Enter presses.
|
|
35
|
+
const [draft, setDraft] = useState([]);
|
|
36
|
+
// Redrawn on resize so the visible window matches the new size.
|
|
37
|
+
const [size, setSize] = useState({ rows: process.stdout.rows, columns: process.stdout.columns });
|
|
20
38
|
// Load conversation history on resume
|
|
21
39
|
useEffect(() => {
|
|
22
40
|
if (!resumeConversationId)
|
|
@@ -33,24 +51,83 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
|
|
|
33
51
|
}));
|
|
34
52
|
setState(s => ({ ...s, messages: msgs, loading: false }));
|
|
35
53
|
}
|
|
36
|
-
catch {
|
|
37
|
-
setState(s => ({
|
|
54
|
+
catch (err) {
|
|
55
|
+
setState(s => ({
|
|
56
|
+
...s,
|
|
57
|
+
loading: false,
|
|
58
|
+
messages: [...s.messages, { role: 'error', text: explainError(err, { ...errCtx, what: 'history' }) }],
|
|
59
|
+
}));
|
|
38
60
|
}
|
|
39
61
|
})();
|
|
40
62
|
}, [resumeConversationId]);
|
|
63
|
+
// ink redraws on resize, but the window arithmetic needs the new
|
|
64
|
+
// numbers in state to recompute with them.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const onResize = () => setSize({ rows: process.stdout.rows, columns: process.stdout.columns });
|
|
67
|
+
process.stdout.on('resize', onResize);
|
|
68
|
+
return () => { process.stdout.off('resize', onResize); };
|
|
69
|
+
}, []);
|
|
41
70
|
const [input, setInput] = useState('');
|
|
42
71
|
const [paletteCursor, setPaletteCursor] = useState(0);
|
|
43
|
-
// Input history
|
|
44
|
-
|
|
72
|
+
// Input history persisted across sessions, so up-arrow works in a
|
|
73
|
+
// fresh session and survives /clear.
|
|
74
|
+
const [inputHistory, setInputHistory] = useState(() => loadHistory());
|
|
45
75
|
const [historyIdx, setHistoryIdx] = useState(-1);
|
|
46
76
|
const [showPicker, setShowPicker] = useState(false);
|
|
47
77
|
const [pickerAgents, setPickerAgents] = useState([]);
|
|
78
|
+
// Active coding session (input routes to the runner while set).
|
|
79
|
+
const [coding, setCoding] = useState(null);
|
|
80
|
+
// Pending runner x CLI pick for /code when multiple targets exist.
|
|
81
|
+
const [codeChoices, setCodeChoices] = useState(null);
|
|
82
|
+
const codingAbortRef = useRef(null);
|
|
83
|
+
// The in-flight turn, so Ctrl-C can stop it server-side.
|
|
84
|
+
const runAbortRef = useRef(null);
|
|
48
85
|
// Command palette matches
|
|
49
86
|
const slashMatches = input.startsWith('/') && !input.includes(' ')
|
|
50
87
|
? SLASH_COMMANDS.filter(c => c.startsWith(input.slice(1).toLowerCase()))
|
|
51
88
|
: [];
|
|
52
89
|
const paletteOpen = input.startsWith('/') && slashMatches.length > 0;
|
|
90
|
+
const addMessage = useCallback((msg) => {
|
|
91
|
+
setState(s => ({ ...s, messages: [...s.messages, msg] }));
|
|
92
|
+
}, []);
|
|
93
|
+
const quit = useCallback(() => {
|
|
94
|
+
setState(s => {
|
|
95
|
+
exitMessage = s.conversationId
|
|
96
|
+
? `\nTo resume: almyty chat ${agentRef} --resume ${s.conversationId}\n`
|
|
97
|
+
: '';
|
|
98
|
+
return s;
|
|
99
|
+
});
|
|
100
|
+
exit();
|
|
101
|
+
}, [exit, agentRef]);
|
|
53
102
|
useInput((ch, key) => {
|
|
103
|
+
// Ctrl-C and Ctrl-D come first: they have to work while a run is in
|
|
104
|
+
// flight, which is the only moment cancelling means anything.
|
|
105
|
+
if (key.ctrl && ch === 'c') {
|
|
106
|
+
if (runAbortRef.current) {
|
|
107
|
+
// Cancels the run where it runs, not just where it is watched.
|
|
108
|
+
runAbortRef.current.abort();
|
|
109
|
+
runAbortRef.current = null;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (codingAbortRef.current) {
|
|
113
|
+
codingAbortRef.current.abort();
|
|
114
|
+
codingAbortRef.current = null;
|
|
115
|
+
setCoding(null);
|
|
116
|
+
addMessage({ role: 'info', text: 'left coding mode (the session keeps running)' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (input) {
|
|
120
|
+
setInput('');
|
|
121
|
+
setDraft([]);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
quit();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (key.ctrl && ch === 'd') {
|
|
128
|
+
quit();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
54
131
|
if (state.loading)
|
|
55
132
|
return;
|
|
56
133
|
// Command palette navigation
|
|
@@ -71,22 +148,17 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
|
|
|
71
148
|
return;
|
|
72
149
|
}
|
|
73
150
|
// Input history — up/down arrows always, like Claude Code
|
|
74
|
-
if (key.upArrow
|
|
75
|
-
const
|
|
76
|
-
setHistoryIdx(
|
|
77
|
-
|
|
151
|
+
if (key.upArrow) {
|
|
152
|
+
const walked = walkHistory(inputHistory, historyIdx, 'up');
|
|
153
|
+
setHistoryIdx(walked.idx);
|
|
154
|
+
if (walked.value)
|
|
155
|
+
setInput(walked.value);
|
|
78
156
|
return;
|
|
79
157
|
}
|
|
80
158
|
if (key.downArrow) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
setInput(inputHistory[inputHistory.length - 1 - newIdx]);
|
|
85
|
-
}
|
|
86
|
-
else if (historyIdx === 0) {
|
|
87
|
-
setHistoryIdx(-1);
|
|
88
|
-
setInput('');
|
|
89
|
-
}
|
|
159
|
+
const walked = walkHistory(inputHistory, historyIdx, 'down');
|
|
160
|
+
setHistoryIdx(walked.idx);
|
|
161
|
+
setInput(walked.value);
|
|
90
162
|
return;
|
|
91
163
|
}
|
|
92
164
|
});
|
|
@@ -95,22 +167,164 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
|
|
|
95
167
|
setInput(val);
|
|
96
168
|
setPaletteCursor(0);
|
|
97
169
|
}, []);
|
|
98
|
-
|
|
99
|
-
|
|
170
|
+
// ── Coding session helpers (chat-to-runner bridge) ──────────────
|
|
171
|
+
/**
|
|
172
|
+
* Append streamed CLI output to the transcript, merging into the last
|
|
173
|
+
* coding message so a burst of chunks doesn't explode into hundreds of
|
|
174
|
+
* transcript entries. Caps a single coding message before rolling over.
|
|
175
|
+
*/
|
|
176
|
+
const appendCodingOutput = useCallback((text) => {
|
|
177
|
+
if (!text)
|
|
178
|
+
return;
|
|
179
|
+
setState(s => {
|
|
180
|
+
const msgs = [...s.messages];
|
|
181
|
+
const last = msgs[msgs.length - 1];
|
|
182
|
+
if (last && last.role === 'coding' && last.text.length < 4000) {
|
|
183
|
+
msgs[msgs.length - 1] = { ...last, text: last.text + text };
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
msgs.push({ role: 'coding', text });
|
|
187
|
+
}
|
|
188
|
+
return { ...s, messages: msgs };
|
|
189
|
+
});
|
|
100
190
|
}, []);
|
|
191
|
+
const startCoding = useCallback(async (choice, task) => {
|
|
192
|
+
setState(s => ({ ...s, loading: true, loadingLabel: `Starting ${choice.agentName} on ${choice.runnerName}` }));
|
|
193
|
+
try {
|
|
194
|
+
const session = await client.startCodingSession(choice.runnerId, {
|
|
195
|
+
agent: choice.agentId,
|
|
196
|
+
task,
|
|
197
|
+
});
|
|
198
|
+
setState(s => ({ ...s, loading: false }));
|
|
199
|
+
setCoding({
|
|
200
|
+
runnerId: choice.runnerId,
|
|
201
|
+
runnerName: choice.runnerName,
|
|
202
|
+
agent: choice.agentId,
|
|
203
|
+
sessionId: session.sessionId,
|
|
204
|
+
});
|
|
205
|
+
addMessage({
|
|
206
|
+
role: 'info',
|
|
207
|
+
text: `coding session started — ${choice.agentId}@${choice.runnerName} (cwd ${session.cwd ?? '~'})`,
|
|
208
|
+
});
|
|
209
|
+
// Background stream: coding.output chunks land in the transcript,
|
|
210
|
+
// coding.exit closes the mode. /esc aborts the stream client-side.
|
|
211
|
+
const ac = new AbortController();
|
|
212
|
+
codingAbortRef.current = ac;
|
|
213
|
+
void client.streamCodingEvents(choice.runnerId, session.sessionId, (event) => {
|
|
214
|
+
if (event.type === 'coding.output') {
|
|
215
|
+
const chunk = event.data.data;
|
|
216
|
+
if (chunk)
|
|
217
|
+
appendCodingOutput(String(chunk));
|
|
218
|
+
}
|
|
219
|
+
else if (event.type === 'coding.exit') {
|
|
220
|
+
const code = event.data.exitCode;
|
|
221
|
+
addMessage({ role: 'info', text: `coding session exited${code != null ? ` (exit ${code})` : ''}` });
|
|
222
|
+
codingAbortRef.current = null;
|
|
223
|
+
setCoding(null);
|
|
224
|
+
}
|
|
225
|
+
}, ac.signal).catch((err) => {
|
|
226
|
+
if (ac.signal.aborted)
|
|
227
|
+
return;
|
|
228
|
+
addMessage({ role: 'error', text: `coding stream lost: ${explainError(err, errCtx)}` });
|
|
229
|
+
codingAbortRef.current = null;
|
|
230
|
+
setCoding(null);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
setState(s => ({ ...s, loading: false }));
|
|
235
|
+
addMessage({ role: 'error', text: explainError(err, errCtx) });
|
|
236
|
+
}
|
|
237
|
+
}, [client, addMessage, appendCodingOutput]);
|
|
238
|
+
/** Detach from coding mode (abort the stream; the remote session is untouched). */
|
|
239
|
+
const leaveCodingMode = useCallback((note) => {
|
|
240
|
+
codingAbortRef.current?.abort();
|
|
241
|
+
codingAbortRef.current = null;
|
|
242
|
+
setCoding(null);
|
|
243
|
+
addMessage({ role: 'info', text: note });
|
|
244
|
+
}, [addMessage]);
|
|
245
|
+
// ── Turns ───────────────────────────────────────────────────────
|
|
246
|
+
/** Send one message and stream the answer into the transcript. */
|
|
247
|
+
const sendMessage = useCallback(async (text) => {
|
|
248
|
+
addMessage({ role: 'user', text });
|
|
249
|
+
setStreaming('');
|
|
250
|
+
setState(s => ({ ...s, loading: true, loadingLabel: 'Thinking' }));
|
|
251
|
+
const ac = new AbortController();
|
|
252
|
+
runAbortRef.current = ac;
|
|
253
|
+
let result;
|
|
254
|
+
try {
|
|
255
|
+
result = await runTurn(gw, text, {
|
|
256
|
+
mode: state.agent.mode,
|
|
257
|
+
conversationId: state.conversationId ?? undefined,
|
|
258
|
+
pendingRunId: state.pendingRunId ?? undefined,
|
|
259
|
+
signal: ac.signal,
|
|
260
|
+
hooks: {
|
|
261
|
+
partial: setStreaming,
|
|
262
|
+
activity: (activity) => addMessage(activity),
|
|
263
|
+
label: (label) => setState(s => ({ ...s, loadingLabel: label })),
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
runAbortRef.current = null;
|
|
269
|
+
setStreaming('');
|
|
270
|
+
setState(s => ({ ...s, loading: false, pendingRunId: null }));
|
|
271
|
+
addMessage({ role: 'error', text: explainError(err, { ...errCtx, what: 'run' }) });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
runAbortRef.current = null;
|
|
275
|
+
setStreaming('');
|
|
276
|
+
setState(s => ({
|
|
277
|
+
...s,
|
|
278
|
+
loading: false,
|
|
279
|
+
conversationId: result.conversationId ?? s.conversationId,
|
|
280
|
+
pendingRunId: result.pendingRunId ?? null,
|
|
281
|
+
}));
|
|
282
|
+
if (result.runId)
|
|
283
|
+
setLastRunId(result.runId);
|
|
284
|
+
setSessionUsage(u => addUsage(u, result.usage));
|
|
285
|
+
if (result.text)
|
|
286
|
+
addMessage({ role: 'agent', text: result.text });
|
|
287
|
+
const attribution = formatUsage(result.usage);
|
|
288
|
+
if (attribution)
|
|
289
|
+
addMessage({ role: 'info', text: attribution });
|
|
290
|
+
if (result.status === 'failed') {
|
|
291
|
+
addMessage({ role: 'error', text: result.error ?? 'The run failed' });
|
|
292
|
+
}
|
|
293
|
+
else if (result.status === 'cancelled') {
|
|
294
|
+
addMessage({ role: 'info', text: 'cancelled — the run was stopped server-side too' });
|
|
295
|
+
}
|
|
296
|
+
else if (result.status === 'waiting_input') {
|
|
297
|
+
addMessage({ role: 'info', text: 'the agent is waiting for your answer' });
|
|
298
|
+
}
|
|
299
|
+
}, [state.agent.mode, state.conversationId, state.pendingRunId, gw, addMessage]);
|
|
101
300
|
const handleSubmit = useCallback(async (value) => {
|
|
102
|
-
|
|
103
|
-
|
|
301
|
+
// A line ending in a backslash keeps the message open.
|
|
302
|
+
const continuation = continuationOf(value);
|
|
303
|
+
if (continuation !== null) {
|
|
304
|
+
setDraft(d => [...d, continuation]);
|
|
305
|
+
setInput('');
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const submitted = draft.length ? [...draft, value].join('\n') : value;
|
|
309
|
+
if (draft.length)
|
|
310
|
+
setDraft([]);
|
|
311
|
+
const trimmed = submitted.trim();
|
|
312
|
+
if (!trimmed) {
|
|
313
|
+
setInput('');
|
|
104
314
|
return;
|
|
315
|
+
}
|
|
105
316
|
setInput('');
|
|
106
317
|
setHistoryIdx(-1);
|
|
107
|
-
|
|
108
|
-
|
|
318
|
+
appendHistory(trimmed);
|
|
319
|
+
setInputHistory(h => (h[h.length - 1] === trimmed ? h : [...h, trimmed]));
|
|
320
|
+
// Slash commands. A multi-line paste is never one, even if its
|
|
321
|
+
// first character is a slash.
|
|
322
|
+
if (classifyInput(trimmed, coding !== null) === 'command') {
|
|
109
323
|
const [raw, ...args] = trimmed.slice(1).split(/\s+/);
|
|
110
324
|
const cmd = resolveSlash(raw);
|
|
111
325
|
if (!cmd) {
|
|
112
326
|
addMessage({ role: 'error', text: `Unknown command: /${raw}` });
|
|
113
|
-
addMessage({ role: 'info', text:
|
|
327
|
+
addMessage({ role: 'info', text: '/help lists every command.' });
|
|
114
328
|
return;
|
|
115
329
|
}
|
|
116
330
|
if (cmd !== raw.toLowerCase() && !Object.entries(ALIASES).some(([k, v]) => k === raw.toLowerCase() && v === cmd)) {
|
|
@@ -118,22 +332,89 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
|
|
|
118
332
|
}
|
|
119
333
|
switch (cmd) {
|
|
120
334
|
case 'quit':
|
|
121
|
-
|
|
122
|
-
exitMessage = state.conversationId
|
|
123
|
-
? `\nTo resume: npx @almyty/chat ${agentRef} --resume ${state.conversationId}\n`
|
|
124
|
-
: '';
|
|
125
|
-
exit();
|
|
335
|
+
quit();
|
|
126
336
|
return;
|
|
127
337
|
case 'clear':
|
|
338
|
+
// Honest about what it does: the server-side conversation is
|
|
339
|
+
// untouched, so the agent still remembers. /new forgets.
|
|
128
340
|
setState(s => ({ ...s, messages: [] }));
|
|
341
|
+
addMessage({ role: 'info', text: 'transcript cleared on screen — the agent still has this conversation. /new starts a fresh one.' });
|
|
342
|
+
return;
|
|
343
|
+
case 'new':
|
|
344
|
+
setState(s => ({ ...s, messages: [], conversationId: null, pendingRunId: null }));
|
|
345
|
+
setSessionUsage({ cost: 0, tokens: 0, steps: 0 });
|
|
346
|
+
setLastRunId(null);
|
|
347
|
+
addMessage({ role: 'info', text: 'new conversation — the agent has no memory of the last one' });
|
|
348
|
+
return;
|
|
349
|
+
case 'resume':
|
|
350
|
+
if (!state.conversationId) {
|
|
351
|
+
addMessage({ role: 'info', text: 'No conversation yet — send a message first.' });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
addMessage({ role: 'info', text: `almyty chat ${agentRef} --resume ${state.conversationId}` });
|
|
355
|
+
return;
|
|
356
|
+
case 'cost': {
|
|
357
|
+
const line = formatUsage(sessionUsage);
|
|
358
|
+
addMessage({ role: 'info', text: line ? `this session: ${line}` : 'Nothing spent yet this session.' });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
case 'model': {
|
|
362
|
+
const config = (state.agent.modelConfig ?? {});
|
|
363
|
+
const routing = config.routing;
|
|
364
|
+
if (routing) {
|
|
365
|
+
const summary = [routing.role, routing.rationale, routing.strategy].filter(Boolean).join(' · ');
|
|
366
|
+
addMessage({ role: 'info', text: `routed per run${summary ? ` — ${summary}` : ''}; the answering model is shown after each turn` });
|
|
367
|
+
}
|
|
368
|
+
else if (config.model) {
|
|
369
|
+
addMessage({ role: 'info', text: `${config.model}${config.providerId ? ` · provider ${String(config.providerId).slice(0, 8)}` : ''}` });
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
addMessage({ role: 'info', text: 'No model configured on this agent. It will not answer until one is chosen in the dashboard.' });
|
|
373
|
+
}
|
|
374
|
+
if (sessionUsage.model)
|
|
375
|
+
addMessage({ role: 'info', text: `last answered by ${sessionUsage.model}` });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
case 'trace': {
|
|
379
|
+
if (!lastRunId) {
|
|
380
|
+
addMessage({ role: 'info', text: 'No run to trace yet.' });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
setState(s => ({ ...s, loading: true, loadingLabel: 'Loading trace' }));
|
|
384
|
+
try {
|
|
385
|
+
const run = await gw.getRun(lastRunId);
|
|
386
|
+
setState(s => ({ ...s, loading: false }));
|
|
387
|
+
const steps = Array.isArray(run.steps) ? run.steps : [];
|
|
388
|
+
if (!steps.length) {
|
|
389
|
+
addMessage({ role: 'info', text: `run ${lastRunId.slice(0, 8)} · ${run.status} · no steps recorded` });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
addMessage({ role: 'info', text: `run ${lastRunId.slice(0, 8)} · ${run.status} · ${steps.length} step${steps.length === 1 ? '' : 's'}` });
|
|
393
|
+
steps.forEach((step, i) => {
|
|
394
|
+
const bits = [step.type];
|
|
395
|
+
if (step.input?.tool)
|
|
396
|
+
bits.push(step.input.tool);
|
|
397
|
+
if (typeof step.cost === 'number' && step.cost)
|
|
398
|
+
bits.push(`$${step.cost.toFixed(4)}`);
|
|
399
|
+
if (step.duration)
|
|
400
|
+
bits.push(`${Math.round(step.duration)}ms`);
|
|
401
|
+
if (step.error)
|
|
402
|
+
bits.push(`error: ${step.error}`);
|
|
403
|
+
addMessage({ role: step.error ? 'error' : 'tool', text: `${i + 1}. ${bits.join(' · ')}` });
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
setState(s => ({ ...s, loading: false }));
|
|
408
|
+
addMessage({ role: 'error', text: explainError(err, errCtx) });
|
|
409
|
+
}
|
|
129
410
|
return;
|
|
411
|
+
}
|
|
130
412
|
case 'help':
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
addMessage({ role: 'info', text: '/
|
|
135
|
-
addMessage({ role: 'info', text: '
|
|
136
|
-
addMessage({ role: 'info', text: 'Tab to autocomplete commands.' });
|
|
413
|
+
for (const name of SLASH_COMMANDS) {
|
|
414
|
+
addMessage({ role: 'info', text: `/${name.padEnd(11)}${COMMAND_DESCS[name] ?? ''}` });
|
|
415
|
+
}
|
|
416
|
+
addMessage({ role: 'info', text: 'Tab completes · up/down walk history · end a line with \\ to keep typing' });
|
|
417
|
+
addMessage({ role: 'info', text: 'Ctrl-C cancels the running answer, again to exit · Ctrl-D exits' });
|
|
137
418
|
return;
|
|
138
419
|
case 'tools': {
|
|
139
420
|
const tools = state.agent.tools;
|
|
@@ -152,138 +433,130 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
|
|
|
152
433
|
const target = args.join(' ').trim();
|
|
153
434
|
if (target) {
|
|
154
435
|
setState(s => ({ ...s, loading: true, loadingLabel: 'Switching' }));
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
436
|
+
try {
|
|
437
|
+
const found = await client.findAgentByNameOrId(target);
|
|
438
|
+
setState(s => ({ ...s, loading: false }));
|
|
439
|
+
if (!found) {
|
|
440
|
+
addMessage({ role: 'error', text: `No agent called "${target}". /agents with no argument lists them.` });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
setState(s => ({
|
|
444
|
+
...s,
|
|
445
|
+
agent: found,
|
|
446
|
+
messages: [],
|
|
447
|
+
conversationId: null,
|
|
448
|
+
pendingRunId: null,
|
|
449
|
+
}));
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
setState(s => ({ ...s, loading: false }));
|
|
453
|
+
addMessage({ role: 'error', text: explainError(err, errCtx) });
|
|
160
454
|
}
|
|
161
|
-
setState(s => ({
|
|
162
|
-
...s,
|
|
163
|
-
agent: found,
|
|
164
|
-
messages: [],
|
|
165
|
-
conversationId: null,
|
|
166
|
-
pendingRunId: null,
|
|
167
|
-
}));
|
|
168
455
|
return;
|
|
169
456
|
}
|
|
170
457
|
setState(s => ({ ...s, loading: true, loadingLabel: 'Loading' }));
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// Regular message
|
|
181
|
-
addMessage({ role: 'user', text: trimmed });
|
|
182
|
-
setState(s => ({ ...s, loading: true, loadingLabel: 'Thinking' }));
|
|
183
|
-
try {
|
|
184
|
-
if (state.agent.mode === 'autonomous') {
|
|
185
|
-
let runId;
|
|
186
|
-
if (state.pendingRunId) {
|
|
187
|
-
await gw.sendRunInput(state.pendingRunId, trimmed);
|
|
188
|
-
runId = state.pendingRunId;
|
|
189
|
-
setState(s => ({ ...s, pendingRunId: null }));
|
|
190
|
-
}
|
|
191
|
-
else {
|
|
192
|
-
const run = await gw.startRun(trimmed, {
|
|
193
|
-
conversationId: state.conversationId ?? undefined,
|
|
194
|
-
});
|
|
195
|
-
runId = run.id;
|
|
196
|
-
if (run.conversationId) {
|
|
197
|
-
setState(s => ({ ...s, conversationId: run.conversationId }));
|
|
458
|
+
try {
|
|
459
|
+
const agents = await client.listAgents();
|
|
460
|
+
setState(s => ({ ...s, loading: false }));
|
|
461
|
+
setPickerAgents(agents);
|
|
462
|
+
setShowPicker(true);
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
setState(s => ({ ...s, loading: false }));
|
|
466
|
+
addMessage({ role: 'error', text: explainError(err, errCtx) });
|
|
198
467
|
}
|
|
468
|
+
return;
|
|
199
469
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const chunk = event.data.content;
|
|
209
|
-
if (chunk)
|
|
210
|
-
streamedContent += chunk;
|
|
211
|
-
break;
|
|
212
|
-
}
|
|
213
|
-
case 'llm.response':
|
|
214
|
-
setState(s => ({ ...s, loadingLabel: 'Processing' }));
|
|
215
|
-
break;
|
|
216
|
-
case 'tool.started': {
|
|
217
|
-
const toolName = event.data.tool;
|
|
218
|
-
if (toolName) {
|
|
219
|
-
setState(s => ({ ...s, loading: false }));
|
|
220
|
-
addMessage({ role: 'tool', text: toolName });
|
|
221
|
-
setState(s => ({ ...s, loading: true, loadingLabel: `Running ${toolName}` }));
|
|
222
|
-
}
|
|
223
|
-
break;
|
|
470
|
+
case 'runners': {
|
|
471
|
+
setState(s => ({ ...s, loading: true, loadingLabel: 'Loading runners' }));
|
|
472
|
+
try {
|
|
473
|
+
const runners = await client.listRunners();
|
|
474
|
+
setState(s => ({ ...s, loading: false }));
|
|
475
|
+
if (!runners.length) {
|
|
476
|
+
addMessage({ role: 'info', text: 'No runners registered. Start one: npx @almyty/runner start' });
|
|
477
|
+
return;
|
|
224
478
|
}
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
if (tool) {
|
|
229
|
-
addMessage({ role: 'info', text: `${tool} ${success ? 'completed' : 'failed'}` });
|
|
230
|
-
}
|
|
231
|
-
break;
|
|
479
|
+
for (const r of runners) {
|
|
480
|
+
const clis = (r.codingAgents ?? []).map(a => a.id).join(', ') || 'no coding CLIs detected';
|
|
481
|
+
addMessage({ role: 'info', text: `${r.name} · ${r.state ?? 'unknown'} · ${clis}` });
|
|
232
482
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
setState(s => ({ ...s, loading: false }));
|
|
486
|
+
addMessage({ role: 'error', text: explainError(err, errCtx) });
|
|
487
|
+
}
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
case 'code': {
|
|
491
|
+
const task = args.join(' ').trim();
|
|
492
|
+
if (!task) {
|
|
493
|
+
addMessage({ role: 'error', text: 'Usage: /code <task>' });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (coding) {
|
|
497
|
+
addMessage({ role: 'error', text: 'A coding session is already active — /code-stop or /esc first.' });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
setState(s => ({ ...s, loading: true, loadingLabel: 'Finding runners' }));
|
|
501
|
+
try {
|
|
502
|
+
const runners = await client.listRunners();
|
|
503
|
+
setState(s => ({ ...s, loading: false }));
|
|
504
|
+
const choices = buildCodeChoices(runners);
|
|
505
|
+
if (!choices.length) {
|
|
506
|
+
addMessage({ role: 'error', text: 'No online runner with a detected coding CLI. Start one: npx @almyty/runner start' });
|
|
507
|
+
return;
|
|
242
508
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
break;
|
|
509
|
+
if (choices.length === 1) {
|
|
510
|
+
await startCoding(choices[0], task);
|
|
511
|
+
return;
|
|
247
512
|
}
|
|
513
|
+
setCodeChoices({ choices, task });
|
|
248
514
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
finalResult = await gw.pollRun(runId);
|
|
255
|
-
}
|
|
256
|
-
// Show final output
|
|
257
|
-
if (finalResult.status === 'completed') {
|
|
258
|
-
const text = streamedContent
|
|
259
|
-
|| (finalResult.output != null ? (typeof finalResult.output === 'string' ? finalResult.output : JSON.stringify(finalResult.output, null, 2)) : '');
|
|
260
|
-
if (text)
|
|
261
|
-
addMessage({ role: 'agent', text });
|
|
262
|
-
}
|
|
263
|
-
else if (finalResult.status === 'waiting_input') {
|
|
264
|
-
setState(s => ({ ...s, pendingRunId: runId }));
|
|
265
|
-
addMessage({ role: 'info', text: 'Waiting for your input' });
|
|
515
|
+
catch (err) {
|
|
516
|
+
setState(s => ({ ...s, loading: false }));
|
|
517
|
+
addMessage({ role: 'error', text: explainError(err, errCtx) });
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
266
520
|
}
|
|
267
|
-
|
|
268
|
-
|
|
521
|
+
case 'code-stop': {
|
|
522
|
+
if (!coding) {
|
|
523
|
+
addMessage({ role: 'info', text: 'No active coding session.' });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
try {
|
|
527
|
+
await client.stopCodingSession(coding.runnerId, coding.sessionId);
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
addMessage({ role: 'error', text: explainError(err, errCtx) });
|
|
531
|
+
}
|
|
532
|
+
leaveCodingMode('coding session stopped');
|
|
533
|
+
return;
|
|
269
534
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
|
|
278
|
-
addMessage({ role: 'agent', text });
|
|
535
|
+
case 'esc': {
|
|
536
|
+
if (!coding) {
|
|
537
|
+
addMessage({ role: 'info', text: 'Not in coding mode.' });
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
leaveCodingMode(`left coding mode — session keeps running on ${coding.runnerName} (/code-stop to kill it)`);
|
|
541
|
+
return;
|
|
279
542
|
}
|
|
280
543
|
}
|
|
544
|
+
return;
|
|
281
545
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
546
|
+
// Coding mode: anything that isn't a slash command routes to the
|
|
547
|
+
// session's stdin, not to the chat agent.
|
|
548
|
+
if (classifyInput(trimmed, coding !== null) === 'coding' && coding) {
|
|
549
|
+
addMessage({ role: 'user', text: trimmed });
|
|
550
|
+
try {
|
|
551
|
+
await client.sendCodingInput(coding.runnerId, coding.sessionId, trimmed);
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
addMessage({ role: 'error', text: explainError(err, errCtx) });
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
285
557
|
}
|
|
286
|
-
|
|
558
|
+
await sendMessage(trimmed);
|
|
559
|
+
}, [state.agent, state.conversationId, state.pendingRunId, client, gw, addMessage, quit, coding, startCoding, leaveCodingMode, sendMessage, draft, sessionUsage, lastRunId, agentRef]);
|
|
287
560
|
const handlePickerSelect = useCallback((agent) => {
|
|
288
561
|
setShowPicker(false);
|
|
289
562
|
if (agent.id === state.agent.id)
|
|
@@ -296,17 +569,33 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
|
|
|
296
569
|
pendingRunId: null,
|
|
297
570
|
}));
|
|
298
571
|
}, [state.agent.id]);
|
|
299
|
-
const suggestion = getSuggestion(input);
|
|
300
572
|
if (showPicker) {
|
|
301
573
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { agent: state.agent, conversationId: state.conversationId }), _jsx(AgentSelector, { agents: pickerAgents, onSelect: handlePickerSelect })] }));
|
|
302
574
|
}
|
|
303
|
-
|
|
575
|
+
if (codeChoices) {
|
|
576
|
+
const items = codeChoices.choices.map(c => ({
|
|
577
|
+
key: `${c.runnerId}:${c.agentId}`,
|
|
578
|
+
label: c.agentName,
|
|
579
|
+
hint: `on ${c.runnerName}`,
|
|
580
|
+
}));
|
|
581
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { agent: state.agent, conversationId: state.conversationId }), _jsx(ChoiceSelector, { title: "Pick a coding CLI + runner:", choices: items, onSelect: (picked) => {
|
|
582
|
+
const choice = codeChoices.choices.find(c => `${c.runnerId}:${c.agentId}` === picked.key);
|
|
583
|
+
const task = codeChoices.task;
|
|
584
|
+
setCodeChoices(null);
|
|
585
|
+
if (choice)
|
|
586
|
+
void startCoding(choice, task);
|
|
587
|
+
} })] }));
|
|
588
|
+
}
|
|
589
|
+
const transcriptRows = usableRows(size.rows, CHROME_ROWS + (paletteOpen ? slashMatches.length : 0));
|
|
590
|
+
const separatorWidth = Math.min(Math.max(size.columns || 80, 20), 120);
|
|
591
|
+
const sessionLine = formatUsage(sessionUsage);
|
|
592
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { agent: state.agent, conversationId: state.conversationId }), _jsx(Box, { flexDirection: "column", paddingRight: 2, children: _jsx(MessageWindow, { messages: state.messages, loading: state.loading, loadingLabel: state.loadingLabel, maxRows: transcriptRows, streaming: streaming }) }), paletteOpen && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: slashMatches.map((cmd, i) => {
|
|
304
593
|
const active = i === paletteCursor;
|
|
305
|
-
const padded = `/${cmd}`.padEnd(
|
|
594
|
+
const padded = `/${cmd}`.padEnd(12);
|
|
306
595
|
const desc = COMMAND_DESCS[cmd] ?? '';
|
|
307
596
|
const line = `${active ? '❯' : ' '} ${padded} ${desc}`;
|
|
308
597
|
return _jsx(Text, { color: active ? '#8b5cf6' : undefined, bold: active, wrap: "truncate", children: line }, cmd);
|
|
309
|
-
}) })), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '─'.repeat(
|
|
598
|
+
}) })), coding && _jsx(CodingModeIndicator, { agent: coding.agent, runner: coding.runnerName }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '─'.repeat(separatorWidth) }) }), _jsxs(Box, { paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: coding ? '#22d3ee' : '#8b5cf6', children: draft.length ? '… ' : '❯ ' }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { value: input, onChange: handleInputChange, onSubmit: (val) => {
|
|
310
599
|
if (paletteOpen && slashMatches.length > 0) {
|
|
311
600
|
const selected = `/${slashMatches[paletteCursor]}`;
|
|
312
601
|
setInput('');
|
|
@@ -315,5 +604,5 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
|
|
|
315
604
|
return;
|
|
316
605
|
}
|
|
317
606
|
handleSubmit(val);
|
|
318
|
-
}, placeholder:
|
|
607
|
+
}, placeholder: coding ? 'Type input for the coding session or / for commands' : 'Type a message or / for commands' }) })] }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { dimColor: true, wrap: "truncate", children: [state.agent.name, state.agent.tools?.length ? ` · ${state.agent.tools.length} tools` : '', state.conversationId ? ` · ${state.conversationId.slice(0, 8)}` : '', sessionLine ? ` · ${sessionLine}` : '', coding ? ` · coding:${coding.agent}@${coding.runnerName}` : '', state.loading ? ' · ctrl-c cancels' : ''] }) })] }));
|
|
319
608
|
}
|