@almyty/chat 1.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
@@ -2,13 +2,22 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
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, CodingModeIndicator, ChoiceSelector, } from './components.js';
6
- import { SLASH_COMMANDS, COMMAND_DESCS, resolveSlash, getSuggestion, ALIASES, classifyInput, buildCodeChoices, } 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,15 +51,27 @@ 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([]);
@@ -50,12 +80,54 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
50
80
  // Pending runner x CLI pick for /code when multiple targets exist.
51
81
  const [codeChoices, setCodeChoices] = useState(null);
52
82
  const codingAbortRef = useRef(null);
83
+ // The in-flight turn, so Ctrl-C can stop it server-side.
84
+ const runAbortRef = useRef(null);
53
85
  // Command palette matches
54
86
  const slashMatches = input.startsWith('/') && !input.includes(' ')
55
87
  ? SLASH_COMMANDS.filter(c => c.startsWith(input.slice(1).toLowerCase()))
56
88
  : [];
57
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]);
58
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
+ }
59
131
  if (state.loading)
60
132
  return;
61
133
  // Command palette navigation
@@ -76,22 +148,17 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
76
148
  return;
77
149
  }
78
150
  // Input history — up/down arrows always, like Claude Code
79
- if (key.upArrow && inputHistory.length > 0) {
80
- const newIdx = Math.min(historyIdx + 1, inputHistory.length - 1);
81
- setHistoryIdx(newIdx);
82
- 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);
83
156
  return;
84
157
  }
85
158
  if (key.downArrow) {
86
- if (historyIdx > 0) {
87
- const newIdx = historyIdx - 1;
88
- setHistoryIdx(newIdx);
89
- setInput(inputHistory[inputHistory.length - 1 - newIdx]);
90
- }
91
- else if (historyIdx === 0) {
92
- setHistoryIdx(-1);
93
- setInput('');
94
- }
159
+ const walked = walkHistory(inputHistory, historyIdx, 'down');
160
+ setHistoryIdx(walked.idx);
161
+ setInput(walked.value);
95
162
  return;
96
163
  }
97
164
  });
@@ -100,9 +167,6 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
100
167
  setInput(val);
101
168
  setPaletteCursor(0);
102
169
  }, []);
103
- const addMessage = useCallback((msg) => {
104
- setState(s => ({ ...s, messages: [...s.messages, msg] }));
105
- }, []);
106
170
  // ── Coding session helpers (chat-to-runner bridge) ──────────────
107
171
  /**
108
172
  * Append streamed CLI output to the transcript, merging into the last
@@ -161,14 +225,14 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
161
225
  }, ac.signal).catch((err) => {
162
226
  if (ac.signal.aborted)
163
227
  return;
164
- addMessage({ role: 'error', text: `coding stream lost: ${err.message}` });
228
+ addMessage({ role: 'error', text: `coding stream lost: ${explainError(err, errCtx)}` });
165
229
  codingAbortRef.current = null;
166
230
  setCoding(null);
167
231
  });
168
232
  }
169
233
  catch (err) {
170
234
  setState(s => ({ ...s, loading: false }));
171
- addMessage({ role: 'error', text: err.message });
235
+ addMessage({ role: 'error', text: explainError(err, errCtx) });
172
236
  }
173
237
  }, [client, addMessage, appendCodingOutput]);
174
238
  /** Detach from coding mode (abort the stream; the remote session is untouched). */
@@ -178,19 +242,89 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
178
242
  setCoding(null);
179
243
  addMessage({ role: 'info', text: note });
180
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]);
181
300
  const handleSubmit = useCallback(async (value) => {
182
- const trimmed = value.trim();
183
- 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('');
184
314
  return;
315
+ }
185
316
  setInput('');
186
317
  setHistoryIdx(-1);
187
- // Slash commands
188
- 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') {
189
323
  const [raw, ...args] = trimmed.slice(1).split(/\s+/);
190
324
  const cmd = resolveSlash(raw);
191
325
  if (!cmd) {
192
326
  addMessage({ role: 'error', text: `Unknown command: /${raw}` });
193
- addMessage({ role: 'info', text: `Commands: /help /agents /runners /code /clear /quit` });
327
+ addMessage({ role: 'info', text: '/help lists every command.' });
194
328
  return;
195
329
  }
196
330
  if (cmd !== raw.toLowerCase() && !Object.entries(ALIASES).some(([k, v]) => k === raw.toLowerCase() && v === cmd)) {
@@ -198,26 +332,89 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
198
332
  }
199
333
  switch (cmd) {
200
334
  case 'quit':
201
- const agentRef = `${gw.orgSlug}/${gw.agentSlug}`;
202
- exitMessage = state.conversationId
203
- ? `\nTo resume: npx @almyty/chat ${agentRef} --resume ${state.conversationId}\n`
204
- : '';
205
- exit();
335
+ quit();
206
336
  return;
207
337
  case 'clear':
338
+ // Honest about what it does: the server-side conversation is
339
+ // untouched, so the agent still remembers. /new forgets.
208
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
+ }
209
410
  return;
411
+ }
210
412
  case 'help':
211
- addMessage({ role: 'info', text: '/agents browse and switch agents' });
212
- addMessage({ role: 'info', text: '/tools show available tools' });
213
- addMessage({ role: 'info', text: '/runners list your runners + coding CLIs' });
214
- addMessage({ role: 'info', text: '/code run a coding task on a runner' });
215
- addMessage({ role: 'info', text: '/code-stop stop the active coding session' });
216
- addMessage({ role: 'info', text: '/esc leave coding mode (session keeps running)' });
217
- addMessage({ role: 'info', text: '/clear clear conversation' });
218
- addMessage({ role: 'info', text: '/help show this help' });
219
- addMessage({ role: 'info', text: '/quit exit' });
220
- 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' });
221
418
  return;
222
419
  case 'tools': {
223
420
  const tools = state.agent.tools;
@@ -236,26 +433,38 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
236
433
  const target = args.join(' ').trim();
237
434
  if (target) {
238
435
  setState(s => ({ ...s, loading: true, loadingLabel: 'Switching' }));
239
- const found = await client.findAgentByNameOrId(target);
240
- setState(s => ({ ...s, loading: false }));
241
- if (!found) {
242
- addMessage({ role: 'error', text: `Agent "${target}" not found` });
243
- 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) });
244
454
  }
245
- setState(s => ({
246
- ...s,
247
- agent: found,
248
- messages: [],
249
- conversationId: null,
250
- pendingRunId: null,
251
- }));
252
455
  return;
253
456
  }
254
457
  setState(s => ({ ...s, loading: true, loadingLabel: 'Loading' }));
255
- const agents = await client.listAgents();
256
- setState(s => ({ ...s, loading: false }));
257
- setPickerAgents(agents);
258
- setShowPicker(true);
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) });
467
+ }
259
468
  return;
260
469
  }
261
470
  case 'runners': {
@@ -274,7 +483,7 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
274
483
  }
275
484
  catch (err) {
276
485
  setState(s => ({ ...s, loading: false }));
277
- addMessage({ role: 'error', text: err.message });
486
+ addMessage({ role: 'error', text: explainError(err, errCtx) });
278
487
  }
279
488
  return;
280
489
  }
@@ -305,7 +514,7 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
305
514
  }
306
515
  catch (err) {
307
516
  setState(s => ({ ...s, loading: false }));
308
- addMessage({ role: 'error', text: err.message });
517
+ addMessage({ role: 'error', text: explainError(err, errCtx) });
309
518
  }
310
519
  return;
311
520
  }
@@ -318,7 +527,7 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
318
527
  await client.stopCodingSession(coding.runnerId, coding.sessionId);
319
528
  }
320
529
  catch (err) {
321
- addMessage({ role: 'error', text: err.message });
530
+ addMessage({ role: 'error', text: explainError(err, errCtx) });
322
531
  }
323
532
  leaveCodingMode('coding session stopped');
324
533
  return;
@@ -342,117 +551,12 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
342
551
  await client.sendCodingInput(coding.runnerId, coding.sessionId, trimmed);
343
552
  }
344
553
  catch (err) {
345
- addMessage({ role: 'error', text: err.message });
554
+ addMessage({ role: 'error', text: explainError(err, errCtx) });
346
555
  }
347
556
  return;
348
557
  }
349
- // Regular message
350
- addMessage({ role: 'user', text: trimmed });
351
- setState(s => ({ ...s, loading: true, loadingLabel: 'Thinking' }));
352
- try {
353
- if (state.agent.mode === 'autonomous') {
354
- let runId;
355
- if (state.pendingRunId) {
356
- await gw.sendRunInput(state.pendingRunId, trimmed);
357
- runId = state.pendingRunId;
358
- setState(s => ({ ...s, pendingRunId: null }));
359
- }
360
- else {
361
- const run = await gw.startRun(trimmed, {
362
- conversationId: state.conversationId ?? undefined,
363
- });
364
- runId = run.id;
365
- if (run.conversationId) {
366
- setState(s => ({ ...s, conversationId: run.conversationId }));
367
- }
368
- }
369
- // Stream events — show tool calls, sub-agents, chunks in real time
370
- let streamedContent = '';
371
- const result = await gw.streamRun(runId, (event) => {
372
- switch (event.type) {
373
- case 'llm.started':
374
- setState(s => ({ ...s, loadingLabel: 'Thinking' }));
375
- break;
376
- case 'llm.chunk': {
377
- const chunk = event.data.content;
378
- if (chunk)
379
- streamedContent += chunk;
380
- break;
381
- }
382
- case 'llm.response':
383
- setState(s => ({ ...s, loadingLabel: 'Processing' }));
384
- break;
385
- case 'tool.started': {
386
- const toolName = event.data.tool;
387
- if (toolName) {
388
- setState(s => ({ ...s, loading: false }));
389
- addMessage({ role: 'tool', text: toolName });
390
- setState(s => ({ ...s, loading: true, loadingLabel: `Running ${toolName}` }));
391
- }
392
- break;
393
- }
394
- case 'tool.result': {
395
- const tool = event.data.tool;
396
- const success = event.data.success;
397
- if (tool) {
398
- addMessage({ role: 'info', text: `${tool} ${success ? 'completed' : 'failed'}` });
399
- }
400
- break;
401
- }
402
- case 'step.completed':
403
- break;
404
- case 'run.completed': {
405
- const output = event.data.output;
406
- if (output) {
407
- const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
408
- streamedContent = text;
409
- }
410
- break;
411
- }
412
- case 'run.failed': {
413
- const error = event.data.error;
414
- addMessage({ role: 'error', text: error || 'Run failed' });
415
- break;
416
- }
417
- }
418
- });
419
- setState(s => ({ ...s, loading: false }));
420
- // If still running after stream, poll to completion
421
- let finalResult = result;
422
- if (result.status === 'running') {
423
- finalResult = await gw.pollRun(runId);
424
- }
425
- // Show final output
426
- if (finalResult.status === 'completed') {
427
- const text = streamedContent
428
- || (finalResult.output != null ? (typeof finalResult.output === 'string' ? finalResult.output : JSON.stringify(finalResult.output, null, 2)) : '');
429
- if (text)
430
- addMessage({ role: 'agent', text });
431
- }
432
- else if (finalResult.status === 'waiting_input') {
433
- setState(s => ({ ...s, pendingRunId: runId }));
434
- addMessage({ role: 'info', text: 'Waiting for your input' });
435
- }
436
- else if (finalResult.status === 'failed' && !streamedContent) {
437
- addMessage({ role: 'error', text: finalResult.error || 'Run failed' });
438
- }
439
- }
440
- else {
441
- // Workflow: synchronous invoke
442
- const result = await gw.invoke({ message: trimmed });
443
- setState(s => ({ ...s, loading: false }));
444
- const output = result?.output ?? result?.data?.output ?? result;
445
- if (output != null) {
446
- const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
447
- addMessage({ role: 'agent', text });
448
- }
449
- }
450
- }
451
- catch (err) {
452
- setState(s => ({ ...s, loading: false }));
453
- addMessage({ role: 'error', text: err.message });
454
- }
455
- }, [state.agent, state.conversationId, state.pendingRunId, client, addMessage, exit, coding, startCoding, leaveCodingMode]);
558
+ await sendMessage(trimmed);
559
+ }, [state.agent, state.conversationId, state.pendingRunId, client, gw, addMessage, quit, coding, startCoding, leaveCodingMode, sendMessage, draft, sessionUsage, lastRunId, agentRef]);
456
560
  const handlePickerSelect = useCallback((agent) => {
457
561
  setShowPicker(false);
458
562
  if (agent.id === state.agent.id)
@@ -465,7 +569,6 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
465
569
  pendingRunId: null,
466
570
  }));
467
571
  }, [state.agent.id]);
468
- const suggestion = getSuggestion(input);
469
572
  if (showPicker) {
470
573
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { agent: state.agent, conversationId: state.conversationId }), _jsx(AgentSelector, { agents: pickerAgents, onSelect: handlePickerSelect })] }));
471
574
  }
@@ -483,13 +586,16 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
483
586
  void startCoding(choice, task);
484
587
  } })] }));
485
588
  }
486
- 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) => {
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) => {
487
593
  const active = i === paletteCursor;
488
- const padded = `/${cmd}`.padEnd(10);
594
+ const padded = `/${cmd}`.padEnd(12);
489
595
  const desc = COMMAND_DESCS[cmd] ?? '';
490
596
  const line = `${active ? '❯' : ' '} ${padded} ${desc}`;
491
597
  return _jsx(Text, { color: active ? '#8b5cf6' : undefined, bold: active, wrap: "truncate", children: line }, cmd);
492
- }) })), coding && _jsx(CodingModeIndicator, { agent: coding.agent, runner: coding.runnerName }), _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: coding ? '#22d3ee' : '#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) => {
493
599
  if (paletteOpen && slashMatches.length > 0) {
494
600
  const selected = `/${slashMatches[paletteCursor]}`;
495
601
  setInput('');
@@ -498,5 +604,5 @@ export function ChatApp({ client, initialAgent, gw, resumeConversationId }) {
498
604
  return;
499
605
  }
500
606
  handleSubmit(val);
501
- }, 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, children: [state.agent.name, state.agent.tools?.length ? ` · ${state.agent.tools.length} tools` : '', state.conversationId ? ` · ${state.conversationId.slice(0, 8)}` : '', coding ? ` · coding:${coding.agent}@${coding.runnerName}` : ''] }) })] }));
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' : ''] }) })] }));
502
608
  }