zames_pro 2.73.11 → 2.75.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +37 -7
  3. package/dist/attachments.js +6 -1
  4. package/dist/banner.js +71 -0
  5. package/dist/browser.js +84 -27
  6. package/dist/code-blocks.js +48 -0
  7. package/dist/commands.js +1 -0
  8. package/dist/config-live.js +53 -0
  9. package/dist/config.js +38 -0
  10. package/dist/goal.js +81 -0
  11. package/dist/i18n/common.js +35 -0
  12. package/dist/i18n/config.js +20 -0
  13. package/dist/i18n/deepseek.js +14 -0
  14. package/dist/i18n/help.js +30 -2
  15. package/dist/i18n/qwen.js +4 -0
  16. package/dist/i18n/self-review.js +8 -0
  17. package/dist/i18n/spinner.js +6 -0
  18. package/dist/index.js +487 -65
  19. package/dist/input/layout.js +19 -0
  20. package/dist/input/suggest.js +29 -1
  21. package/dist/input.js +14 -3
  22. package/dist/net-capture.js +18 -0
  23. package/dist/qwen-browser.js +43 -20
  24. package/dist/run-task.js +97 -4
  25. package/dist/theme.js +16 -1
  26. package/dist/tool-summary.js +83 -0
  27. package/dist/tui/ConfigMenu.js +337 -0
  28. package/dist/tui/ConfirmDialog.js +42 -0
  29. package/dist/tui/FullScreenApp.js +113 -0
  30. package/dist/tui/InputLine.js +318 -0
  31. package/dist/tui/LogView.js +48 -0
  32. package/dist/tui/LoginForm.js +83 -0
  33. package/dist/tui/SelectList.js +54 -0
  34. package/dist/tui/StatusBar.js +170 -0
  35. package/dist/tui/TextPrompt.js +41 -0
  36. package/dist/tui/TuiApp.js +29 -0
  37. package/dist/tui/TuiEditor.js +490 -0
  38. package/dist/tui/edit.js +317 -0
  39. package/dist/tui/keys.js +195 -0
  40. package/dist/tui/log-window.js +93 -0
  41. package/dist/tui/login-pure.js +20 -0
  42. package/dist/tui/run-config-menu.js +19 -0
  43. package/dist/tui/run-confirm.js +22 -0
  44. package/dist/tui/run-login.js +43 -0
  45. package/dist/tui/run-select.js +18 -0
  46. package/dist/tui/store.js +104 -0
  47. package/dist/tui/types.js +10 -0
  48. package/dist/tui/useMouseWheel.js +164 -0
  49. package/dist/tui/widgets.js +60 -0
  50. package/package.json +7 -3
  51. package/scripts/coverage-gate.mts +10 -0
  52. package/scripts/inline-status-driver.mts +31 -0
  53. package/scripts/inline-status.tsx +33 -0
  54. package/scripts/mouse-churn.mts +34 -0
  55. package/scripts/qwen-models-smoke.mts +117 -0
  56. package/scripts/render-demo.mts +1 -1
  57. package/scripts/tui-comp-shot.mts +63 -0
  58. package/scripts/tui-components.tsx +62 -0
  59. package/scripts/tui-config-driver.mts +82 -0
  60. package/scripts/tui-config-probe.tsx +74 -0
  61. package/scripts/tui-edit-driver.mts +100 -0
  62. package/scripts/tui-edit-probe.tsx +38 -0
  63. package/scripts/tui-editor-probe.tsx +24 -0
  64. package/scripts/tui-input-driver.mts +54 -0
  65. package/scripts/tui-input-shot.tsx +59 -0
  66. package/scripts/tui-log-driver.mts +35 -0
  67. package/scripts/tui-log-shot.tsx +48 -0
  68. package/scripts/tui-longpaste-driver.mts +56 -0
  69. package/scripts/tui-longpaste.tsx +35 -0
  70. package/scripts/tui-manyselect-shot.mts +34 -0
  71. package/scripts/tui-manyselect.tsx +17 -0
  72. package/scripts/tui-mouse-churn-probe.tsx +28 -0
  73. package/scripts/tui-paste-driver.mts +88 -0
  74. package/scripts/tui-paste-kinds.tsx +76 -0
  75. package/scripts/tui-pastetest.mts +38 -0
  76. package/scripts/tui-probe.tsx +62 -0
  77. package/scripts/tui-repro.tsx +64 -0
  78. package/scripts/tui-resize.mts +70 -0
  79. package/scripts/tui-scroll-driver.mts +53 -0
  80. package/scripts/tui-scroll-probe.tsx +46 -0
  81. package/scripts/tui-shot.mts +106 -0
  82. package/scripts/tui-status-driver.mts +24 -0
  83. package/scripts/tui-status-shot.tsx +27 -0
  84. package/scripts/tui-submit.tsx +57 -0
  85. package/scripts/tui-wheel-driver.mts +61 -0
  86. package/scripts/tui-wheel-probe.tsx +48 -0
  87. package/scripts/tui-widgets-driver.mts +20 -0
  88. package/scripts/tui-widgets.tsx +44 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.75.0] - 2026-10-14
11
+
12
+ ### Added
13
+
14
+ - **tui:** show a one-time hint about mouse selection
15
+ - **tui:** Home/End jump to the top/bottom of the dialog
16
+ - **tui:** mouse wheel scrolls the dialog by default
17
+
18
+ ### Fixed
19
+
20
+ - **tui:** the status row keeps its height (inline caret on the input row)
21
+ - **tui:** the status bar never wraps to a second line
22
+ - **tui:** the caret lands correctly in a CJK buffer
23
+ - **tui:** responsive dividers and wrapping-aware menu window
24
+ - **tui:** log scroll reads the LIVE offset (no stale closure)
25
+ - **tui:** Ctrl+Delete works (kitty CSI-u) and deletes left at end of line
26
+ - **tui:** the mouse wheel no longer scrolls the input history (for good)
27
+ - **attachments:** keep the items array identity across reset()
28
+ - **tui:** the config menu now redraws on change and fits the terminal
29
+ - **tui:** reserve the real number of rows for a wrapping suggest popup
30
+ - **tui:** exact badge width (T0.7) and honor NO_COLOR (T0.11)
31
+ - **tui:** deliver the FULL pasted text to the agent, not the [Pasted lines#N] marker
32
+ - **tui:** separate SELECT/SCROLL mouse modes and stop the header wrapping the panel
33
+
34
+ ## [2.74.0] - 2026-10-13
35
+
36
+ ### Added
37
+
38
+ - **tui:** interactive /chats and /sessions list menus
39
+ - **tui:** task progress bar and tool-result outcome icons (TUI-12)
40
+ - **tui:** in-TUI login and one-time-code forms (TUI-10)
41
+ - **tui:** copy code blocks (Ctrl+Y / /copy-code) and a CodeBlock widget
42
+ - **tui:** Ink-based terminal UI with full-screen mode, rich menus and mouse scrolling
43
+ - **goal:** completion-condition semantics with evaluator; make /config live
44
+
45
+ ### Fixed
46
+
47
+ - **tui:** the mouse wheel now scrolls the DIALOG (not the input history)
48
+ - **tui:** window the full-screen log by rendered ROW, not by entry
49
+ - **tui:** split-safe wheel parser, empty-log hint, selection-aware suggest window
50
+ - **tui:** real visual-row input box height, Ctrl+J, and wheel-vs-history
51
+ - **tui:** multi-line input, growing input box, optional mouse scroll
52
+
53
+ ### Changed
54
+
55
+ - **tui:** drop the task progress bar — keep just the "tasks: n/m" text
56
+ - **tui:** distinct color and a divider for operator messages
57
+
10
58
  ## [2.73.11] - 2026-10-12
11
59
 
12
60
  ### Fixed
package/README.md CHANGED
@@ -255,6 +255,18 @@ The prompt is a small line editor with persistent history:
255
255
  Claude Code's bash mode). The same sandbox guard as the `Bash` tool applies,
256
256
  so a direct command cannot leave the project either.
257
257
 
258
+ Full-screen mode adds dialog navigation, so a long answer can be read and
259
+ selected without leaving the terminal:
260
+
261
+ - mouse wheel — scroll the dialog (never the input history; history is arrow
262
+ keys only). Selecting text needs `Shift`+drag, the standard TUI convention.
263
+ - `PgUp` / `PgDn` — scroll the dialog half a screen.
264
+ - `Home` / `End` — jump to the very top / bottom of the dialog (only while the
265
+ input line is empty; with text typed they move the caret as usual).
266
+ - To copy a LARGE block, use `/export` (writes the whole session as Markdown)
267
+ or the transcript file (`/transcript` shows the path) — the terminal's own
268
+ drag-select cannot reach past one screen.
269
+
258
270
  Set `NO_COLOR=1` to disable colors (a calm default palette is used otherwise).
259
271
 
260
272
  ### Images and files
@@ -375,9 +387,12 @@ Codex CLI:
375
387
  - /compact — ask the active backend to compress the current chat into a handover
376
388
  summary, then open a NEW chat, resend the system prompt and post the summary
377
389
  as the carried-over context. Use it when the context gets long.
378
- - /goal [text|clear] — set a long-lived session goal. It is prepended to every
379
- task message, so the model keeps the big picture across many turns. Stored in
380
- `<project>/.zames-goal` (git-ignored) and restored on the next launch.
390
+ - /goal <condition> — set a **completion condition** (Claude Code parity). The
391
+ agent starts working toward it at once and keeps starting new turns until a
392
+ separate evaluator model judges the condition met. The goal clears itself
393
+ automatically when it is met (or judged impossible), or with `/goal clear`.
394
+ The condition is stored in `<project>/.zames-goal` (git-ignored) and restored
395
+ on the next launch. `/goal` with no argument shows the current state.
381
396
  - /loop <interval> <task> — repeat a task periodically (e.g. `/loop 10m run the
382
397
  tests`). /cron "<min> <hour> <dom> <month> <dow>" <task> — run on a schedule.
383
398
  /jobs [rm <id>|clear] lists and stops them. A fired job is put into the same
@@ -820,6 +835,18 @@ zames
820
835
  Claude Code). Действует та же песочница, что и для инструмента `Bash`, так
821
836
  что прямая команда тоже не может выйти за пределы проекта.
822
837
 
838
+ В полноэкранном режиме добавляется навигация по диалогу, чтобы длинный ответ
839
+ можно было прочитать и выделить не покидая терминал:
840
+
841
+ - колесо мыши — скролл диалога (историю ввода колесо НЕ листает; история —
842
+ только стрелки). Выделение текста — `Shift`+протяжка, стандарт TUI.
843
+ - `PgUp` / `PgDn` — листать диалог на пол-экрана.
844
+ - `Home` / `End` — прыжок в самый верх / низ диалога (только когда строка ввода
845
+ пуста; при набранном тексте они двигают курсор, как обычно).
846
+ - Чтобы скопировать БОЛЬШОЙ кусок, используйте `/export` (пишет всю сессию в
847
+ Markdown) или файл транскрипта (`/transcript` покажет путь) — собственным
848
+ выделением терминала за пределы одного экрана не выделить.
849
+
823
850
  Установите `NO_COLOR=1`, чтобы отключить цвета (иначе используется спокойная
824
851
  палитра по умолчанию).
825
852
 
@@ -942,10 +969,13 @@ zames --task "..." --output-format jsonl \
942
969
  - /compact — попросить активный бэкенд сжать текущий чат в передаточное резюме,
943
970
  затем открыть НОВЫЙ чат, заново отправить системный промпт и положить резюме как
944
971
  перенесённый контекст. Используйте, когда контекст разрастается.
945
- - /goal [text|clear] — задать долгоживущую цель сессии. Она добавляется в
946
- начало каждого сообщения задачи, поэтому модель держит общую картину через
947
- много ходов. Хранится в `<project>/.zames-goal` (git-ignored) и
948
- восстанавливается при следующем запуске.
972
+ - /goal <условие> — задать **условие завершения** (как в Claude Code). Агент
973
+ сразу начинает работать над ним и продолжает запускать новые ходы, пока
974
+ отдельная модель-оценщик не признает условие выполненным. Цель снимается
975
+ автоматически при достижении (или если оценщик признал её недостижимой),
976
+ либо командой `/goal clear`. Условие хранится в `<project>/.zames-goal`
977
+ (git-ignored) и восстанавливается при следующем запуске. `/goal` без
978
+ аргумента показывает текущее состояние.
949
979
  - /loop <interval> <task> — повторять задачу периодически (например,
950
980
  `/loop 10m run the tests`). /cron "<min> <hour> <dom> <month> <dow>" <task> —
951
981
  запуск по расписанию. /jobs [rm <id>|clear] перечисляет и останавливает их.
@@ -141,7 +141,12 @@ export class AttachmentStore {
141
141
  imageCount = 0;
142
142
  fileCount = 0;
143
143
  reset() {
144
- this.items = [];
144
+ // Empty IN PLACE: the Ink tree captures `this.items` by reference at mount
145
+ // (InputLine's `attachments` prop). Reassigning to a new array left the
146
+ // tree holding the OLD one, so after the first submit every new attachment
147
+ // was appended to an array the UI no longer read — `onSubmit` got `[]` and
148
+ // the pasted image/file was silently dropped.
149
+ this.items.length = 0;
145
150
  this.imageCount = 0;
146
151
  this.fileCount = 0;
147
152
  }
package/dist/banner.js ADDED
@@ -0,0 +1,71 @@
1
+ // The startup banner — a rounded box shown once at launch with the app name,
2
+ // version, working directory and active provider. Pure string building (no
3
+ // terminal access), so it is unit-testable and usable by both the TUI and the
4
+ // non-TTY path.
5
+ import { theme } from './theme.js';
6
+ import { visLen } from './input/layout.js';
7
+ /** Visible width of a string (ANSI stripped, wide chars counted as 2). */
8
+ function width(s) {
9
+ return visLen(s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ''));
10
+ }
11
+ /**
12
+ * Draw a rounded box around the given content lines. The box width is the
13
+ * widest line (clamped to `maxWidth`), padded by one space each side. Lines
14
+ * longer than the inner width are truncated with an ellipsis.
15
+ */
16
+ function box(lines, maxWidth, border = '#c8b06a') {
17
+ const inner = Math.max(10, Math.min(maxWidth - 4, Math.max(...lines.map(width)) + 2));
18
+ const top = theme.prompt('╭' + '─'.repeat(inner) + '╮');
19
+ const bottom = theme.prompt('╰' + '─'.repeat(inner) + '╯');
20
+ const body = lines.map((l) => {
21
+ // Truncate to the inner width so a long path never breaks the frame.
22
+ const maxContent = Math.max(0, inner - 2);
23
+ const clipped = clip(l, maxContent);
24
+ const w = width(clipped);
25
+ const pad = Math.max(0, inner - 1 - w);
26
+ return (theme.prompt('│') + ' ' + clipped + ' '.repeat(pad) + theme.prompt('│'));
27
+ });
28
+ void border;
29
+ return [top, ...body, bottom].join('\n');
30
+ }
31
+ /** Truncate an (ANSI-carrying) string to `max` visible columns. */
32
+ function clip(s, max) {
33
+ if (width(s) <= max)
34
+ return s;
35
+ // The banner content has at most a couple of ANSI runs; drop the tail by
36
+ // visible width while keeping escape sequences intact.
37
+ let out = '';
38
+ let visible = 0;
39
+ let i = 0;
40
+ const esc = /\x1b\[[0-9;?]*[A-Za-z]/y;
41
+ while (i < s.length && visible < max - 1) {
42
+ esc.lastIndex = i;
43
+ const m = esc.exec(s);
44
+ if (m && m.index === i) {
45
+ out += m[0];
46
+ i += m[0].length;
47
+ continue;
48
+ }
49
+ const cp = s.codePointAt(i);
50
+ const ch = String.fromCodePoint(cp);
51
+ visible += cp >= 0x1100 ? 2 : 1;
52
+ if (visible > max - 1)
53
+ break;
54
+ out += ch;
55
+ i += ch.length;
56
+ }
57
+ return out + '…';
58
+ }
59
+ /**
60
+ * The full startup banner string. `contentWidth` is the target outer width
61
+ * (the terminal width minus a safety column).
62
+ */
63
+ export function renderStartupBanner(info, contentWidth) {
64
+ const title = theme.assistant(' ' + info.title) + theme.dim(' v' + info.version);
65
+ const dir = theme.system(' cwd: ') + theme.dir(info.workdir);
66
+ const prov = theme.system(' provider: ') + theme.toggleOn(info.provider);
67
+ const lines = [title, dir, prov];
68
+ if (info.hint)
69
+ lines.push(theme.dim(' ' + info.hint));
70
+ return box(lines, contentWidth);
71
+ }
package/dist/browser.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { chromium, } from 'playwright';
2
- import { extractAnswer, extractTokenUsage, dumpNetBody, isFinishedWithoutAnswer, } from './net-capture.js';
2
+ import { extractAnswer, extractTokenUsage, dumpNetBody, isFinishedWithoutAnswer, isFinishedWithAnswer, } from './net-capture.js';
3
3
  import { rewriteFailedAttachments } from './commands.js';
4
4
  import { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
5
5
  import { parseHistoryMessages, extractChatMessages, scrapeChatList, scrapeChatMessages, } from './browser-chats.js';
@@ -64,6 +64,9 @@ export class DeepSeekBrowser {
64
64
  // Called after a successful interactive login so index.ts can persist the
65
65
  // credentials into the user config (the browser does not write config).
66
66
  onAuthSave;
67
+ // TUI-injected interactive prompts; null falls back to readline/askPassword.
68
+ onLoginPrompt;
69
+ onMfaPrompt;
67
70
  _lastSentAt;
68
71
  // Timestamp of the last Continue click. Clicks send a `chat/continue`
69
72
  // request and used to bypass `_waitForSendSlot()` entirely — a run that
@@ -158,7 +161,7 @@ export class DeepSeekBrowser {
158
161
  // must be stripped (DeepSeek's CDN answers 403 to it); computed in the
159
162
  // constructor, overridable by an explicit UA in the options/config.
160
163
  userAgent;
161
- constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, thinkingExtraMs = 2000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, autoContinue = true, continueMinGapMs = 1500, deepThinking = false, webSearch = true, auth, locale, onAuthSave, userAgent, } = {}) {
164
+ constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, thinkingExtraMs = 2000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, autoContinue = true, continueMinGapMs = 1500, deepThinking = false, webSearch = true, auth, locale, onAuthSave, onLoginPrompt, onMfaPrompt, userAgent, } = {}) {
162
165
  this.headless = headless;
163
166
  this.debug = debug;
164
167
  this.channel = channel;
@@ -185,6 +188,8 @@ export class DeepSeekBrowser {
185
188
  };
186
189
  this.locale = locale || DEFAULT_LOCALE;
187
190
  this.onAuthSave = onAuthSave || null;
191
+ this.onLoginPrompt = onLoginPrompt || null;
192
+ this.onMfaPrompt = onMfaPrompt || null;
188
193
  this._lastSentAt = 0;
189
194
  this._lastContinueAt = 0;
190
195
  this._abort = false;
@@ -593,25 +598,41 @@ export class DeepSeekBrowser {
593
598
  return false;
594
599
  }
595
600
  console.log('\n' + theme.warn(this._t('auth.need_login')));
596
- // Username via a normal readline question (echoed).
597
601
  let user = this.auth.username;
598
- if (!user) {
599
- const readline = await import('readline');
600
- const rl = readline.createInterface({
601
- input: process.stdin,
602
- output: process.stdout,
603
- terminal: true,
602
+ let password = this.auth.password;
603
+ if (this.onLoginPrompt) {
604
+ // TUI path: ONE Ink form for username + masked password (a readline
605
+ // question would pause the whole TUI and fight Ink for stdin).
606
+ const res = await this.onLoginPrompt({
607
+ savedUsername: this.auth.username,
608
+ hasSavedPassword: !!this.auth.password,
604
609
  });
605
- user = (await new Promise((resolve) => rl.question(this._t('auth.prompt_login'), (a) => resolve(a)))).trim();
606
- rl.close();
610
+ if (!res)
611
+ return false;
612
+ user = res.username.trim();
613
+ password = res.password;
614
+ }
615
+ else {
616
+ // Username via a normal readline question (echoed).
617
+ if (!user) {
618
+ const readline = await import('readline');
619
+ const rl = readline.createInterface({
620
+ input: process.stdin,
621
+ output: process.stdout,
622
+ terminal: true,
623
+ });
624
+ user = (await new Promise((resolve) => rl.question(this._t('auth.prompt_login'), (a) => resolve(a)))).trim();
625
+ rl.close();
626
+ }
627
+ // Password via raw keystroke reading, echoed as `*` (readline would echo
628
+ // it in clear or swallow the prompt — see askPassword).
629
+ const passPromptKey = hasSaved
630
+ ? 'auth.prompt_password_saved'
631
+ : 'auth.prompt_password';
632
+ const pass = await askPassword(this._t(passPromptKey));
633
+ if (pass)
634
+ password = pass;
607
635
  }
608
- // Password via raw keystroke reading, echoed as `*` (readline would echo
609
- // it in clear or swallow the prompt — see askPassword).
610
- const passPromptKey = hasSaved
611
- ? 'auth.prompt_password_saved'
612
- : 'auth.prompt_password';
613
- const pass = await askPassword(this._t(passPromptKey));
614
- const password = pass || this.auth.password;
615
636
  if (!user || !password)
616
637
  return false;
617
638
  console.log(theme.system(this._t('auth.auto_login')));
@@ -817,15 +838,22 @@ export class DeepSeekBrowser {
817
838
  v: account || '—',
818
839
  })));
819
840
  const readline = await import('readline');
820
- const rl = readline.createInterface({
821
- input: process.stdin,
822
- output: process.stdout,
823
- terminal: true,
824
- });
825
- const code = (await new Promise((resolve) => rl.question(this._t('auth.mfa_prompt'), (a) => resolve(a))))
826
- .trim()
827
- .replace(/\s+/g, '');
828
- rl.close();
841
+ let code;
842
+ if (this.onMfaPrompt) {
843
+ // TUI path: the Ink form reads the code without pausing the whole UI.
844
+ code = ((await this.onMfaPrompt()) ?? '').trim().replace(/\s+/g, '');
845
+ }
846
+ else {
847
+ const rl = readline.createInterface({
848
+ input: process.stdin,
849
+ output: process.stdout,
850
+ terminal: true,
851
+ });
852
+ code = (await new Promise((resolve) => rl.question(this._t('auth.mfa_prompt'), (a) => resolve(a))))
853
+ .trim()
854
+ .replace(/\s+/g, '');
855
+ rl.close();
856
+ }
829
857
  if (!code)
830
858
  return false;
831
859
  await this._setInputText(codeInput, code);
@@ -2006,6 +2034,9 @@ export class DeepSeekBrowser {
2006
2034
  const deadline = Date.now() + timeout;
2007
2035
  let last = '';
2008
2036
  let stable = 0;
2037
+ // Ticks since the SSE body reported FINISHED-with-answer while the DOM
2038
+ // heuristics kept the loop waiting (see the safety net below).
2039
+ let netFinishedTicks = 0;
2009
2040
  while (Date.now() < deadline) {
2010
2041
  if (this._abort || this._askCancelled) {
2011
2042
  return last || '(прервано пользователем)';
@@ -2089,6 +2120,32 @@ export class DeepSeekBrowser {
2089
2120
  // tool call" hang. If generation has clearly ENDED (no Stop button) and
2090
2121
  // the text is stable, accept it (even when it repeats the previous one).
2091
2122
  const sameAsBefore = !!cur && !isNew && normText(cur) === normText(beforeText);
2123
+ // NETWORK-AUTHORITATIVE SAFETY NET. The DOM heuristics above
2124
+ // (`isNew`/`sameAsBefore`/`_isGenerating`) can all be wrong at once on a
2125
+ // resumed chat: the DOM may still show the PREVIOUS answer, the Stop
2126
+ // button may linger, and `beforeText` may equal the fresh answer. The
2127
+ // finish loop then waits out the whole askDeadlineMs while the spinner
2128
+ // runs — the operator sees "generation is running but nothing happens"
2129
+ // (reported from the live agent). The SSE body is the ground truth: if it
2130
+ // says FINISHED **with** a RESPONSE fragment, the turn is over and the
2131
+ // captured answer is complete. Return it after a short grace period so a
2132
+ // genuinely still-streaming answer is never cut (the capture keeps
2133
+ // growing until FINISHED).
2134
+ const netFinishedWithAnswer = !!this._netCapture &&
2135
+ this._netCaptureAt >= this._lastSentAt &&
2136
+ isFinishedWithAnswer(this._netBody);
2137
+ if (netFinishedWithAnswer) {
2138
+ netFinishedTicks++;
2139
+ // Two ticks (~2 x stabilityDelayMs) is enough for the final DOM/capture
2140
+ // settle without risking a truncated answer.
2141
+ if (netFinishedTicks >= 2) {
2142
+ this._askDebug('RETURN net-finished curLen=' + this._netCapture.length);
2143
+ return this._cleanAnswer(this._netCapture) || cur;
2144
+ }
2145
+ }
2146
+ else {
2147
+ netFinishedTicks = 0;
2148
+ }
2092
2149
  if (isNew && cur === last) {
2093
2150
  stable++;
2094
2151
  // `stabilityChecks` / `stabilityDelayMs` are the real knobs here.
@@ -0,0 +1,48 @@
1
+ // PURE extraction of fenced code blocks from a Markdown answer.
2
+ //
3
+ // The TUI cannot offer a real "copy button", so copying a code block is bound
4
+ // to a key / command. That needs the RAW fenced text (the highlighted render is
5
+ // full of ANSI and must NOT be copied), which is what this module returns.
6
+ // Kept pure so it is unit-tested without a terminal.
7
+ /**
8
+ * Extract every fenced code block (``` or ~~~) from `md`, in order. Handles a
9
+ * longer closing fence than the opening one and an unterminated final block
10
+ * (the answer was truncated) — a half block is still copyable.
11
+ */
12
+ export function extractCodeBlocks(md) {
13
+ const lines = String(md ?? '').split(String.fromCharCode(10));
14
+ const out = [];
15
+ let i = 0;
16
+ let n = 0;
17
+ while (i < lines.length) {
18
+ const m = /^(\s*)(`{3,}|~{3,})\s*([^`]*)$/.exec(lines[i]);
19
+ if (!m) {
20
+ i++;
21
+ continue;
22
+ }
23
+ const fence = m[2];
24
+ const marker = fence[0];
25
+ const minLen = fence.length;
26
+ const lang = (m[3] || '').trim();
27
+ i++;
28
+ const body = [];
29
+ let closed = false;
30
+ while (i < lines.length) {
31
+ // A closing fence is the SAME marker, at least as long, with nothing
32
+ // else on the line (whitespace allowed).
33
+ const c = new RegExp('^\\s*' + marker + '{' + minLen + ',}\\s*$');
34
+ if (c.test(lines[i])) {
35
+ closed = true;
36
+ i++;
37
+ break;
38
+ }
39
+ body.push(lines[i]);
40
+ i++;
41
+ }
42
+ if (body.length === 0 && closed)
43
+ continue;
44
+ n++;
45
+ out.push({ lang, text: body.join(String.fromCharCode(10)), index: n });
46
+ }
47
+ return out;
48
+ }
package/dist/commands.js CHANGED
@@ -988,6 +988,7 @@ export const SLASH_COMMANDS = [
988
988
  { name: '/rename', key: 'help.cmd.rename' },
989
989
  { name: '/context', key: 'help.cmd.context' },
990
990
  { name: '/copy', key: 'help.cmd.copy' },
991
+ { name: '/copy-code', key: 'help.cmd.copy_code' },
991
992
  { name: '/chat', key: 'help.cmd.chat' },
992
993
  { name: '/cd', key: 'help.cmd.cd' },
993
994
  { name: '/pwd', key: 'help.cmd.pwd' },
@@ -0,0 +1,53 @@
1
+ // Live application of /config changes to the objects built ONCE at
2
+ // startup (the browser, the undo/checkpoint stores). setConfigRuntime only
3
+ // mutates the config object; these objects COPIED their values at
4
+ // construction, so a change must be pushed onto the live instance too or it
5
+ // is silently a no-op until a restart. The path->target mapping is PURE and
6
+ // unit-tested here; index.ts wires it to the live instances.
7
+ /** config path -> field name on the browser instance. Both backends
8
+ * share these names; a field that is absent on the active backend
9
+ * (e.g. DeepSeek-only thinkingExtraMs on Qwen) is skipped, not thrown. */
10
+ export const BROWSER_LIVE_CONFIG = {
11
+ 'browser.answerTimeoutMs': 'answerTimeoutMs',
12
+ 'browser.askRetries': 'askRetries',
13
+ 'browser.stabilityChecks': 'stabilityChecks',
14
+ 'browser.stabilityDelayMs': 'stabilityDelayMs',
15
+ 'browser.minSendIntervalMs': 'minSendIntervalMs',
16
+ 'browser.thinkingExtraMs': 'thinkingExtraMs',
17
+ 'browser.rateLimitWaitMs': 'rateLimitWaitMs',
18
+ 'browser.maxRateLimitRetries': 'maxRateLimitRetries',
19
+ 'browser.maxIncompleteRetries': 'maxIncompleteRetries',
20
+ 'browser.incompleteWaitMs': 'incompleteWaitMs',
21
+ 'browser.autoContinue': 'autoContinue',
22
+ 'browser.continueMinGapMs': 'continueMinGapMs',
23
+ 'browser.askDeadlineMs': 'askDeadlineMs',
24
+ 'browser.maxAfterToolRetries': 'maxAfterToolRetries',
25
+ };
26
+ /** Push a browser.* change onto the live instance. Returns true when a
27
+ * field was written (false = not a browser timing knob, or absent on this
28
+ * backend). */
29
+ export function applyBrowserLiveConfig(path, value, browser) {
30
+ const field = BROWSER_LIVE_CONFIG[path];
31
+ if (!field || !(field in browser))
32
+ return false;
33
+ browser[field] = value;
34
+ return true;
35
+ }
36
+ /** Push undo.* / checkpoint.* changes onto the live stores. The stores copy
37
+ * their config at construction, so a change must be written to the instance
38
+ * too. */
39
+ export function applyStoreLiveConfig(path, value, stores) {
40
+ if (path === 'undo.enabled' || path === 'undo.maxBackups') {
41
+ if (!stores.undo)
42
+ return false;
43
+ stores.undo[path === 'undo.enabled' ? 'enabled' : 'maxBackups'] = value;
44
+ return true;
45
+ }
46
+ if (path === 'checkpoint.enabled' || path === 'checkpoint.maxBackups') {
47
+ if (!stores.checkpoints)
48
+ return false;
49
+ stores.checkpoints[path === 'checkpoint.enabled' ? 'enabled' : 'maxBackups'] = value;
50
+ return true;
51
+ }
52
+ return false;
53
+ }
package/dist/config.js CHANGED
@@ -89,6 +89,26 @@ export const DEFAULTS = {
89
89
  // N37 statusLine hook. Off by default; edit config.json to set a command.
90
90
  statusLineCommand: '',
91
91
  statusLineIntervalMs: 10_000,
92
+ // Use the Ink/React TUI for the input area (the rich editor). Set to false
93
+ // to fall back to the legacy hand-rolled LineEditor.
94
+ tui: true,
95
+ // Render the TUI in the terminal's alternate screen buffer (FULL-SCREEN
96
+ // mode, like Codex / Claude Code / opencode). This is the default because
97
+ // it is the only mode where a resize cannot leave ghost/duplicate lines:
98
+ // the whole screen belongs to the app and the terminal never reflows our
99
+ // output. The trade-off is that the terminal SCROLLBACK is not available
100
+ // (PgUp/PgDn scroll the in-app log instead). Set to false for the inline
101
+ // layout, which keeps the scrollback but is subject to reflow artifacts.
102
+ alternateScreen: true,
103
+ // In-app MOUSE-WHEEL scrolling of the log. ON by default: the operator
104
+ // asked that the wheel scroll the DIALOG (and it must NEVER scroll the
105
+ // input history — history is arrow keys only). That needs terminal mouse
106
+ // REPORTING (1000/1006) plus alternate-scroll OFF (1007l), so the wheel
107
+ // arrives as SGR events we consume and never as Up/Down arrows. The
108
+ // trade-off is that the terminal no longer does its own drag-select, so
109
+ // selecting text needs SHIFT+DRAG (the standard TUI convention). Set to
110
+ // false to give the terminal the mouse back (native select, wheel inert).
111
+ mouseScroll: true,
92
112
  },
93
113
  // Off by default: a full tsc/eslint run after every edit is slow; opt in.
94
114
  diagnostics: { ...DEFAULT_DIAGNOSTICS },
@@ -162,6 +182,24 @@ export const CONFIG_SCHEMA = [
162
182
  labelKey: 'cfg.f.ui_answerWidth',
163
183
  groupKey: 'cfg.group.ui',
164
184
  },
185
+ {
186
+ path: 'ui.tui',
187
+ type: 'boolean',
188
+ labelKey: 'cfg.f.ui_tui',
189
+ groupKey: 'cfg.group.ui',
190
+ },
191
+ {
192
+ path: 'ui.alternateScreen',
193
+ type: 'boolean',
194
+ labelKey: 'cfg.f.ui_alternateScreen',
195
+ groupKey: 'cfg.group.ui',
196
+ },
197
+ {
198
+ path: 'ui.mouseScroll',
199
+ type: 'boolean',
200
+ labelKey: 'cfg.f.ui_mouseScroll',
201
+ groupKey: 'cfg.group.ui',
202
+ },
165
203
  {
166
204
  path: 'headless',
167
205
  type: 'boolean',
package/dist/goal.js ADDED
@@ -0,0 +1,81 @@
1
+ // Completion-goal semantics (Claude Code parity). A /goal sets a CONDITION
2
+ // that must be satisfied; after every turn a fresh model judges it (MET ->
3
+ // clear, IMPOSSIBLE -> clear, NOT_MET -> keep working). The PURE half -- the
4
+ // evaluator prompt and the verdict parser -- lives here so it is unit-tested
5
+ // without a browser. The `browser.ask()` call and the continuation loop live
6
+ // in run-task.ts.
7
+ /** Hard cap on the condition length, mirroring Claude Code's 4 000-char bound. */
8
+ export const GOAL_CONDITION_CAP = 4000;
9
+ /** Default cap on how many auto-continue turns a goal may spawn. */
10
+ export const GOAL_MAX_TURNS = 25;
11
+ /** Trim a goal condition to the cap. */
12
+ export function clampGoalCondition(condition) {
13
+ const g = String(condition ?? '').trim();
14
+ if (g.length <= GOAL_CONDITION_CAP)
15
+ return g;
16
+ return g.slice(0, GOAL_CONDITION_CAP);
17
+ }
18
+ // The evaluator prompt is AGENT-facing (English), like the system prompt and
19
+ // the compact summary prompt. It runs in the SAME chat as the work so the
20
+ // judge sees the actual transcript (command output, file state), which is
21
+ // what Claude Code's evaluator reads too. A clean, machine-parsable verdict
22
+ // is requested; no tools.
23
+ export function buildEvaluatorPrompt(condition) {
24
+ const NL = String.fromCharCode(10);
25
+ return ('You are a strict COMPLETION EVALUATOR. You do NOT do the work and you do NOT call tools. Read ' +
26
+ 'the conversation above and judge - from what the agent has ALREADY surfaced - whether ' +
27
+ 'the completion condition below is satisfied.' +
28
+ NL +
29
+ NL +
30
+ 'Completion condition:' +
31
+ NL +
32
+ condition +
33
+ NL +
34
+ NL +
35
+ 'Reply with EXACTLY two lines, nothing else:' +
36
+ NL +
37
+ 'VERDICT: MET | NOT_MET | IMPOSSIBLE' +
38
+ NL +
39
+ 'REASON: <one short sentence>' +
40
+ NL +
41
+ NL +
42
+ 'Rules:' +
43
+ NL +
44
+ '- MET only if the condition is demonstrably satisfied: evidence appears in the conversation ' +
45
+ '(command output, test result, file state). A promise to do it later is NOT MET.' +
46
+ NL +
47
+ '- NOT_MET if more work is needed; state briefly what is missing.' +
48
+ NL +
49
+ '- IMPOSSIBLE only if the condition can never be satisfied as written.' +
50
+ NL +
51
+ '- Judge from the conversation only; do not run commands or read files independently.');
52
+ }
53
+ // The continuation directive injected as the next turn's task when the
54
+ // evaluator says NOT_MET. Agent-facing (English). The goal itself is prepended
55
+ // again by withGoal(), so this only adds the verdict + a continue instruction.
56
+ export function buildContinuationDirective(reason) {
57
+ const NL = String.fromCharCode(10);
58
+ return ('The completion goal is NOT yet met. Continue working toward it ' +
59
+ 'until it is satisfied. Do not call respond with a final summary until the ' +
60
+ 'condition is met.' +
61
+ NL +
62
+ (argumentReason(reason) ? 'Evaluator note: ' + reason : ''));
63
+ }
64
+ function argumentReason(reason) {
65
+ return String(reason ?? '').trim().length > 0;
66
+ }
67
+ // Parse the evaluator's reply into a verdict. Tolerant of markdown emphasis
68
+ // and casing (```VErdict: MET``). Returns null when the verdict line is
69
+ // missing, so the caller can fail closed (treat an unparsable answer as
70
+ // "continue", never as "done").
71
+ export function parseGoalVerdict(text) {
72
+ const t = String(text ?? '');
73
+ const m = /VERDICT:\s*(`{0,3})?\s*(MET|NOT_MET|IMPOSSIBLE)/i.exec(t);
74
+ if (!m)
75
+ return null;
76
+ const raw = m[2].toUpperCase();
77
+ const status = raw === 'MET' ? 'met' : raw === 'IMPOSSIBLE' ? 'impossible' : 'not_met';
78
+ const rm = /REASON:\s*(.+)/i.exec(t);
79
+ const reason = rm ? rm[1].replace(/`{2,}/g, '').trim() : '';
80
+ return { status, reason };
81
+ }