@steerable/agent-cli 0.6.60 → 0.6.61

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.
@@ -1,7 +1,25 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
1
6
  import { getKeybindings } from '@earendil-works/pi-tui';
2
7
  import { listBusyChatIds } from '@steerable/agent-client';
8
+ import { saveAttachmentFiles } from '@steerable/agent-shell/attachments';
9
+ import { applyChildEvent, childLines } from './children.js';
10
+ import { readSystemClipboard, saveClipboardImage, writeSystemClipboard } from './clipboard.js';
11
+ import { completeSlash, slashAt } from './commands.js';
12
+ import { clearDraft, createDraft, editDraft, insertText } from './editor.js';
13
+ import { attachmentMessage, completeFiles, isImagePath, mentionAt, } from './files.js';
14
+ import { markdownChat } from './export-chat.js';
3
15
  import { ensureAgentKeybindings } from './keys.js';
16
+ import { createPromptHistory, leavePromptHistory, onFirstLine, onLastLine, recallPrompt, rememberPrompt, } from './prompt-history.js';
4
17
  import { renderScreen } from './screen.js';
18
+ import { searchHits, searchLabel } from './search.js';
19
+ import { transcriptEdge } from './scroll.js';
20
+ import { todosFromAction } from './todos.js';
21
+ import { historyRows, toolOutput, toolStatus } from './transcript.js';
22
+ import { formatUsage } from './usage.js';
5
23
  export class AgentTui {
6
24
  client;
7
25
  options;
@@ -16,7 +34,24 @@ export class AgentTui {
16
34
  ask = null;
17
35
  readOnly = false;
18
36
  help = false;
19
- draft = '';
37
+ buffer = createDraft();
38
+ prompts = createPromptHistory();
39
+ paste = null;
40
+ picks = null;
41
+ pickIndex = 0;
42
+ pickGeneration = 0;
43
+ attachments = [];
44
+ queue = [];
45
+ sending = false;
46
+ search = null;
47
+ scroll = null;
48
+ panel = null;
49
+ mode = 'agent';
50
+ permission = 'ask';
51
+ todos = [];
52
+ liveGeneration = 0;
53
+ children = [];
54
+ cwd;
20
55
  status = '';
21
56
  stopped = false;
22
57
  turnAbort = null;
@@ -24,6 +59,7 @@ export class AgentTui {
24
59
  constructor(client, options) {
25
60
  this.client = client;
26
61
  this.options = options;
62
+ this.cwd = options.cwd ?? process.cwd();
27
63
  ensureAgentKeybindings();
28
64
  }
29
65
  async open() {
@@ -51,15 +87,36 @@ export class AgentTui {
51
87
  return this.screen();
52
88
  }
53
89
  handleInput(data) {
54
- if (data.length > 1 && !data.includes('\x1b')) {
55
- for (const char of data)
56
- this.handleInput(char);
90
+ this.applyInput(data);
91
+ this.touch();
92
+ this.scroll = null;
93
+ }
94
+ applyInput(data) {
95
+ if (this.paste !== null || data.includes('\x1b[200~')) {
96
+ if (!this.composing())
97
+ this.paste = null;
98
+ else
99
+ this.consumePaste(data);
100
+ return;
101
+ }
102
+ const chars = [...data];
103
+ if (chars.length > 1 && !data.includes('\x1b')) {
104
+ for (let index = 0; index < chars.length; index += 1) {
105
+ const char = chars[index] ?? '';
106
+ if (char === '\r' && chars[index + 1] === '\n') {
107
+ this.applyInput('\r');
108
+ index += 1;
109
+ continue;
110
+ }
111
+ this.applyInput(char);
112
+ }
57
113
  return;
58
114
  }
59
115
  const keys = getKeybindings();
60
116
  if (keys.matches(data, 'agent.interrupt')) {
61
117
  if (this.turnAbort && !this.turnAbort.signal.aborted) {
62
118
  this.turnAbort.abort();
119
+ this.queue = [];
63
120
  this.status = '已中断';
64
121
  return;
65
122
  }
@@ -108,28 +165,164 @@ export class AgentTui {
108
165
  this.showChats();
109
166
  return;
110
167
  }
168
+ if (keys.matches(data, 'agent.tool.toggle')) {
169
+ this.toggleFold();
170
+ return;
171
+ }
172
+ const edge = this.composing() ? transcriptEdge(data) : null;
173
+ if (edge) {
174
+ this.scroll = edge;
175
+ return;
176
+ }
177
+ if (this.search && this.composing() && this.editSearch(data))
178
+ return;
179
+ if (this.composing() && keys.matches(data, 'tui.altScreen.search')) {
180
+ this.search = { query: '', index: 0 };
181
+ this.panel = null;
182
+ this.picks = null;
183
+ return;
184
+ }
185
+ if (this.panel && keys.matches(data, 'tui.select.cancel')) {
186
+ this.panel = null;
187
+ return;
188
+ }
189
+ if (this.composing() && keys.matches(data, 'agent.queue.pull')) {
190
+ this.pullQueue();
191
+ return;
192
+ }
193
+ if (this.composing() && keys.matches(data, 'agent.copy.reply')) {
194
+ void this.copyReply();
195
+ return;
196
+ }
197
+ if (this.composing() && keys.matches(data, 'agent.editor')) {
198
+ void this.editDraftExternal();
199
+ return;
200
+ }
111
201
  if (this.ask && keys.matches(data, 'tui.input.submit')) {
112
202
  const requestId = this.ask.requestId;
113
- const answer = this.draft;
114
- this.draft = '';
203
+ const answer = this.buffer.text;
204
+ clearDraft(this.buffer);
115
205
  this.ask = null;
116
206
  void this.client.answerAsk(requestId, { text: answer });
117
207
  return;
118
208
  }
119
- if (keys.matches(data, 'tui.input.newLine')) {
120
- this.draft += '\n';
209
+ if (this.picks && keys.matches(data, 'tui.select.cancel')) {
210
+ this.picks = null;
211
+ return;
212
+ }
213
+ if (this.picks && keys.matches(data, 'tui.input.tab')) {
214
+ this.acceptPick();
215
+ return;
216
+ }
217
+ if (this.picks && keys.matches(data, 'tui.select.confirm')) {
218
+ const pick = this.picks[this.pickIndex];
219
+ if (!pick || this.buffer.text.trim() !== pick.insert.trim()) {
220
+ this.acceptPick();
221
+ return;
222
+ }
223
+ }
224
+ if (this.picks && keys.matches(data, 'tui.select.up')) {
225
+ this.pickIndex = Math.max(0, this.pickIndex - 1);
226
+ this.markPicks();
227
+ return;
228
+ }
229
+ if (this.picks && keys.matches(data, 'tui.select.down')) {
230
+ this.pickIndex = Math.min(this.picks.length - 1, this.pickIndex + 1);
231
+ this.markPicks();
232
+ return;
233
+ }
234
+ if (keys.matches(data, 'tui.editor.deleteCharBackward')
235
+ && this.buffer.text.length === 0
236
+ && this.buffer.cursor === 0
237
+ && this.attachments.length > 0) {
238
+ this.attachments.pop();
239
+ return;
240
+ }
241
+ if (this.composing() && keys.matches(data, 'agent.clipboard.paste')) {
242
+ void this.pasteClipboard();
121
243
  return;
122
244
  }
123
- if (keys.matches(data, 'tui.input.submit')) {
245
+ if (this.browseHistory(data))
246
+ return;
247
+ const edited = editDraft(this.buffer, data);
248
+ if (edited === 'submit') {
124
249
  void this.submit();
125
250
  return;
126
251
  }
127
- if (keys.matches(data, 'tui.editor.deleteCharBackward')) {
128
- this.draft = [...this.draft].slice(0, -1).join('');
252
+ if (edited === 'edited')
253
+ leavePromptHistory(this.prompts);
254
+ this.refreshPicks();
255
+ }
256
+ async pasteClipboard() {
257
+ const read = this.options.readClipboard ?? readSystemClipboard;
258
+ let payload;
259
+ try {
260
+ payload = await read();
261
+ }
262
+ catch (error) {
263
+ void error;
264
+ this.status = '剪贴板不可用';
265
+ this.touch();
129
266
  return;
130
267
  }
131
- if (data.length > 0 && [...data].every((char) => char >= ' ' && char !== '\x7f'))
132
- this.draft += data;
268
+ const bytes = payload.image;
269
+ if (bytes && bytes.length > 0) {
270
+ const saved = await saveClipboardImage(bytes);
271
+ if (!saved) {
272
+ this.status = '剪贴板里的图片格式不支持';
273
+ this.touch();
274
+ return;
275
+ }
276
+ this.attachments.push(saved);
277
+ this.status = `已附加 ${saved.name}`;
278
+ this.touch();
279
+ return;
280
+ }
281
+ if (payload.text) {
282
+ insertText(this.buffer, payload.text);
283
+ this.refreshPicks();
284
+ this.touch();
285
+ }
286
+ }
287
+ browseHistory(data) {
288
+ if (!this.composing())
289
+ return false;
290
+ const keys = getKeybindings();
291
+ const previous = keys.matches(data, 'tui.editor.historyPrevious');
292
+ const next = keys.matches(data, 'tui.editor.historyNext');
293
+ const up = keys.matches(data, 'tui.editor.cursorUp') && onFirstLine(this.buffer.text, this.buffer.cursor);
294
+ const down = keys.matches(data, 'tui.editor.cursorDown')
295
+ && this.prompts.index >= 0
296
+ && onLastLine(this.buffer.text, this.buffer.cursor);
297
+ if (!previous && !next && !up && !down)
298
+ return false;
299
+ const direction = previous || up ? -1 : 1;
300
+ const moved = recallPrompt(this.prompts, this.buffer, direction);
301
+ if (!moved && !previous && !next)
302
+ return false;
303
+ this.picks = null;
304
+ return true;
305
+ }
306
+ composing() {
307
+ return !this.approval && !this.help && !this.chats;
308
+ }
309
+ consumePaste(data) {
310
+ let chunk = data;
311
+ if (this.paste === null) {
312
+ this.paste = '';
313
+ chunk = chunk.replaceAll('\x1b[200~', '');
314
+ }
315
+ this.paste += chunk;
316
+ const end = this.paste.indexOf('\x1b[201~');
317
+ if (end < 0)
318
+ return;
319
+ const text = this.paste.slice(0, end).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
320
+ const rest = this.paste.slice(end + '\x1b[201~'.length);
321
+ this.paste = null;
322
+ if (text.length > 0)
323
+ insertText(this.buffer, text);
324
+ if (rest.length > 0)
325
+ this.applyInput(rest);
133
326
  }
134
327
  screen() {
135
328
  return {
@@ -142,18 +335,145 @@ export class AgentTui {
142
335
  chats: this.chats,
143
336
  readOnly: this.readOnly,
144
337
  help: this.help,
145
- draft: this.draft,
338
+ draft: this.buffer.text,
339
+ cursor: this.buffer.cursor,
146
340
  status: this.status,
341
+ picks: this.pickRows(),
342
+ attachments: this.attachments.map((file) => file.name),
343
+ children: this.children,
344
+ queued: this.queue.map((item) => item.text || item.files.map((file) => file.name).join(' ')),
345
+ search: this.searchBar().label,
346
+ searchFocus: this.searchBar().focus,
347
+ scroll: this.scroll,
348
+ panel: this.panel,
349
+ mode: this.mode,
350
+ permission: this.permission,
351
+ todos: this.todos,
352
+ };
353
+ }
354
+ searchBar() {
355
+ if (!this.search)
356
+ return { label: null, focus: null };
357
+ const hits = searchHits(this.lines, this.search.query);
358
+ const index = hits.length === 0 ? 0 : this.search.index % hits.length;
359
+ return {
360
+ label: searchLabel(this.search.query, hits, index),
361
+ focus: hits[index]?.line ?? null,
147
362
  };
148
363
  }
364
+ editSearch(data) {
365
+ if (!this.search)
366
+ return false;
367
+ const keys = getKeybindings();
368
+ if (keys.matches(data, 'tui.altScreen.search') || keys.matches(data, 'tui.altScreen.searchClose')) {
369
+ this.search = null;
370
+ return true;
371
+ }
372
+ const hits = searchHits(this.lines, this.search.query);
373
+ if (keys.matches(data, 'tui.altScreen.searchNext')) {
374
+ if (hits.length > 0)
375
+ this.search.index = (this.search.index + 1) % hits.length;
376
+ return true;
377
+ }
378
+ if (keys.matches(data, 'tui.altScreen.searchPrevious')) {
379
+ if (hits.length > 0)
380
+ this.search.index = (this.search.index - 1 + hits.length) % hits.length;
381
+ return true;
382
+ }
383
+ if (keys.matches(data, 'tui.editor.deleteCharBackward')) {
384
+ this.search.query = [...this.search.query].slice(0, -1).join('');
385
+ this.search.index = 0;
386
+ return true;
387
+ }
388
+ if ([...data].length === 1 && data >= ' ' && !data.includes('\x1b')) {
389
+ this.search.query += data;
390
+ this.search.index = 0;
391
+ return true;
392
+ }
393
+ return false;
394
+ }
395
+ pullQueue() {
396
+ const item = this.queue.pop();
397
+ if (!item)
398
+ return;
399
+ if (this.buffer.text.length > 0) {
400
+ this.queue.push(item);
401
+ this.status = '输入框里还有字';
402
+ return;
403
+ }
404
+ this.buffer.text = item.text;
405
+ this.buffer.cursor = [...item.text].length;
406
+ this.attachments.push(...item.files);
407
+ this.status = '已取回';
408
+ }
409
+ pickRows() {
410
+ if (!this.picks || this.picks.length === 0)
411
+ return null;
412
+ return this.picks.map((pick, index) => ({ label: pick.label, selected: index === this.pickIndex }));
413
+ }
414
+ markPicks() {
415
+ if (!this.picks)
416
+ return;
417
+ if (this.pickIndex >= this.picks.length)
418
+ this.pickIndex = Math.max(0, this.picks.length - 1);
419
+ }
420
+ refreshPicks() {
421
+ if (!this.composing()) {
422
+ this.picks = null;
423
+ return;
424
+ }
425
+ const slash = slashAt(this.buffer.text, this.buffer.cursor);
426
+ if (slash) {
427
+ this.pickGeneration += 1;
428
+ const commands = completeSlash(slash.query);
429
+ this.picks = commands.length > 0 ? commands : null;
430
+ this.markPicks();
431
+ return;
432
+ }
433
+ const token = mentionAt(this.buffer.text, this.buffer.cursor);
434
+ if (!token) {
435
+ this.picks = null;
436
+ return;
437
+ }
438
+ const generation = ++this.pickGeneration;
439
+ const query = token.query;
440
+ void completeFiles(this.cwd, query).then((files) => {
441
+ if (generation !== this.pickGeneration)
442
+ return;
443
+ if (!mentionAt(this.buffer.text, this.buffer.cursor))
444
+ return;
445
+ this.picks = files.length > 0 ? files : [{ label: '无匹配', insert: '', directory: false }];
446
+ this.markPicks();
447
+ this.touch();
448
+ });
449
+ }
450
+ acceptPick() {
451
+ const pick = this.picks?.[this.pickIndex];
452
+ const token = slashAt(this.buffer.text, this.buffer.cursor) ?? mentionAt(this.buffer.text, this.buffer.cursor);
453
+ if (!pick?.insert || !token) {
454
+ this.picks = null;
455
+ return;
456
+ }
457
+ const next = `${this.buffer.text.slice(0, token.start)}${pick.insert}${this.buffer.text.slice(this.buffer.cursor)}`;
458
+ this.buffer.text = next;
459
+ this.buffer.cursor = token.start + pick.insert.length;
460
+ if (pick.directory)
461
+ this.refreshPicks();
462
+ else
463
+ this.picks = null;
464
+ }
149
465
  showChats() {
150
466
  const index = this.rows.findIndex((chat) => chat.id === this.chatId);
151
467
  this.selected = index >= 0 ? index : 0;
152
468
  this.chats = this.rows.map((chat, row) => ({ ...chat, selected: row === this.selected }));
153
469
  void this.loadChats().then((rows) => {
154
470
  this.rows = rows;
155
- if (this.chats)
156
- this.markSelection();
471
+ if (!this.chats)
472
+ return;
473
+ if (this.selected >= rows.length)
474
+ this.selected = Math.max(0, rows.length - 1);
475
+ this.chats = rows.map((chat, row) => ({ ...chat, selected: row === this.selected }));
476
+ this.touch();
157
477
  });
158
478
  }
159
479
  async loadChats() {
@@ -184,17 +504,31 @@ export class AgentTui {
184
504
  const records = messages.status === 200
185
505
  ? (messages.data.messages ?? [])
186
506
  : [];
187
- this.lines = records.map((message) => ({
188
- kind: message.role === 'user' ? 'user' : 'assistant',
189
- text: message.content ?? '',
190
- }));
507
+ this.todos = [];
508
+ this.lines = historyRows(records).map((row) => {
509
+ if (row.kind !== 'tool')
510
+ return { kind: row.kind, text: row.text };
511
+ const next = todosFromAction(row.action.tool, row.action.arguments, row.action.result);
512
+ if (next)
513
+ this.todos = next;
514
+ return toolLine(row.action);
515
+ });
191
516
  this.help = false;
517
+ this.search = null;
518
+ this.panel = null;
519
+ this.children = [];
520
+ this.picks = null;
521
+ this.attachments = [];
192
522
  this.touch();
523
+ this.armLive(id);
193
524
  }
194
525
  async submit() {
195
- const text = this.draft.trim();
196
- this.draft = '';
197
- if (!text)
526
+ const text = this.buffer.text.trim();
527
+ clearDraft(this.buffer);
528
+ this.picks = null;
529
+ if (text)
530
+ rememberPrompt(this.prompts, text);
531
+ if (!text && this.attachments.length === 0)
198
532
  return;
199
533
  if (text.startsWith('/')) {
200
534
  await this.slash(text);
@@ -204,10 +538,50 @@ export class AgentTui {
204
538
  this.status = '只读 · 另一个进程正在运行';
205
539
  return;
206
540
  }
207
- this.lines.push({ kind: 'user', text });
208
- this.pending = this.runTurn(text);
541
+ if (this.permission === 'read') {
542
+ this.status = '权限 只读';
543
+ this.touch();
544
+ return;
545
+ }
546
+ const files = this.attachments.splice(0);
547
+ if (this.sending) {
548
+ this.queue.push({ text, files });
549
+ this.touch();
550
+ return;
551
+ }
552
+ this.sending = true;
553
+ try {
554
+ await this.sendPrepared(text, files);
555
+ while (!this.stopped && this.queue.length > 0) {
556
+ const next = this.queue.shift();
557
+ if (!next)
558
+ break;
559
+ await this.sendPrepared(next.text, next.files);
560
+ }
561
+ }
562
+ finally {
563
+ this.sending = false;
564
+ this.touch();
565
+ }
566
+ }
567
+ async sendPrepared(text, files) {
568
+ const prepared = await this.prepareOwned(text, files);
569
+ this.lines.push({ kind: 'user', text: prepared.message });
570
+ this.touch();
571
+ this.pending = this.runTurn(prepared.message, prepared.images);
209
572
  await this.pending;
210
573
  }
574
+ async prepareOwned(text, files) {
575
+ const added = this.attachments.splice(0);
576
+ this.attachments = files;
577
+ try {
578
+ return await this.prepareMessage(text);
579
+ }
580
+ finally {
581
+ const failed = this.attachments;
582
+ this.attachments = [...added, ...failed];
583
+ }
584
+ }
211
585
  async slash(text) {
212
586
  const [command, ...rest] = text.slice(1).split(/\s+/);
213
587
  if (command === 'help') {
@@ -215,6 +589,10 @@ export class AgentTui {
215
589
  this.touch();
216
590
  return;
217
591
  }
592
+ if (command === 'attach') {
593
+ await this.attach(rest.join(' '));
594
+ return;
595
+ }
218
596
  if (command === 'clear') {
219
597
  this.lines = [];
220
598
  this.status = '';
@@ -249,31 +627,179 @@ export class AgentTui {
249
627
  this.touch();
250
628
  return;
251
629
  }
630
+ if (command === 'status') {
631
+ const summary = await this.client.request('GET', '/api/v2/usage/summary', undefined);
632
+ this.status = summary.status === 200 ? formatUsage(summary.data) : '用量不可用';
633
+ this.touch();
634
+ return;
635
+ }
636
+ if (command === 'skills') {
637
+ await this.showList('GET', '/api/v2/chat-agents/skills', 'skills', (row) => `技能 ${textField(row, 'name')}${textField(row, 'description') ? ` ${textField(row, 'description')}` : ''}`, '没有技能');
638
+ return;
639
+ }
640
+ if (command === 'mcp') {
641
+ await this.showList('GET', '/api/v2/mcp/servers', 'servers', (row) => `MCP ${textField(row, 'name') || textField(row, 'id')} ${numberField(row, 'toolCount')} 个工具`, '没有 MCP');
642
+ return;
643
+ }
644
+ if (command === 'export') {
645
+ await this.exportChat();
646
+ return;
647
+ }
648
+ if (command === 'compact') {
649
+ await this.compactChat();
650
+ return;
651
+ }
652
+ if (command === 'plan') {
653
+ this.mode = this.mode === 'plan' ? 'agent' : 'plan';
654
+ this.status = this.mode === 'plan' ? '计划模式' : '对话模式';
655
+ this.touch();
656
+ return;
657
+ }
658
+ if (command === 'tasks') {
659
+ await this.showTasks();
660
+ return;
661
+ }
662
+ if (command === 'fork') {
663
+ await this.forkChat();
664
+ return;
665
+ }
666
+ if (command === 'rewind') {
667
+ await this.rewindChat();
668
+ return;
669
+ }
670
+ if (command === 'permissions') {
671
+ this.permission = this.permission === 'ask' ? 'auto' : this.permission === 'auto' ? 'read' : 'ask';
672
+ this.status = this.permission === 'ask' ? '权限 询问' : this.permission === 'auto' ? '权限 本会话自动' : '权限 只读';
673
+ this.touch();
674
+ return;
675
+ }
676
+ if (command === 'copy') {
677
+ await this.copyReply();
678
+ return;
679
+ }
680
+ if (command === 'editor') {
681
+ await this.editDraftExternal();
682
+ return;
683
+ }
252
684
  this.status = `unknown command /${command}`;
253
685
  this.touch();
254
686
  }
255
- async runTurn(text) {
256
- if (!this.chatId) {
257
- const created = await this.client.request('POST', '/api/v2/chats/new', {});
258
- this.chatId = created.data.chatId ?? 'draft';
259
- this.title = this.chatId;
687
+ async attach(target) {
688
+ if (this.readOnly) {
689
+ this.status = '只读 · 另一个进程正在运行';
690
+ this.touch();
691
+ return;
692
+ }
693
+ if (!target) {
694
+ this.status = this.attachments.length > 0
695
+ ? this.attachments.map((file) => file.name).join(' ')
696
+ : '用法 /attach <路径>';
260
697
  this.touch();
698
+ return;
261
699
  }
700
+ const full = path.resolve(this.cwd, target);
701
+ let fileStat;
702
+ try {
703
+ fileStat = await fs.stat(full);
704
+ }
705
+ catch {
706
+ this.status = '找不到文件';
707
+ this.touch();
708
+ return;
709
+ }
710
+ if (!fileStat.isFile()) {
711
+ this.status = '不是文件';
712
+ this.touch();
713
+ return;
714
+ }
715
+ const name = path.basename(full);
716
+ this.attachments.push({ name, path: full });
717
+ this.status = `已附加 ${name}`;
718
+ this.touch();
719
+ }
720
+ async prepareMessage(text) {
721
+ if (this.attachments.length === 0)
722
+ return { message: text, images: [] };
723
+ await this.ensureChat();
724
+ const chatId = this.chatId ?? '';
725
+ const save = this.options.saveAttachments ?? defaultSaveAttachments;
726
+ const saved = await save(chatId, this.attachments);
727
+ const stored = [];
728
+ const kept = [];
729
+ saved.forEach((file, index) => {
730
+ if (file.path && !file.error)
731
+ stored.push(file);
732
+ else {
733
+ const original = this.attachments[index];
734
+ if (original)
735
+ kept.push(original);
736
+ }
737
+ });
738
+ this.attachments = kept;
739
+ if (kept.length > 0) {
740
+ this.status = saved.filter((file) => file.error).map((file) => file.error).join(' ');
741
+ }
742
+ return {
743
+ message: attachmentMessage(text, stored),
744
+ images: stored.filter((file) => isImagePath(file.path)).map((file) => ({ path: file.path, name: file.name })),
745
+ };
746
+ }
747
+ async ensureChat() {
748
+ if (this.chatId)
749
+ return;
750
+ const created = await this.client.request('POST', '/api/v2/chats/new', {});
751
+ this.chatId = created.data.chatId ?? 'draft';
752
+ this.title = this.chatId;
753
+ }
754
+ async runTurn(text, images = []) {
755
+ await this.ensureChat();
756
+ if (!this.chatId)
757
+ return;
262
758
  const controller = new AbortController();
263
759
  this.turnAbort = controller;
760
+ this.children = [];
264
761
  this.status = '';
265
762
  try {
266
- for await (const event of this.client.stream(`/api/v2/chats/${encodeURIComponent(this.chatId)}/send`, { message: text }, controller.signal)) {
763
+ const payload = { message: text };
764
+ if (images.length > 0)
765
+ payload.images = images;
766
+ if (this.mode === 'plan')
767
+ payload.mode = 'plan';
768
+ for await (const event of this.client.stream(`/api/v2/chats/${encodeURIComponent(this.chatId)}/send`, payload, controller.signal)) {
267
769
  this.observe(event, controller.signal);
268
770
  this.touch();
269
771
  }
270
772
  }
271
773
  finally {
774
+ for (const line of this.lines)
775
+ line.streaming = false;
776
+ if (this.children.length > 0) {
777
+ for (const line of childLines(this.children))
778
+ this.lines.push({ kind: 'tree', text: line });
779
+ this.children = [];
780
+ this.touch();
781
+ }
782
+ if (controller.signal.aborted)
783
+ this.queue = [];
272
784
  if (this.turnAbort === controller)
273
785
  this.turnAbort = null;
274
786
  }
275
787
  }
276
788
  observe(event, signal) {
789
+ if (String(event.type) === 'executed_actions' && Array.isArray(event.actions)) {
790
+ this.applyActions(event.actions);
791
+ return;
792
+ }
793
+ if (String(event.type) === 'orchestration_child') {
794
+ this.children = applyChildEvent(this.children, {
795
+ kind: event.kind,
796
+ childId: event.childId,
797
+ task: event.task,
798
+ profile: event.profile,
799
+ depth: event.depth,
800
+ });
801
+ return;
802
+ }
277
803
  if (event.type === 'tool_call') {
278
804
  const payload = event.payload ?? {};
279
805
  const name = typeof payload.name === 'string' ? payload.name : 'tool';
@@ -287,12 +813,26 @@ export class AgentTui {
287
813
  tool.status = '✓';
288
814
  return;
289
815
  }
816
+ if (String(event.type) === 'reasoning' && typeof event.content === 'string') {
817
+ const last = this.lines[this.lines.length - 1];
818
+ if (last?.kind === 'reasoning') {
819
+ last.text = `${last.text ?? ''}${event.content}`;
820
+ last.streaming = true;
821
+ }
822
+ else {
823
+ this.lines.push({ kind: 'reasoning', text: event.content, streaming: true });
824
+ }
825
+ return;
826
+ }
290
827
  if (event.type === 'content' && typeof event.content === 'string') {
291
828
  const last = this.lines[this.lines.length - 1];
292
- if (last?.kind === 'assistant')
829
+ if (last?.kind === 'assistant') {
293
830
  last.text = `${last.text ?? ''}${event.content}`;
294
- else
295
- this.lines.push({ kind: 'assistant', text: event.content });
831
+ last.streaming = true;
832
+ }
833
+ else {
834
+ this.lines.push({ kind: 'assistant', text: event.content, streaming: true });
835
+ }
296
836
  return;
297
837
  }
298
838
  if (event.type === 'error' && !signal.aborted) {
@@ -307,6 +847,11 @@ export class AgentTui {
307
847
  const payload = event.payload;
308
848
  if (!payload.requestId)
309
849
  continue;
850
+ if (this.permission === 'auto') {
851
+ void this.client.decideApproval(payload.requestId, 'allow_for_session');
852
+ this.touch();
853
+ continue;
854
+ }
310
855
  this.approval = {
311
856
  requestId: payload.requestId,
312
857
  toolName: payload.toolName || 'tool',
@@ -321,6 +866,196 @@ export class AgentTui {
321
866
  this.touch();
322
867
  }
323
868
  }
869
+ applyActions(actions) {
870
+ for (const action of actions) {
871
+ if (!action || typeof action !== 'object')
872
+ continue;
873
+ const nextTodos = todosFromAction(action.tool, action.arguments, action.result);
874
+ if (nextTodos)
875
+ this.todos = nextTodos;
876
+ const next = toolLine(action);
877
+ const index = next.id
878
+ ? this.lines.findIndex((line) => line.kind === 'tool' && line.id === next.id)
879
+ : -1;
880
+ if (index >= 0) {
881
+ next.open = this.lines[index]?.open;
882
+ this.lines[index] = next;
883
+ }
884
+ else {
885
+ this.lines.push(next);
886
+ }
887
+ }
888
+ }
889
+ toggleFold() {
890
+ const open = this.lines.find((line) => (line.kind === 'tool' || line.kind === 'reasoning') && line.open);
891
+ if (open) {
892
+ open.open = false;
893
+ return;
894
+ }
895
+ for (let index = this.lines.length - 1; index >= 0; index -= 1) {
896
+ const line = this.lines[index];
897
+ if (line?.kind === 'tool' && line.status !== '…') {
898
+ line.open = true;
899
+ return;
900
+ }
901
+ if (line?.kind === 'reasoning' && (line.text ?? '').length > 0) {
902
+ line.open = true;
903
+ return;
904
+ }
905
+ }
906
+ }
907
+ armLive(chatId) {
908
+ const generation = ++this.liveGeneration;
909
+ void this.pollLive(chatId, generation);
910
+ }
911
+ async pollLive(chatId, generation) {
912
+ if (this.stopped || this.chatId !== chatId || generation !== this.liveGeneration)
913
+ return;
914
+ const live = await this.client.request('GET', `/api/v2/chats/${encodeURIComponent(chatId)}/live-stream`, undefined);
915
+ if (this.stopped || this.chatId !== chatId || generation !== this.liveGeneration)
916
+ return;
917
+ if (live.status !== 200 || !live.data || typeof live.data !== 'object')
918
+ return;
919
+ const data = live.data;
920
+ if (data.active !== true) {
921
+ if (this.status === '运行中')
922
+ this.status = '';
923
+ this.touch();
924
+ return;
925
+ }
926
+ this.status = '运行中';
927
+ if (typeof data.content === 'string' && data.content.length > 0) {
928
+ const streaming = [...this.lines].reverse().find((line) => line.kind === 'assistant' && line.streaming);
929
+ if (streaming)
930
+ streaming.text = data.content;
931
+ else if (!this.lines.some((line) => line.kind === 'assistant' && line.text === data.content)) {
932
+ this.lines.push({ kind: 'assistant', text: data.content, streaming: true });
933
+ }
934
+ }
935
+ if (Array.isArray(data.executedActions))
936
+ this.applyActions(data.executedActions);
937
+ this.touch();
938
+ const wait = this.options.liveIntervalMs ?? 1000;
939
+ await new Promise((resolve) => {
940
+ setTimeout(resolve, wait);
941
+ });
942
+ await this.pollLive(chatId, generation);
943
+ }
944
+ async showList(method, requestPath, key, format, empty) {
945
+ const listed = await this.client.request(method, requestPath, undefined);
946
+ const rows = listed.status === 200 && listed.data && typeof listed.data === 'object'
947
+ ? listed.data[key]
948
+ : [];
949
+ const lines = Array.isArray(rows)
950
+ ? rows.filter((row) => !!row && typeof row === 'object').map(format)
951
+ : [];
952
+ this.panel = lines.length > 0 ? lines : [empty];
953
+ this.touch();
954
+ }
955
+ async showTasks() {
956
+ if (!this.chatId) {
957
+ this.status = '还没有会话';
958
+ this.touch();
959
+ return;
960
+ }
961
+ const listed = await this.client.request('GET', `/api/v2/chats/${encodeURIComponent(this.chatId)}/tasks`, undefined);
962
+ const rows = listed.status === 200 && listed.data && typeof listed.data === 'object'
963
+ ? listed.data.tasks ?? []
964
+ : [];
965
+ this.panel = rows.length > 0
966
+ ? rows.map((task) => `后台 ${task.task ?? ''} ${taskStatus(task.status)}`)
967
+ : ['没有后台任务'];
968
+ this.touch();
969
+ }
970
+ async exportChat() {
971
+ const messages = this.lines
972
+ .filter((line) => line.kind === 'user' || line.kind === 'assistant')
973
+ .map((line) => ({ role: line.kind, content: line.text ?? '' }));
974
+ const file = path.join(this.options.exportDir ?? os.tmpdir(), `${this.chatId ?? 'chat'}.md`);
975
+ await fs.writeFile(file, markdownChat(this.title, messages));
976
+ this.status = `已导出 ${file}`;
977
+ this.touch();
978
+ }
979
+ async compactChat() {
980
+ if (!this.chatId)
981
+ return;
982
+ const result = await this.client.request('POST', `/api/v2/chats/${encodeURIComponent(this.chatId)}/compact`, {});
983
+ const compacted = result.status === 200 ? numberField(result.data, 'compacted') : 0;
984
+ if (result.status !== 200) {
985
+ this.status = '压缩失败';
986
+ this.touch();
987
+ return;
988
+ }
989
+ if (compacted === 0) {
990
+ this.status = '没有可压缩的记录';
991
+ this.touch();
992
+ return;
993
+ }
994
+ await this.openChat(this.chatId);
995
+ this.status = `已压缩 ${compacted} 条`;
996
+ this.touch();
997
+ }
998
+ async forkChat() {
999
+ if (!this.chatId)
1000
+ return;
1001
+ const created = await this.client.request('POST', `/api/v2/chats/${encodeURIComponent(this.chatId)}/fork`, {});
1002
+ const chatId = created.status === 200 ? textField(created.data, 'chatId') : '';
1003
+ if (!chatId) {
1004
+ this.status = '分叉失败';
1005
+ this.touch();
1006
+ return;
1007
+ }
1008
+ await this.openChat(chatId);
1009
+ this.status = '已分叉';
1010
+ this.touch();
1011
+ }
1012
+ async rewindChat() {
1013
+ if (!this.chatId)
1014
+ return;
1015
+ const result = await this.client.request('POST', `/api/v2/chats/${encodeURIComponent(this.chatId)}/rewind`, {});
1016
+ const removed = result.status === 200 ? numberField(result.data, 'removed') : 0;
1017
+ if (result.status !== 200 || removed === 0) {
1018
+ this.status = '没有可回退的回合';
1019
+ this.touch();
1020
+ return;
1021
+ }
1022
+ await this.openChat(this.chatId);
1023
+ this.status = '已回退';
1024
+ this.touch();
1025
+ }
1026
+ async copyReply() {
1027
+ const text = [...this.lines].reverse().find((line) => line.kind === 'assistant' && (line.text ?? '').length > 0)?.text ?? '';
1028
+ if (!text) {
1029
+ this.status = '没有可复制的回答';
1030
+ this.touch();
1031
+ return;
1032
+ }
1033
+ const write = this.options.writeClipboard ?? writeSystemClipboard;
1034
+ try {
1035
+ await write(text);
1036
+ this.status = '已复制';
1037
+ }
1038
+ catch (error) {
1039
+ void error;
1040
+ this.status = '剪贴板不可用';
1041
+ }
1042
+ this.touch();
1043
+ }
1044
+ async editDraftExternal() {
1045
+ const edit = this.options.editInEditor ?? editWithEditor;
1046
+ try {
1047
+ const next = await edit(this.buffer.text);
1048
+ this.buffer.text = next.replace(/\r\n/g, '\n');
1049
+ this.buffer.cursor = [...this.buffer.text].length;
1050
+ leavePromptHistory(this.prompts);
1051
+ this.refreshPicks();
1052
+ }
1053
+ catch (error) {
1054
+ void error;
1055
+ this.status = '编辑器不可用';
1056
+ }
1057
+ this.touch();
1058
+ }
324
1059
  async decide(kind) {
325
1060
  const request = this.approval;
326
1061
  if (!request)
@@ -330,6 +1065,8 @@ export class AgentTui {
330
1065
  await this.client.decideApproval(request.requestId, kind);
331
1066
  }
332
1067
  touch() {
1068
+ if (this.stopped)
1069
+ return;
333
1070
  this.options.onChange?.();
334
1071
  }
335
1072
  exit() {
@@ -356,6 +1093,76 @@ function approvalKind(data) {
356
1093
  return 'abort';
357
1094
  return null;
358
1095
  }
1096
+ async function defaultSaveAttachments(chatId, files) {
1097
+ const saved = await saveAttachmentFiles(chatId, files);
1098
+ return saved.files.map((file) => ({
1099
+ name: file.name,
1100
+ path: file.path,
1101
+ ...(file.error ? { error: file.error } : {}),
1102
+ }));
1103
+ }
1104
+ function toolLine(action) {
1105
+ const name = typeof action.tool === 'string' && action.tool ? action.tool : 'tool';
1106
+ const title = typeof action.view?.title === 'string' ? action.view.title : '';
1107
+ return {
1108
+ kind: 'tool',
1109
+ id: typeof action.id === 'string' ? action.id : undefined,
1110
+ name,
1111
+ args: title && title !== name ? title : toolArgs(action.arguments),
1112
+ status: toolStatus(action),
1113
+ output: toolOutput(action),
1114
+ ...(diffOf(action) ? { diff: diffOf(action) } : {}),
1115
+ };
1116
+ }
1117
+ function diffOf(action) {
1118
+ const result = action.result;
1119
+ if (!result || typeof result !== 'object')
1120
+ return undefined;
1121
+ const diff = result.diff;
1122
+ return typeof diff === 'string' && diff.length > 0 ? diff : undefined;
1123
+ }
1124
+ function textField(value, key) {
1125
+ if (!value || typeof value !== 'object')
1126
+ return '';
1127
+ const field = value[key];
1128
+ return typeof field === 'string' ? field : '';
1129
+ }
1130
+ function numberField(value, key) {
1131
+ if (!value || typeof value !== 'object')
1132
+ return 0;
1133
+ const field = value[key];
1134
+ return typeof field === 'number' && Number.isFinite(field) ? field : 0;
1135
+ }
1136
+ function taskStatus(status) {
1137
+ if (status === 'running')
1138
+ return '进行中';
1139
+ if (status === 'completed')
1140
+ return '完成';
1141
+ if (status === 'failed')
1142
+ return '失败';
1143
+ if (status === 'blocked')
1144
+ return '等待';
1145
+ return status ?? '';
1146
+ }
1147
+ async function editWithEditor(text) {
1148
+ const editor = process.env.VISUAL || process.env.EDITOR;
1149
+ if (!editor)
1150
+ throw new Error('editor unavailable');
1151
+ const file = path.join(os.tmpdir(), `steerable-draft-${randomUUID().slice(0, 8)}.md`);
1152
+ await fs.writeFile(file, text);
1153
+ const code = await new Promise((resolve, reject) => {
1154
+ const child = spawn(editor, [file], { stdio: 'inherit' });
1155
+ child.on('error', reject);
1156
+ child.on('exit', (status) => resolve(status ?? 1));
1157
+ });
1158
+ const next = await fs.readFile(file, 'utf8');
1159
+ await fs.unlink(file).catch((error) => {
1160
+ void error;
1161
+ });
1162
+ if (code !== 0)
1163
+ throw new Error(`editor exited ${code}`);
1164
+ return next;
1165
+ }
359
1166
  function toolArgs(value) {
360
1167
  if (typeof value === 'string')
361
1168
  return value;