zames_pro 1.1.0 → 1.2.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/src/index.js CHANGED
@@ -2,13 +2,13 @@
2
2
  import path from 'path'
3
3
  import fs from 'fs/promises'
4
4
  import { fileURLToPath } from 'url'
5
- import * as readlinePromises from 'readline/promises'
6
5
  import { theme } from './theme.js'
7
6
 
8
7
  import { DeepSeekBrowser } from './browser.js'
9
8
  import { createTools } from './tools.js'
10
9
  import { runAgentLoop } from './agent-loop.js'
11
10
  import { createSpinner } from './spinner.js'
11
+ import { LineEditor } from './input.js'
12
12
  import { loadConfig, CONFIG_PATHS, ZAMES_HOME } from './config.js'
13
13
  import { Transcript } from './transcript.js'
14
14
  import { UndoStore } from './undo.js'
@@ -201,6 +201,10 @@ ${theme.bold('Опции CLI:')}
201
201
  --version, -v показать версию
202
202
  --help, -h эта справка
203
203
 
204
+ ${theme.bold('Пока агент работает:')}
205
+ печать + Enter поставить сообщение в очередь (уйдёт после текущей задачи)
206
+ Esc, Ctrl+C прервать текущую генерацию
207
+
204
208
  ${theme.bold('Обычные команды:')}
205
209
  /new, /clear новый чат (сброс контекста)
206
210
  /chats список последних чатов DeepSeek
@@ -256,46 +260,352 @@ async function cleanTmpDir() {
256
260
  }
257
261
  }
258
262
 
263
+ // Ввод строки в терминале с корректной обработкой вставки (Shift+Insert,
264
+ // Ctrl+Shift+V, правая кнопка мыши и т.п.).
265
+ //
266
+ // Зачем свой ридер вместо readline:
267
+ // 1) readline отправляет строку на ПЕРВОМ переводе строки. При вставке
268
+ // многострочного текста это приводило к немедленной отправке и к тому,
269
+ // что в чат уходила только первая строка. Здесь переводы строк внутри
270
+ // вставки заменяются на пробелы, а отправка происходит только по
271
+ // одиночному нажатию Enter.
272
+ // 2) Включаем bracketed paste mode (\x1b[?2004h): терминал оборачивает
273
+ // вставленный текст в маркеры \x1b[200~ … \x1b[201~, поэтому мы точно
274
+ // знаем, что это вставка, а не набор с клавиатуры, и Enter внутри неё
275
+ // не считается отправкой.
259
276
  async function promptOnce(question) {
260
- process.stdin.resume()
261
- if (process.stdin.isTTY && process.stdin.setRawMode) {
262
- process.stdin.setRawMode(false)
277
+ const stdin = process.stdin
278
+ const stdout = process.stdout
279
+
280
+ // Не-TTY (пайп, редирект): читаем всё до EOF одной строкой.
281
+ if (!stdin.isTTY || !stdin.setRawMode) {
282
+ const chunks = []
283
+ return await new Promise((resolve) => {
284
+ const onData = (b) => chunks.push(b)
285
+ const onEnd = () => {
286
+ stdin.removeListener('data', onData)
287
+ stdin.removeListener('end', onEnd)
288
+ resolve(Buffer.concat(chunks).toString('utf-8'))
289
+ }
290
+ stdin.on('data', onData)
291
+ stdin.on('end', onEnd)
292
+ stdin.resume()
293
+ })
263
294
  }
264
- const rl = readlinePromises.createInterface({
265
- input: process.stdin,
266
- output: process.stdout,
267
- })
268
- try {
269
- return await rl.question(question)
270
- } finally {
271
- rl.close()
295
+
296
+ const wasRaw = stdin.isRaw
297
+ stdin.setRawMode(true)
298
+ stdin.resume()
299
+
300
+ // Bracketed paste включаем/выключаем парно.
301
+ stdout.write('\x1b[?2004h')
302
+ stdout.write(question)
303
+
304
+ let line = ''
305
+ let cursor = 0
306
+ let inPaste = false
307
+ const PASTE_START = '\x1b[200~'
308
+ const PASTE_END = '\x1b[201~'
309
+
310
+ const redraw = () => {
311
+ // Возвращаемся в начало строки, стираем и печатаем заново.
312
+ stdout.write(String.fromCharCode(13))
313
+ stdout.write('\x1b[K')
314
+ stdout.write(question + line)
315
+ // Ставим курсор в нужную позицию.
316
+ const back = line.length - cursor
317
+ if (back > 0) stdout.write('\x1b[' + back + 'D')
272
318
  }
319
+
320
+ return await new Promise((resolve) => {
321
+ const finish = (value, submit) => {
322
+ stdin.removeListener('data', onData)
323
+ stdout.write('\x1b[?2004l')
324
+ if (stdin.setRawMode) stdin.setRawMode(wasRaw || false)
325
+ if (submit) stdout.write(String.fromCharCode(10))
326
+ resolve(value)
327
+ }
328
+
329
+ const insertText = (text) => {
330
+ // Нормализуем переводы строк: они приходят от многострочной вставки,
331
+ // но означают «отправить». Внутри сообщения заменяем на пробел, чтобы
332
+ // вся вставка ушла ОДНИМ сообщением.
333
+ const clean = text
334
+ .replace(/\r\n/g, ' ')
335
+ .replace(/\r/g, ' ')
336
+ .replace(/\n/g, ' ')
337
+ line = line.slice(0, cursor) + clean + line.slice(cursor)
338
+ cursor += clean.length
339
+ }
340
+
341
+ const onData = (buf) => {
342
+ let s = buf.toString('utf-8')
343
+
344
+ // Fallback для терминалов без bracketed paste: если весь чанк — это
345
+ // «голый» перевод строки (один байт), значит нажали Enter → отправляем.
346
+ // Если переводы строк пришли ВМЕСТЕ с другим текстом в одном чанке —
347
+ // это вставка; такие переводы строк не отправляют сообщение, а
348
+ // заменяются на пробелы (см. insertText).
349
+ if (!inPaste && (s === '\r' || s === '\n')) {
350
+ return finish(line, true)
351
+ }
352
+
353
+ while (s.length) {
354
+ if (inPaste) {
355
+ const end = s.indexOf(PASTE_END)
356
+ if (end === -1) {
357
+ insertText(s)
358
+ s = ''
359
+ } else {
360
+ insertText(s.slice(0, end))
361
+ s = s.slice(end + PASTE_END.length)
362
+ inPaste = false
363
+ }
364
+ redraw()
365
+ continue
366
+ }
367
+
368
+ const start = s.indexOf(PASTE_START)
369
+ if (start !== -1) {
370
+ // Всё до маркера обрабатываем как обычный ввод.
371
+ const before = s.slice(0, start)
372
+ s = s.slice(start + PASTE_START.length)
373
+ inPaste = true
374
+ if (before) {
375
+ for (const ch of before) {
376
+ if (ch === '\r' || ch === '\n') { /* внутри вставки — пропускаем */ }
377
+ else insertText(ch)
378
+ }
379
+ redraw()
380
+ }
381
+ continue
382
+ }
383
+
384
+ const ch = s[0]
385
+ const code = s.charCodeAt(0)
386
+ s = s.slice(1)
387
+
388
+ if (ch === '\r' || ch === '\n') {
389
+ // Перевод строки внутри чанка с другим текстом (вставка без
390
+ // bracketed paste): не отправляем, а вставляем пробел.
391
+ insertText(' ')
392
+ redraw()
393
+ continue
394
+ }
395
+ if (code === 3) {
396
+ // Ctrl+C — прерываем ввод.
397
+ return finish('', true)
398
+ }
399
+ if (code === 4) {
400
+ // Ctrl+D — как отправка пустой строки.
401
+ return finish(line, true)
402
+ }
403
+ if (code === 21) {
404
+ // Ctrl+U — стереть строку.
405
+ line = ''
406
+ cursor = 0
407
+ redraw()
408
+ continue
409
+ }
410
+ if (code === 127 || code === 8) {
411
+ // Backspace.
412
+ if (cursor > 0) {
413
+ line = line.slice(0, cursor - 1) + line.slice(cursor)
414
+ cursor--
415
+ redraw()
416
+ }
417
+ continue
418
+ }
419
+ if (ch === '\x1b') {
420
+ // Escape-последовательности (стрелки, Home/End, Delete…).
421
+ const rest = s
422
+ if (rest.startsWith('[D')) {
423
+ if (cursor > 0) cursor--
424
+ s = s.slice(2)
425
+ redraw()
426
+ continue
427
+ }
428
+ if (rest.startsWith('[C')) {
429
+ if (cursor < line.length) cursor++
430
+ s = s.slice(2)
431
+ redraw()
432
+ continue
433
+ }
434
+ if (rest.startsWith('[H') || rest.startsWith('[1~')) {
435
+ cursor = 0
436
+ s = s.slice(rest.startsWith('[1~') ? 3 : 2)
437
+ redraw()
438
+ continue
439
+ }
440
+ if (rest.startsWith('[F') || rest.startsWith('[4~')) {
441
+ cursor = line.length
442
+ s = s.slice(rest.startsWith('[4~') ? 3 : 2)
443
+ redraw()
444
+ continue
445
+ }
446
+ if (rest.startsWith('[3~')) {
447
+ // Delete.
448
+ if (cursor < line.length) {
449
+ line = line.slice(0, cursor) + line.slice(cursor + 1)
450
+ redraw()
451
+ }
452
+ s = s.slice(3)
453
+ continue
454
+ }
455
+ // Прочие ESC-последовательности пропускаем до буквы/тильды.
456
+ const m = s.match(/^\[[0-9;]*[A-Za-z~]/)
457
+ if (m) s = s.slice(m[0].length)
458
+ continue
459
+ }
460
+ if (code < 32) continue // прочие управляющие символы игнорируем
461
+
462
+ insertText(ch)
463
+ redraw()
464
+ }
465
+ }
466
+
467
+ stdin.on('data', onData)
468
+ })
273
469
  }
274
470
 
275
471
  // Слежение за клавиатурой во время работы агента.
276
- // Esc — прервать текущую генерацию (клик Stop в браузере).
277
- function watchEscape(onEscape) {
472
+ //
473
+ // Терминал остаётся живым, пока агент думает:
474
+ // - Esc (или Ctrl+C) — прервать текущую генерацию (клик Stop в браузере);
475
+ // - набор текста + Enter — поставить сообщение в очередь; оно уйдёт
476
+ // агенту сразу после того, как текущая задача завершится
477
+ // (как «отправить во время генерации» в веб-версии DeepSeek).
478
+ //
479
+ // Ввод буферизуется без построчного редактора: набранный текст отображается
480
+ // в строке спиннера через onChange -> ui.setPending(). Enter отправляет буфер
481
+ // в onQueue, пустой Enter игнорируется. Поддержаны Backspace, Ctrl+U, Esc-
482
+ // последовательности (стрелки/Home/End/Delete игнорируются) и bracketed paste.
483
+ function watchInput({ onEscape, onChange, onQueue } = {}) {
278
484
  const stdin = process.stdin
279
- if (!stdin.isTTY) return () => {}
485
+ if (!stdin.isTTY || !stdin.setRawMode) return () => {}
486
+
487
+ // Управляющие символы собираем из кодов: в этом файле нельзя писать
488
+ // «сырые» ESC/CR/LF в строковых литералах (см. AGENTS.md).
489
+ const ESC = String.fromCharCode(27)
490
+ const CSI = String.fromCharCode(91)
491
+ const CR = String.fromCharCode(13)
492
+ const LF = String.fromCharCode(10)
493
+
280
494
  const wasRaw = stdin.isRaw
281
- if (stdin.setRawMode) stdin.setRawMode(true)
495
+ stdin.setRawMode(true)
282
496
  stdin.resume()
283
- const handler = (buf) => {
284
- if (buf.length === 1 && buf[0] === 27) {
285
- onEscape()
497
+ process.stdout.write(ESC + '[?2004h')
498
+
499
+ let buf = ''
500
+ let inPaste = false
501
+ const PASTE_START = ESC + '[200~'
502
+ const PASTE_END = ESC + '[201~'
503
+ const CSI_RE = new RegExp('^' + CSI + '[0-9;]*[A-Za-z~]')
504
+
505
+ const emitChange = () => {
506
+ if (onChange) onChange(buf)
507
+ }
508
+
509
+ // Вставленный текст: переводы строк внутри многострочной вставки
510
+ // трактуем как пробелы — сообщение уходит одной строкой.
511
+ const insert = (text) => {
512
+ buf += text
513
+ .split(CR + LF).join(' ')
514
+ .split(CR).join(' ')
515
+ .split(LF).join(' ')
516
+ }
517
+
518
+ function onData(data) {
519
+ let s = data.toString('utf-8')
520
+
521
+ // Одиночный Esc — прервать генерацию. Стрелки приходят целым чанком
522
+ // и сюда не попадают.
523
+ if (!inPaste && s === ESC) {
524
+ if (onEscape) onEscape()
525
+ return
526
+ }
527
+
528
+ while (s.length) {
529
+ if (inPaste) {
530
+ const end = s.indexOf(PASTE_END)
531
+ if (end === -1) {
532
+ insert(s)
533
+ s = ''
534
+ } else {
535
+ insert(s.slice(0, end))
536
+ s = s.slice(end + PASTE_END.length)
537
+ inPaste = false
538
+ }
539
+ emitChange()
540
+ continue
541
+ }
542
+
543
+ const start = s.indexOf(PASTE_START)
544
+ if (start !== -1) {
545
+ const before = s.slice(0, start)
546
+ s = s.slice(start + PASTE_START.length)
547
+ inPaste = true
548
+ if (before) {
549
+ insert(before)
550
+ emitChange()
551
+ }
552
+ continue
553
+ }
554
+
555
+ const ch = s[0]
556
+ const code = s.charCodeAt(0)
557
+ s = s.slice(1)
558
+
559
+ if (ch === CR || ch === LF) {
560
+ const text = buf.trim()
561
+ buf = ''
562
+ emitChange()
563
+ if (text && onQueue) onQueue(text)
564
+ continue
565
+ }
566
+ if (code === 3) {
567
+ // Ctrl+C — как Esc: прервать генерацию.
568
+ if (onEscape) onEscape()
569
+ continue
570
+ }
571
+ if (code === 21) {
572
+ // Ctrl+U — очистить набранное.
573
+ buf = ''
574
+ emitChange()
575
+ continue
576
+ }
577
+ if (code === 127 || code === 8) {
578
+ // Backspace.
579
+ if (buf) {
580
+ buf = buf.slice(0, -1)
581
+ emitChange()
582
+ }
583
+ continue
584
+ }
585
+ if (ch === ESC) {
586
+ // Escape-последовательность (стрелки, Home/End, Delete) — пропускаем.
587
+ const m = s.match(CSI_RE)
588
+ if (m) s = s.slice(m[0].length)
589
+ continue
590
+ }
591
+ if (code < 32) continue // прочие управляющие — игнорируем
592
+
593
+ buf += ch
594
+ emitChange()
286
595
  }
287
596
  }
597
+
598
+ const handler = (data) => onData(data)
288
599
  stdin.on('data', handler)
289
600
  return () => {
290
601
  stdin.removeListener('data', handler)
602
+ process.stdout.write(ESC + '[?2004l')
291
603
  if (stdin.setRawMode) stdin.setRawMode(wasRaw || false)
292
604
  }
293
605
  }
294
606
 
295
607
  // ---------- workdir resolution ----------
296
608
 
297
- // ---------- workdir resolution ----------
298
-
299
609
  // Агент работает в директории, из которой его запустили (process.cwd()).
300
610
  // Это корень sandbox: инструменты не могут выходить выше него.
301
611
  async function resolveWorkdir() {
@@ -311,47 +621,75 @@ async function resolveWorkdir() {
311
621
  // ---------- task runner ----------
312
622
 
313
623
  async function runTask(browser, tools, taskText, workdir, opts) {
314
- const { transcript, freshChat, sendSystemPrompt } = opts
315
-
316
- const ui = mod.createSpinner()
317
- ui.thinking()
318
-
319
- // Esc во время работы — прервать генерацию.
320
- const stopWatching = watchEscape(() => {
321
- ui.stop()
322
- console.error(theme.warn('⏹ Esc — прерываю генерацию...'))
323
- browser.stopGeneration().catch(() => {})
324
- })
325
-
326
- let finished = false
624
+ const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor } = opts
625
+
626
+ // В TTY-режиме UI — это LineEditor: он владеет вводом (очередь, Esc,
627
+ // Ctrl+C) и рисует статус НАД постоянной строкой ввода. В не-TTY режиме
628
+ // (пайпы) — обычный спиннер + watchInput.
629
+ const ui = editor || mod.createSpinner()
630
+ const stopWatching = editor
631
+ ? () => {}
632
+ : watchInput({
633
+ onEscape: () => {
634
+ ui.stop()
635
+ console.error(theme.warn('⏹ Esc — прерываю генерацию...'))
636
+ browser.stopGeneration().catch(() => {})
637
+ },
638
+ onChange: (text) => ui.setPending(text),
639
+ onQueue: (text) => {
640
+ queue.push(text)
641
+ ui.setPending(null)
642
+ ui.stop()
643
+ console.log(
644
+ theme.user('📨 В очередь (' + queue.length + '): ') + theme.assistant(text),
645
+ )
646
+ ui.thinking()
647
+ },
648
+ })
327
649
 
328
650
  try {
329
- await mod.runAgentLoop({
330
- browser,
331
- tools,
332
- task: taskText,
333
- workdir,
334
- maxIterations: maxIter,
335
- freshChat,
336
- sendSystemPrompt,
337
- transcript,
338
- onThinking: () => ui.thinking(),
339
- onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
340
- onToolResult: (result) => ui.toolResult(result),
341
- onAssistantMessage: (msg) => {
342
- ui.assistant(msg)
343
- finished = true
344
- },
345
- debugLog: debug,
346
- })
651
+ let next = { task: taskText, freshChat, sendSystemPrompt }
652
+
653
+ // Выполняем задачу, затем — всё, что пользователь успел напечатать за
654
+ // время работы. Очередь может пополняться прямо во время дренажа.
655
+ while (true) {
656
+ ui.thinking()
657
+ await mod.runAgentLoop({
658
+ browser,
659
+ tools,
660
+ task: next.task,
661
+ workdir,
662
+ maxIterations: maxIter,
663
+ freshChat: next.freshChat,
664
+ sendSystemPrompt: next.sendSystemPrompt,
665
+ transcript,
666
+ onThinking: () => ui.thinking(),
667
+ onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
668
+ onToolResult: (result) => ui.toolResult(result),
669
+ onAssistantMessage: (msg) => ui.assistant(msg),
670
+ debugLog: debug,
671
+ })
672
+
673
+ if (!queue.length) break
674
+
675
+ const queued = queue.shift()
676
+ ui.stop()
677
+ if (editor) {
678
+ editor.printAbove(theme.user('▶ Из очереди: ') + theme.assistant(queued))
679
+ } else {
680
+ console.log(theme.user('▶ Из очереди: ') + theme.assistant(queued))
681
+ }
682
+ transcript?.log('queued_task', { task: queued })
683
+ next = { task: queued, freshChat: false, sendSystemPrompt: false }
684
+ }
347
685
  } catch (e) {
348
686
  ui.stop()
349
- console.error(theme.error('\n✖ Ошибка агента:'), e.message)
687
+ console.error(theme.error(String.fromCharCode(10) + '✖ Ошибка агента:'), e.message)
350
688
  if (debug) console.error(e.stack)
351
689
  transcript?.log('agent_error', { error: e.message })
352
690
  } finally {
353
691
  stopWatching()
354
- if (!finished) ui.stop()
692
+ ui.stop()
355
693
  }
356
694
  }
357
695
 
@@ -468,7 +806,12 @@ async function main() {
468
806
 
469
807
  console.log(
470
808
  theme.system(
471
- 'Интерактивный режим. Введите задачу. Команды — /help. Выход — /exit.\n',
809
+ 'Интерактивный режим. Введите задачу. Команды — /help. Выход — /exit.',
810
+ ),
811
+ )
812
+ console.log(
813
+ theme.system(
814
+ 'Пока агент работает, можно печатать следующее сообщение — оно уйдёт в очередь (Enter — отправить, Esc — прервать).',
472
815
  ),
473
816
  )
474
817
 
@@ -478,6 +821,10 @@ async function main() {
478
821
  let currentChatId = null
479
822
  let running = true
480
823
 
824
+ // Сообщения, набранные пользователем, пока агент работал. runTask
825
+ // забирает их по одному после завершения текущей задачи.
826
+ const pendingQueue = []
827
+
481
828
  // ---------- review mode state ----------
482
829
  // null — обычный режим.
483
830
  // { snapDir, snapName, originalWorkdir } — мы внутри снапшота, чат уже
@@ -518,18 +865,98 @@ async function main() {
518
865
  }
519
866
  }
520
867
 
868
+ // ---------- ввод: постоянная строка внизу + статус сверху ----------
869
+ // В TTY используем LineEditor: он владеет вводом всё время, показывает
870
+ // статус над строкой ввода и печатает ответы агента ВЫШЕ неё, поэтому
871
+ // набранный текст никогда не затирается выводом. В не-TTY (пайп) —
872
+ // старый promptOnce.
873
+ let editor = null
874
+ let waiter = null
875
+ const takeInput = () => {
876
+ if (pendingQueue.length) return Promise.resolve(pendingQueue.shift())
877
+ return new Promise((resolve) => {
878
+ waiter = resolve
879
+ })
880
+ }
881
+
882
+ const buildPrompt = () => {
883
+ let tail
884
+ if (reviewMode) {
885
+ tail = theme.warn('REVIEW') + theme.dim(':') + theme.dir(reviewMode.snapName)
886
+ } else {
887
+ tail = theme.dir(dirLabel(currentWorkdir))
888
+ }
889
+ return theme.prompt('❯ ') + tail + theme.dim(' › ')
890
+ }
891
+
892
+ if (process.stdin.isTTY && process.stdout.isTTY) {
893
+ editor = new LineEditor({ prompt: buildPrompt() })
894
+ editor.onSubmit = (text) => {
895
+ pendingQueue.push(text)
896
+ if (waiter) {
897
+ const r = waiter
898
+ waiter = null
899
+ r(pendingQueue.shift())
900
+ }
901
+ }
902
+ editor.onEscape = () => {
903
+ if (editor.busy) {
904
+ editor.printAbove(theme.warn('⏹ Esc — прерываю генерацию...'))
905
+ browser.stopGeneration().catch(() => {})
906
+ }
907
+ }
908
+ editor.onCtrlC = () => {
909
+ if (editor.busy) {
910
+ editor.printAbove(theme.warn('⏹ Ctrl+C — прерываю генерацию...'))
911
+ browser.stopGeneration().catch(() => {})
912
+ } else {
913
+ // Не заняты — выходим. Будим takeInput(), чтобы цикл завершился.
914
+ running = false
915
+ if (waiter) {
916
+ const r = waiter
917
+ waiter = null
918
+ r(null)
919
+ }
920
+ }
921
+ }
922
+ editor.start()
923
+
924
+ // Весь вывод команд (console.log/error) должен идти ВЫШЕ строки ввода,
925
+ // иначе он затирает набираемый текст. Пока редактор активен, заворачиваем
926
+ // оба потока в editor.printAbove.
927
+ const origLog = console.log.bind(console)
928
+ const origErr = console.error.bind(console)
929
+ const fmt = (a) =>
930
+ typeof a === 'string' ? a : (() => {
931
+ try {
932
+ return JSON.stringify(a)
933
+ } catch {
934
+ return String(a)
935
+ }
936
+ })()
937
+ console.log = (...a) => editor.printAbove(a.map(fmt).join(' '))
938
+ console.error = (...a) => editor.printAbove(a.map(fmt).join(' '))
939
+ // Сохраняем на случай отладки.
940
+ editor._origLog = origLog
941
+ editor._origErr = origErr
942
+ }
943
+
521
944
  while (running) {
522
945
  let input
523
946
  try {
524
- // Компактное приглашение: золотая стрелка + светло-голубая директория.
525
- let tail
526
- if (reviewMode) {
527
- tail =
528
- theme.warn('REVIEW') + theme.dim(':') + theme.dir(reviewMode.snapName)
947
+ if (editor) {
948
+ editor.setPrompt(buildPrompt())
949
+ input = await takeInput()
529
950
  } else {
530
- tail = theme.dir(dirLabel(currentWorkdir))
951
+ let tail
952
+ if (reviewMode) {
953
+ tail =
954
+ theme.warn('REVIEW') + theme.dim(':') + theme.dir(reviewMode.snapName)
955
+ } else {
956
+ tail = theme.dir(dirLabel(currentWorkdir))
957
+ }
958
+ input = await promptOnce(theme.prompt('❯ ') + tail + theme.dim(' › '))
531
959
  }
532
- input = await promptOnce(theme.prompt('❯ ') + tail + theme.dim(' › '))
533
960
  } catch {
534
961
  break
535
962
  }
@@ -661,10 +1088,6 @@ async function main() {
661
1088
  sendSystemPrompt: false,
662
1089
  transcript,
663
1090
  onThinking: () => ui.thinking(),
664
- onAssistantThought: (text) => {
665
- ui.stop()
666
- console.log(theme.system('\n💭 ' + text.slice(0, 1200) + '\n'))
667
- },
668
1091
  onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
669
1092
  onToolResult: (r) => ui.toolResult(r),
670
1093
  onAssistantMessage: (m) => {
@@ -752,7 +1175,7 @@ async function main() {
752
1175
  // ---------- Обычные команды ----------
753
1176
 
754
1177
  if (lower === '/chats') {
755
- const spin = mod.createSpinner()
1178
+ const spin = editor || mod.createSpinner()
756
1179
  spin.thinking()
757
1180
  try {
758
1181
  lastChats = await browser.listChats(30)
@@ -1004,11 +1427,18 @@ async function main() {
1004
1427
  transcript.log('user_task', { task: trimmed, workdir: currentWorkdir })
1005
1428
 
1006
1429
  const tools = mod.createTools(currentWorkdir, { undo })
1007
- await runTask(browser, tools, trimmed, currentWorkdir, {
1008
- transcript,
1009
- freshChat: freshChatNext,
1010
- sendSystemPrompt: sendSystemPromptNext,
1011
- })
1430
+ if (editor) editor.busy = true
1431
+ try {
1432
+ await runTask(browser, tools, trimmed, currentWorkdir, {
1433
+ transcript,
1434
+ freshChat: freshChatNext,
1435
+ sendSystemPrompt: sendSystemPromptNext,
1436
+ queue: pendingQueue,
1437
+ ui: editor || null,
1438
+ })
1439
+ } finally {
1440
+ if (editor) editor.busy = false
1441
+ }
1012
1442
 
1013
1443
  freshChatNext = false
1014
1444
  sendSystemPromptNext = false
@@ -1018,6 +1448,7 @@ async function main() {
1018
1448
  await saveLastChat(currentChatId)
1019
1449
  }
1020
1450
 
1451
+ if (editor) editor.dispose()
1021
1452
  await browser.close().catch(() => {})
1022
1453
  await mod.closeWeb().catch(() => {})
1023
1454
  transcript.close()