@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/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, MessageView, LoadingIndicator, AgentSelector } from './components.js';
6
- import { SLASH_COMMANDS, COMMAND_DESCS, resolveSlash, getSuggestion, ALIASES } from './commands.js';
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 => ({ ...s, loading: false }));
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 derived from conversation — includes resumed messages
44
- const inputHistory = state.messages.filter(m => m.role === 'user').map(m => m.text);
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 && inputHistory.length > 0) {
75
- const newIdx = Math.min(historyIdx + 1, inputHistory.length - 1);
76
- setHistoryIdx(newIdx);
77
- setInput(inputHistory[inputHistory.length - 1 - newIdx]);
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
- if (historyIdx > 0) {
82
- const newIdx = historyIdx - 1;
83
- setHistoryIdx(newIdx);
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
- const addMessage = useCallback((msg) => {
99
- setState(s => ({ ...s, messages: [...s.messages, msg] }));
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
- const trimmed = value.trim();
103
- if (!trimmed)
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
- // Slash commands
108
- if (trimmed.startsWith('/')) {
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: `Commands: /help /agents /clear /quit` });
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
- const agentRef = `${gw.orgSlug}/${gw.agentSlug}`;
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
- addMessage({ role: 'info', text: '/agents browse and switch agents' });
132
- addMessage({ role: 'info', text: '/tools show available tools' });
133
- addMessage({ role: 'info', text: '/clear clear conversation' });
134
- addMessage({ role: 'info', text: '/help show this help' });
135
- addMessage({ role: 'info', text: '/quit exit' });
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
- const found = await client.findAgentByNameOrId(target);
156
- setState(s => ({ ...s, loading: false }));
157
- if (!found) {
158
- addMessage({ role: 'error', text: `Agent "${target}" not found` });
159
- return;
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
- const agents = await client.listAgents();
172
- setState(s => ({ ...s, loading: false }));
173
- setPickerAgents(agents);
174
- setShowPicker(true);
175
- return;
176
- }
177
- }
178
- return;
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
- // Stream events — show tool calls, sub-agents, chunks in real time
201
- let streamedContent = '';
202
- const result = await gw.streamRun(runId, (event) => {
203
- switch (event.type) {
204
- case 'llm.started':
205
- setState(s => ({ ...s, loadingLabel: 'Thinking' }));
206
- break;
207
- case 'llm.chunk': {
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
- case 'tool.result': {
226
- const tool = event.data.tool;
227
- const success = event.data.success;
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
- case 'step.completed':
234
- break;
235
- case 'run.completed': {
236
- const output = event.data.output;
237
- if (output) {
238
- const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
239
- streamedContent = text;
240
- }
241
- break;
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
- case 'run.failed': {
244
- const error = event.data.error;
245
- addMessage({ role: 'error', text: error || 'Run failed' });
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
- setState(s => ({ ...s, loading: false }));
251
- // If still running after stream, poll to completion
252
- let finalResult = result;
253
- if (result.status === 'running') {
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
- else if (finalResult.status === 'failed' && !streamedContent) {
268
- addMessage({ role: 'error', text: finalResult.error || 'Run failed' });
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
- else {
272
- // Workflow: synchronous invoke
273
- const result = await gw.invoke({ message: trimmed });
274
- setState(s => ({ ...s, loading: false }));
275
- const output = result?.output ?? result?.data?.output ?? result;
276
- if (output != null) {
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
- catch (err) {
283
- setState(s => ({ ...s, loading: false }));
284
- addMessage({ role: 'error', text: err.message });
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
- }, [state.agent, state.conversationId, state.pendingRunId, client, addMessage, exit]);
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
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { agent: state.agent, conversationId: state.conversationId }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, paddingRight: 2, overflow: "hidden", children: [state.messages.map((msg, i) => (_jsx(MessageView, { msg: msg }, i))), state.loading && _jsx(LoadingIndicator, { label: state.loadingLabel })] }), paletteOpen && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: slashMatches.map((cmd, i) => {
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(10);
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(Math.min(process.stdout.columns || 80, 120)) }) }), _jsxs(Box, { paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: "#8b5cf6", children: "\u276F " }), _jsx(Box, { flexGrow: 1, children: _jsx(TextInput, { value: input, onChange: handleInputChange, onSubmit: (val) => {
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: "Type a message or / for commands" }) })] }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { dimColor: true, children: [state.agent.name, state.agent.tools?.length ? ` · ${state.agent.tools.length} tools` : '', state.conversationId ? ` · ${state.conversationId.slice(0, 8)}` : ''] }) })] }));
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
  }