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/dist/index.js CHANGED
@@ -14,10 +14,12 @@ import { runAgentLoop } from './agent-loop.js';
14
14
  import { createSubagentRunner } from './subagent.js';
15
15
  import { createSpinner } from './spinner.js';
16
16
  import { LineEditor } from './input.js';
17
+ import { TuiEditor } from './tui/TuiEditor.js';
17
18
  import { promptOnce } from './prompt.js';
18
19
  import { parseImagePaste, extForMime, saveToTemp, guessMime, formatSize, readClipboardImageDetailed, sniffMime, readWindowsClipboardFiles, hasClipboardTool, writeClipboardText, contentHash, } from './attachments.js';
19
20
  import { loadConfig, DEFAULTS, CONFIG_PATHS, ZAMES_HOME, CONFIG_SCHEMA, getByPath, validateConfigValue, writeConfigValue, resetConfigValue, providerAskDeadlineMs, } from './config.js';
20
21
  import { runConfigMenu } from './config-menu.js';
22
+ import { runConfigMenuInk } from './tui/run-config-menu.js';
21
23
  import { formatConfigList } from './config-commands.js';
22
24
  import { handleConfigCommand as runConfigCommand } from './config-command.js';
23
25
  import { translate, normalizeLocale, localeDisplayName, isLocale, } from './i18n.js';
@@ -26,10 +28,15 @@ import { UndoStore } from './undo.js';
26
28
  import { CheckpointStore, pickRecord as pickCheckpoint } from './checkpoint.js';
27
29
  import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
28
30
  import { performCompact } from './compact.js';
31
+ import { clampGoalCondition } from './goal.js';
32
+ import { applyBrowserLiveConfig, applyStoreLiveConfig } from './config-live.js';
29
33
  import { buildHandoffText, saveProviderHandoff, readProviderHandoff, clearProviderHandoff, } from './provider-handoff.js';
30
34
  import { Scheduler, parseInterval, formatInterval, formatJobLine, parseCron, } from './scheduler.js';
31
35
  import { createCliArgs } from './cli-args.js';
32
36
  import { renderMarkdown, setAnswerWidth } from './markdown.js';
37
+ import { extractCodeBlocks } from './code-blocks.js';
38
+ import { contentWidth } from './width.js';
39
+ import { renderStartupBanner } from './banner.js';
33
40
  import { runStatusLineCommand } from './statusline.js';
34
41
  import { parseBacklogNote } from './backlog.js';
35
42
  import { askOperatorConfirm, } from './run-task.js';
@@ -108,10 +115,14 @@ const maxIterArg = getArg('--max-iter', null);
108
115
  // A non-numeric `--max-iter abc` yields NaN, which the loop treats as
109
116
  // "unlimited" (NaN > 0 is false). Fall back to the configured value instead
110
117
  // of silently disabling the cap.
118
+ // The CLI value OVERRIDES the config for the whole run; when no flag is
119
+ // given, the value is read from config LIVE so `/config set maxIterations`
120
+ // takes effect on the next task.
111
121
  const maxIterParsed = maxIterArg !== null ? Number(maxIterArg) : NaN;
112
- const maxIter = Number.isFinite(maxIterParsed)
113
- ? maxIterParsed
114
- : config.maxIterations;
122
+ const maxIterOverride = Number.isFinite(maxIterParsed) ? maxIterParsed : null;
123
+ function currentMaxIter() {
124
+ return maxIterOverride !== null ? maxIterOverride : config.maxIterations;
125
+ }
115
126
  const positional = getPositional();
116
127
  const task = getArg('--task', positional.join(' ').trim() || null);
117
128
  const chatIdArg = getArg('--chat', null);
@@ -426,6 +437,8 @@ ${theme.bold(t('help.while_working'))}
426
437
  ${t('help.key.attach')}
427
438
  ${t('help.key.esc')}
428
439
  ${t('help.key.bang')}
440
+ ${t('help.key.scroll')}
441
+ ${t('help.key.wheel')}
429
442
 
430
443
  ${theme.bold(t('help.commands'))}
431
444
 
@@ -780,7 +793,7 @@ function runTaskDeps() {
780
793
  t,
781
794
  locale: currentLocale,
782
795
  debug,
783
- maxIter,
796
+ maxIter: currentMaxIter(),
784
797
  config,
785
798
  runAgentLoop: mod.runAgentLoop,
786
799
  createSpinner: mod.createSpinner,
@@ -817,8 +830,21 @@ async function main() {
817
830
  console.error(theme.error(t('msg.workdir_error')), e.message);
818
831
  process.exit(1);
819
832
  }
820
- console.log(theme.system(t('msg.working_dir', { v: currentWorkdir })));
821
- console.log(theme.system(t('msg.version', { v: pkgVersion || '?' })));
833
+ // A styled startup banner in an interactive terminal; a plain two-line
834
+ // header when the output is piped (scripts/CI parse it).
835
+ if (process.stdout.isTTY && !jsonMode && config.ui.tui) {
836
+ console.log(renderStartupBanner({
837
+ title: 'zames',
838
+ version: pkgVersion || '?',
839
+ workdir: currentWorkdir,
840
+ provider: providerLabel(config.provider),
841
+ hint: t('msg.banner_hint'),
842
+ }, contentWidth()));
843
+ }
844
+ else {
845
+ console.log(theme.system(t('msg.working_dir', { v: currentWorkdir })));
846
+ console.log(theme.system(t('msg.version', { v: pkgVersion || '?' })));
847
+ }
822
848
  const transcript = new Transcript({
823
849
  dir: config.transcript.dir,
824
850
  enabled: config.transcript.enabled,
@@ -1193,6 +1219,10 @@ async function main() {
1193
1219
  // the typed text is never overwritten by output. In non-TTY (pipe) —
1194
1220
  // the old promptOnce.
1195
1221
  let editor = null;
1222
+ // Re-wire the browser login callbacks after the editor exists / a provider
1223
+ // swap. Assigned where the editor is built (see the login wiring below);
1224
+ // a no-op until then so /provider before setup is harmless.
1225
+ let wireLoginCallbacks = () => { };
1196
1226
  // N37 statusLine hook timer (cleared on exit).
1197
1227
  let statusLineTimer = null;
1198
1228
  let waiter = null;
@@ -1218,19 +1248,36 @@ async function main() {
1218
1248
  };
1219
1249
  await refreshDynamicCommands(currentWorkdir);
1220
1250
  if (process.stdin.isTTY && process.stdout.isTTY) {
1221
- const ed = new LineEditor({
1222
- prompt: buildPrompt(),
1223
- commands: buildSlashCommands(),
1224
- locale: currentLocale,
1225
- });
1251
+ const ed = config.ui.tui
1252
+ ? new TuiEditor({
1253
+ prompt: buildPrompt(),
1254
+ commands: buildSlashCommands(),
1255
+ locale: currentLocale,
1256
+ alternateScreen: config.ui.alternateScreen,
1257
+ })
1258
+ : new LineEditor({
1259
+ prompt: buildPrompt(),
1260
+ commands: buildSlashCommands(),
1261
+ locale: currentLocale,
1262
+ });
1226
1263
  editor = ed;
1227
1264
  ed.setTmpDir(TMP_DIR);
1228
1265
  // Provider badge: show which backend is answering ([Qwen]/[DeepSeek]).
1229
1266
  ed.setProvider(providerLabel(config.provider));
1267
+ // Header for full-screen mode (title / version / working directory).
1268
+ ed.setHeader?.({
1269
+ title: 'zames',
1270
+ version: pkgVersion || '',
1271
+ workdir: currentWorkdir,
1272
+ mouseScroll: config.ui.mouseScroll,
1273
+ });
1230
1274
  // Restore the input history from the previous runs and persist every new
1231
1275
  // entry, so Ctrl+R / arrows can recall older messages after a restart.
1232
1276
  ed.history = loadHistory();
1233
- ed._histIndex = ed.history.length;
1277
+ // The legacy LineEditor keeps its own browse index; the TUI seeds it from
1278
+ // the history itself.
1279
+ if (ed instanceof LineEditor)
1280
+ ed._histIndex = ed.history.length;
1234
1281
  ed.onHistoryChange = (h) => saveHistory(h);
1235
1282
  // The token context right-aligned on the status line (above the input).
1236
1283
  // The editor pulls the number on every render, so it follows the live
@@ -1247,6 +1294,81 @@ async function main() {
1247
1294
  // Toggle icons (🧠 deep thinking, 🌐 web search) before the context
1248
1295
  // counter. The editor pulls the live state on every render.
1249
1296
  ed.onToggleQuery = () => browser.getToggleStatesSync();
1297
+ // Interactive login goes through the Ink form when the TUI owns the
1298
+ // terminal: the browser's readline/askPassword would PAUSE the whole TUI
1299
+ // and fight Ink for stdin. A NAMED function so /provider can re-wire the
1300
+ // freshly created browser (the factory runs before `editor` exists, so the
1301
+ // callbacks cannot be passed there without a forward reference).
1302
+ wireLoginCallbacks = (b) => {
1303
+ // The DeepSeek and Qwen browsers expose DIFFERENT login callback shapes
1304
+ // (DeepSeek: login+password / MFA; Qwen: email -> one-time code), so the
1305
+ // wiring is branched on the provider rather than forced through one
1306
+ // shape.
1307
+ const bb = b;
1308
+ if (config.provider === 'qwen') {
1309
+ bb.onLoginPrompt = async (info) => {
1310
+ if (!config.ui.tui || !(ed instanceof TuiEditor))
1311
+ return null;
1312
+ ed.pause();
1313
+ try {
1314
+ const { runTextPrompt } = await import('./tui/run-login.js');
1315
+ const isCode = info.step === 'code';
1316
+ return await runTextPrompt({
1317
+ t,
1318
+ label: isCode
1319
+ ? t('qwen.login_code_prompt')
1320
+ : t('qwen.login_email_prompt', {
1321
+ def: info.savedUsername
1322
+ ? ' [' + info.savedUsername + ']'
1323
+ : '',
1324
+ }),
1325
+ secret: isCode,
1326
+ });
1327
+ }
1328
+ finally {
1329
+ ed.resume();
1330
+ }
1331
+ };
1332
+ }
1333
+ else {
1334
+ bb.onLoginPrompt = async (info) => {
1335
+ if (!config.ui.tui || !(ed instanceof TuiEditor))
1336
+ return null;
1337
+ ed.pause();
1338
+ try {
1339
+ const { runLoginForm } = await import('./tui/run-login.js');
1340
+ return await runLoginForm({
1341
+ t,
1342
+ initialUsername: config.browser.auth.username,
1343
+ initialPassword: config.browser.auth.password,
1344
+ note: info.hasSavedPassword || info.savedUsername
1345
+ ? t('auth.form.note_saved')
1346
+ : undefined,
1347
+ });
1348
+ }
1349
+ finally {
1350
+ ed.resume();
1351
+ }
1352
+ };
1353
+ }
1354
+ bb.onMfaPrompt = async () => {
1355
+ if (!config.ui.tui || !(ed instanceof TuiEditor))
1356
+ return null;
1357
+ ed.pause();
1358
+ try {
1359
+ const { runTextPrompt } = await import('./tui/run-login.js');
1360
+ return await runTextPrompt({
1361
+ t,
1362
+ label: t('auth.mfa_prompt'),
1363
+ secret: true,
1364
+ });
1365
+ }
1366
+ finally {
1367
+ ed.resume();
1368
+ }
1369
+ };
1370
+ };
1371
+ wireLoginCallbacks(browser);
1250
1372
  // N37 statusLine hook: run the configured command on its own timer (never
1251
1373
  // per-render — a spawn would block typing) and show its first stdout line
1252
1374
  // in the status line. Off when ui.statusLineCommand is empty.
@@ -1363,6 +1485,7 @@ async function main() {
1363
1485
  }) + theme.dim(' (' + res.via + ')')));
1364
1486
  return att;
1365
1487
  };
1488
+ ed.onCopyText = (text) => writeClipboardText(text);
1366
1489
  ed.onSubmit = (text, items) => {
1367
1490
  // While the agent is busy the main command loop is blocked on
1368
1491
  // `await runTask()`, so ONLY commands that make sense without the main
@@ -1473,7 +1596,23 @@ async function main() {
1473
1596
  }
1474
1597
  }
1475
1598
  };
1476
- ed.start();
1599
+ try {
1600
+ ed.start();
1601
+ // One-time hint so the operator knows how selection works: with wheel
1602
+ // scrolling on (the default) the terminal no longer selects natively, so
1603
+ // a drag needs Shift. Printed ONCE, above the input, in full-screen TUI
1604
+ // only (the legacy editor keeps native selection).
1605
+ if (config.ui.tui && config.ui.alternateScreen) {
1606
+ ed.printAbove(theme.dim(t('msg.mouse_hint')));
1607
+ }
1608
+ }
1609
+ catch (e) {
1610
+ // A TUI mount failure must not kill the agent. We surface it and let the
1611
+ // operator retry with `ui.tui: false` if it persists; the imperative
1612
+ // callbacks stay bound to the same editor object, so no state is lost.
1613
+ console.error(theme.warn('UI start failed (set ui.tui=false to use the legacy editor): ' +
1614
+ String(e)));
1615
+ }
1477
1616
  // All command output (console.log/error) must go ABOVE the input line,
1478
1617
  // otherwise it overwrites the text being typed. While the editor is
1479
1618
  // active, we wrap both streams in ed.printAbove.
@@ -1560,6 +1699,63 @@ async function main() {
1560
1699
  editor.refreshStatus();
1561
1700
  }
1562
1701
  }
1702
+ // Browser TIMING knobs (timeouts, retries, the send throttle) are COPIED
1703
+ // by the browser at construction, so push them onto the live instance.
1704
+ // (deepThinking/webSearch are handled above because they also repaint the
1705
+ // status icons.)
1706
+ if (path.startsWith('browser.')) {
1707
+ const b = browser;
1708
+ if (applyBrowserLiveConfig(path, value, b)) {
1709
+ // minSendIntervalMs feeds the send countdown the UI renders.
1710
+ if (editor)
1711
+ editor.refreshStatus();
1712
+ }
1713
+ }
1714
+ // The undo/checkpoint stores also copy their config at construction.
1715
+ applyStoreLiveConfig(path, value, {
1716
+ undo: undo,
1717
+ checkpoints: checkpoints,
1718
+ });
1719
+ // Qwen timing knobs feed the QwenBrowser constructor. Its LIVE fields use
1720
+ // the SAME names, so push them there when the active backend is Qwen (a
1721
+ // no-op on DeepSeek, which has no qwen-only knobs).
1722
+ if (path === 'qwen.minSendIntervalMs' || path === 'qwen.answerTimeoutMs') {
1723
+ const b = browser;
1724
+ if (config.provider === 'qwen') {
1725
+ const field = path === 'qwen.minSendIntervalMs'
1726
+ ? 'minSendIntervalMs'
1727
+ : 'answerTimeoutMs';
1728
+ if (field in b) {
1729
+ b[field] = value;
1730
+ if (editor)
1731
+ editor.refreshStatus();
1732
+ }
1733
+ }
1734
+ }
1735
+ // Switching the provider from /config cannot be applied live (it needs a
1736
+ // new browser); tell the operator instead of silently doing nothing.
1737
+ if (path === 'provider') {
1738
+ const line = t('cfg.provider_restart');
1739
+ if (editor)
1740
+ editor.printAbove(theme.warn(line));
1741
+ else
1742
+ console.log(theme.warn(line));
1743
+ }
1744
+ // headless is fixed at launch (the browser is already open); a /config set
1745
+ // only takes effect on the next run.
1746
+ if (path === 'headless') {
1747
+ const line = t('cfg.restart_required');
1748
+ if (editor)
1749
+ editor.printAbove(theme.dim(line));
1750
+ else
1751
+ console.log(theme.dim(line));
1752
+ }
1753
+ // transcript.enabled: the Transcript copies the flag at construction. A
1754
+ // false -> true flip cannot open a NEW stream mid-run, but true -> false
1755
+ // can (and should) stop writing immediately.
1756
+ if (path === 'transcript.enabled' && value === false) {
1757
+ transcript.enabled = false;
1758
+ }
1563
1759
  }
1564
1760
  // Apply `/thinking` / `/web` immediately: flip the browser's DESIRED state
1565
1761
  // and the status icons, and persist to config (so it survives a restart).
@@ -1598,11 +1794,13 @@ async function main() {
1598
1794
  // Handle a `/goal` command shared by the main loop and the live interception
1599
1795
  // (so a goal can be set even while the agent is busy — it only touches a
1600
1796
  // variable and a file, never the chat). Output goes above the input line
1601
- // when the editor is active, otherwise to stdout.
1797
+ // when the editor is active, otherwise to stdout. Returns which subcommand
1798
+ // ran so the main loop can START A TURN when a goal is freshly set (Claude
1799
+ // Code parity: setting a goal begins working toward it immediately).
1602
1800
  async function handleGoal(input) {
1603
1801
  const g = mod.commands.parseGoalCommand(input);
1604
1802
  if (!g)
1605
- return;
1803
+ return null;
1606
1804
  const emit = (line) => {
1607
1805
  if (editor)
1608
1806
  editor.printAbove(line);
@@ -1613,16 +1811,19 @@ async function main() {
1613
1811
  sessionGoal = null;
1614
1812
  await saveGoal(currentWorkdir, null);
1615
1813
  emit(theme.system(t('goal.cleared')));
1814
+ return 'cleared';
1616
1815
  }
1617
1816
  else if (g.sub === 'show') {
1618
1817
  emit(sessionGoal
1619
1818
  ? theme.system(t('goal.current', { goal: sessionGoal }))
1620
1819
  : theme.dim(t('goal.none')));
1820
+ return 'shown';
1621
1821
  }
1622
1822
  else {
1623
- sessionGoal = g.goal;
1624
- await saveGoal(currentWorkdir, g.goal);
1625
- emit(theme.system(t('goal.set', { goal: g.goal })));
1823
+ sessionGoal = clampGoalCondition(g.goal);
1824
+ await saveGoal(currentWorkdir, sessionGoal);
1825
+ emit(theme.system(t('goal.set', { goal: sessionGoal })));
1826
+ return 'set';
1626
1827
  }
1627
1828
  }
1628
1829
  function configSetRaw(field, raw) {
@@ -1650,19 +1851,27 @@ async function main() {
1650
1851
  return;
1651
1852
  }
1652
1853
  // The menu draws directly to stdout and reads keys itself. So that its
1653
- // output doesn't overlap the LineEditor's permanent input line, we "pause"
1854
+ // output doesn't overlap the editor's permanent input line, we "pause"
1654
1855
  // the editor for the duration of the menu and restore it afterwards.
1655
1856
  if (editor)
1656
1857
  editor.pause();
1858
+ const menuOpts = {
1859
+ fields: CONFIG_SCHEMA,
1860
+ t,
1861
+ get: (path) => getByPath(config, path),
1862
+ getDefault: (path) => getByPath(DEFAULTS, path),
1863
+ set: (field, raw) => configSetRaw(field, raw),
1864
+ reset: (field) => configResetField(field),
1865
+ };
1657
1866
  try {
1658
- await runConfigMenu({
1659
- fields: CONFIG_SCHEMA,
1660
- t,
1661
- get: (path) => getByPath(config, path),
1662
- getDefault: (path) => getByPath(DEFAULTS, path),
1663
- set: (field, raw) => configSetRaw(field, raw),
1664
- reset: (field) => configResetField(field),
1665
- });
1867
+ // Prefer the Ink menu (nicer, resize-aware) when the TUI is enabled;
1868
+ // fall back to the legacy hand-rolled menu otherwise.
1869
+ if (config.ui.tui) {
1870
+ await runConfigMenuInk(menuOpts);
1871
+ }
1872
+ else {
1873
+ await runConfigMenu(menuOpts);
1874
+ }
1666
1875
  }
1667
1876
  catch (e) {
1668
1877
  console.error(theme.error(String(e.message || e)));
@@ -1690,9 +1899,114 @@ async function main() {
1690
1899
  projectConfigPath: CONFIG_PATHS.PROJECT_CONFIG,
1691
1900
  });
1692
1901
  }
1902
+ // Open a chat by its ChatInfo and print the restored dialogue. Shared by
1903
+ // /resume and the interactive /chats list, so both paths behave identically
1904
+ // (todos reloaded, prompt-resend decision, transcript event).
1905
+ const openResumedChat = async (pick) => {
1906
+ if (editor)
1907
+ editor.lock(t('msg.input_locked'));
1908
+ try {
1909
+ await browser.openChat(pick.id);
1910
+ currentChatId = pick.id;
1911
+ freshChatNext = false;
1912
+ sendSystemPromptNext = promptOnResume();
1913
+ saveLastChat(pick.id, currentWorkdir, pick.title);
1914
+ transcript.log('resume_chat', { id: pick.id, title: pick.title });
1915
+ if (editor)
1916
+ editor.clearScreen();
1917
+ console.log(theme.system(t('chats.opening', { v: pick.title })));
1918
+ console.log(theme.assistant(t('msg.chat_opened')) +
1919
+ theme.system(sendSystemPromptNext
1920
+ ? String.fromCharCode(10) + t('chats.prompt_will_resend')
1921
+ : String.fromCharCode(10) + t('chats.prompt_no_resend')));
1922
+ loadSessionTodos(pick.id);
1923
+ await printRestoredHistory(browser, editor, pick.id);
1924
+ }
1925
+ catch (e) {
1926
+ console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
1927
+ }
1928
+ finally {
1929
+ if (editor)
1930
+ editor.unlock();
1931
+ }
1932
+ };
1933
+ // Show the recent chats in an Ink SelectList and open the chosen one.
1934
+ // Returns true when the operator picked a chat (the caller then skips the
1935
+ // text listing). Only called when the TUI owns the terminal.
1936
+ const pickChatInteractively = async (chats) => {
1937
+ if (!editor)
1938
+ return false;
1939
+ editor.pause();
1940
+ let picked = null;
1941
+ try {
1942
+ const { selectListInk } = await import('./tui/run-select.js');
1943
+ picked = await selectListInk({
1944
+ title: t('chats.pick_title'),
1945
+ items: chats.map((c, i) => ({
1946
+ value: c.id,
1947
+ label: String(i + 1).padStart(2, ' ') + '. ' + c.title,
1948
+ detail: c.id.slice(0, 8) + '…',
1949
+ })),
1950
+ });
1951
+ }
1952
+ catch {
1953
+ picked = null;
1954
+ }
1955
+ finally {
1956
+ editor.resume();
1957
+ }
1958
+ if (!picked)
1959
+ return false;
1960
+ const pick = chats.find((c) => c.id === picked);
1961
+ if (!pick)
1962
+ return false;
1963
+ await openResumedChat(pick);
1964
+ return true;
1965
+ };
1966
+ // Show the saved SESSIONS in an Ink SelectList and resume the chosen one.
1967
+ // Sessions carry the full chat id (unlike the /chats sidebar), so this is
1968
+ // the safe resume path; it reuses openResumedChat for identical behavior.
1969
+ const pickSessionInteractively = async (sessions) => {
1970
+ if (!editor)
1971
+ return false;
1972
+ editor.pause();
1973
+ let picked = null;
1974
+ try {
1975
+ const { selectListInk } = await import('./tui/run-select.js');
1976
+ picked = await selectListInk({
1977
+ title: t('sessions.pick_title'),
1978
+ items: sessions.map((s, i) => ({
1979
+ value: s.id,
1980
+ label: String(i + 1).padStart(2, ' ') +
1981
+ '. ' +
1982
+ (s.title || s.id.slice(0, 8)),
1983
+ detail: s.id.slice(0, 8) +
1984
+ '…' +
1985
+ (s.workdir ? ' [' + dirLabel(s.workdir) + ']' : ''),
1986
+ })),
1987
+ });
1988
+ }
1989
+ catch {
1990
+ picked = null;
1991
+ }
1992
+ finally {
1993
+ editor.resume();
1994
+ }
1995
+ if (!picked)
1996
+ return false;
1997
+ const pick = sessions.find((s) => s.id === picked);
1998
+ if (!pick)
1999
+ return false;
2000
+ await openResumedChat({ id: pick.id, title: pick.title || '' });
2001
+ return true;
2002
+ };
1693
2003
  while (running) {
1694
2004
  let input;
1695
2005
  let inputAttachments = [];
2006
+ // Set by `/goal <condition>` to fall through into a task turn with this
2007
+ // text instead of returning to the prompt (Claude Code starts a turn the
2008
+ // moment a goal is set). Reset every iteration because it is declared here.
2009
+ let taskOverride = null;
1696
2010
  try {
1697
2011
  if (editor) {
1698
2012
  editor.setPrompt(buildPrompt());
@@ -1898,7 +2212,7 @@ async function main() {
1898
2212
  tools,
1899
2213
  task: focus,
1900
2214
  workdir: snapRoot,
1901
- maxIterations: maxIter,
2215
+ maxIterations: currentMaxIter(),
1902
2216
  freshChat: false,
1903
2217
  sendSystemPrompt: false,
1904
2218
  transcript,
@@ -2003,6 +2317,15 @@ async function main() {
2003
2317
  console.log(theme.system(t('chats.none_hint')));
2004
2318
  }
2005
2319
  else {
2320
+ // In the TUI, open a real SELECT LIST (like /model) instead of
2321
+ // dumping text: pick a chat and it opens directly. The text path
2322
+ // below stays for non-TTY / the legacy editor.
2323
+ const canPick = editor && 'isTui' in editor && config.ui.tui && !filter;
2324
+ if (canPick && editor) {
2325
+ const openChat = await pickChatInteractively(shown);
2326
+ if (openChat)
2327
+ continue;
2328
+ }
2006
2329
  console.log(theme.system(t('chats.recent')));
2007
2330
  // The number is the index in the FULL list, so `/resume <n>` keeps
2008
2331
  // pointing at the same chat even when a filter narrows the view.
@@ -2044,33 +2367,7 @@ async function main() {
2044
2367
  console.error(theme.error(t('chats.no_n', { n, total: lastChats.length })));
2045
2368
  continue;
2046
2369
  }
2047
- if (editor)
2048
- editor.lock(t('msg.input_locked'));
2049
- try {
2050
- await browser.openChat(pick.id);
2051
- currentChatId = pick.id;
2052
- freshChatNext = false;
2053
- sendSystemPromptNext = promptOnResume();
2054
- saveLastChat(pick.id, currentWorkdir, pick.title);
2055
- transcript.log('resume_chat', { id: pick.id, title: pick.title });
2056
- // Resume on a clean screen so the restored dialogue is readable.
2057
- if (editor)
2058
- editor.clearScreen();
2059
- console.log(theme.system(t('chats.opening', { v: pick.title })));
2060
- console.log(theme.assistant(t('msg.chat_opened')) +
2061
- theme.system(sendSystemPromptNext
2062
- ? String.fromCharCode(10) + t('chats.prompt_will_resend')
2063
- : String.fromCharCode(10) + t('chats.prompt_no_resend')));
2064
- loadSessionTodos(pick.id);
2065
- await printRestoredHistory(browser, editor, pick.id);
2066
- }
2067
- catch (e) {
2068
- console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
2069
- }
2070
- finally {
2071
- if (editor)
2072
- editor.unlock();
2073
- }
2370
+ await openResumedChat(pick);
2074
2371
  continue;
2075
2372
  }
2076
2373
  if (lower === '/provider' || lower.startsWith('/provider ')) {
@@ -2195,6 +2492,9 @@ async function main() {
2195
2492
  // launch failed, `browser` would point at a dead instance and every
2196
2493
  // later command would break.
2197
2494
  const nextBrowser = createProviderBrowser(arg);
2495
+ // Wire the Ink login BEFORE waitForLogin(), or the new backend would
2496
+ // fall back to readline and pause the TUI for its sign-in.
2497
+ wireLoginCallbacks(nextBrowser);
2198
2498
  await nextBrowser.launch();
2199
2499
  await nextBrowser.waitForLogin();
2200
2500
  const oldBrowser = browser;
@@ -2248,11 +2548,46 @@ async function main() {
2248
2548
  const models = qb.listModels ? await qb.listModels() : [];
2249
2549
  if (!models.length) {
2250
2550
  console.log(theme.system(t('qwen.model_list', { v: '(none)' })));
2551
+ continue;
2251
2552
  }
2252
- else {
2253
- const lines = models.map((m) => ` ${m.id} — ${m.name}`).join(NL);
2254
- console.log(theme.system(t('qwen.model_list', { v: lines })));
2553
+ // In the TUI, open a proper SELECT LIST instead of dumping text.
2554
+ if (editor && 'isTui' in editor && config.ui.tui) {
2555
+ if (editor.pause)
2556
+ editor.pause();
2557
+ let picked;
2558
+ try {
2559
+ const { selectListInk } = await import('./tui/run-select.js');
2560
+ picked = await selectListInk({
2561
+ title: t('qwen.model_pick_title'),
2562
+ initial: config.qwen.model || models[0].id,
2563
+ items: models.map((m) => ({
2564
+ value: m.id,
2565
+ label: m.name || m.id,
2566
+ detail: m.id === config.qwen.model ? '← текущая' : m.id,
2567
+ })),
2568
+ });
2569
+ }
2570
+ catch {
2571
+ picked = null;
2572
+ }
2573
+ finally {
2574
+ if (editor.resume)
2575
+ editor.resume();
2576
+ }
2577
+ if (picked) {
2578
+ config.qwen.model = picked;
2579
+ if (typeof qb.model === 'string')
2580
+ qb.model = picked;
2581
+ try {
2582
+ writeConfigValue('home', 'qwen.model', picked);
2583
+ }
2584
+ catch { }
2585
+ console.log(theme.system(t('qwen.model_set', { v: picked })));
2586
+ }
2587
+ continue;
2255
2588
  }
2589
+ const lines = models.map((m) => ` ${m.id} — ${m.name}`).join(NL);
2590
+ console.log(theme.system(t('qwen.model_list', { v: lines })));
2256
2591
  continue;
2257
2592
  }
2258
2593
  config.qwen.model = arg;
@@ -2294,6 +2629,14 @@ async function main() {
2294
2629
  console.log(theme.system(t('sessions.none')));
2295
2630
  }
2296
2631
  else {
2632
+ // In the TUI, open a SELECT LIST and resume the chosen session
2633
+ // directly (same behavior as /chats), instead of only listing ids.
2634
+ const canPick = editor && 'isTui' in editor && config.ui.tui && !filter;
2635
+ if (canPick && editor) {
2636
+ const opened = await pickSessionInteractively(all);
2637
+ if (opened)
2638
+ continue;
2639
+ }
2297
2640
  all.forEach((s, i) => {
2298
2641
  const n = String(i + 1).padStart(2, ' ');
2299
2642
  const mark = s.id === currentChatId ? theme.user(' *') : '';
@@ -2377,8 +2720,20 @@ async function main() {
2377
2720
  continue;
2378
2721
  }
2379
2722
  if (lower === '/goal' || lower.startsWith('/goal ')) {
2380
- await handleGoal(trimmed);
2381
- continue;
2723
+ const sub = await handleGoal(trimmed);
2724
+ // Claude Code parity: setting a goal STARTS a turn immediately, with the
2725
+ // condition itself as the directive (no separate prompt needed). The
2726
+ // completion loop in runTask() then keeps working until the evaluator
2727
+ // reports MET/IMPOSSIBLE. We fall through to the regular task path with
2728
+ // an override task text instead of returning to the prompt.
2729
+ if (sub === 'set' && sessionGoal) {
2730
+ taskOverride =
2731
+ 'Work toward this completion goal until it is satisfied. ' +
2732
+ 'Do not call respond with a final summary until the condition is met.';
2733
+ }
2734
+ else {
2735
+ continue;
2736
+ }
2382
2737
  }
2383
2738
  if (lower === '/loop' || lower.startsWith('/loop ')) {
2384
2739
  const rest = trimmed.slice('/loop'.length).trim();
@@ -2662,7 +3017,7 @@ async function main() {
2662
3017
  tools: initTools,
2663
3018
  task: initTask,
2664
3019
  workdir: currentWorkdir,
2665
- maxIterations: maxIter,
3020
+ maxIterations: currentMaxIter(),
2666
3021
  freshChat: freshChatNext,
2667
3022
  sendSystemPrompt: sendSystemPromptNext,
2668
3023
  transcript,
@@ -2761,7 +3116,7 @@ async function main() {
2761
3116
  console.log(theme.system(t('status.dev', {
2762
3117
  v: devMode ? t('common.on') : t('common.off'),
2763
3118
  })));
2764
- console.log(theme.system(t('status.max_iter', { v: maxIter })));
3119
+ console.log(theme.system(t('status.max_iter', { v: currentMaxIter() })));
2765
3120
  console.log(theme.system(t('status.headless', { v: headless ? yes : no })));
2766
3121
  console.log(theme.system(t('status.debug', { v: debug ? yes : no })));
2767
3122
  const statusToggles = browser.getToggleStatesSync();
@@ -2848,6 +3203,53 @@ async function main() {
2848
3203
  }
2849
3204
  continue;
2850
3205
  }
3206
+ if (lower === '/copy-code' || lower.startsWith('/copy-code ')) {
3207
+ const arg = trimmed.slice('/copy-code'.length).trim();
3208
+ const blocks = extractCodeBlocks(lastAssistantMessage ?? '');
3209
+ if (!blocks.length) {
3210
+ console.log(theme.dim(t('copy.no_code')));
3211
+ continue;
3212
+ }
3213
+ if (arg === 'list') {
3214
+ console.log(theme.system(t('copy_code.list')));
3215
+ for (const b of blocks) {
3216
+ console.log(theme.assistant(t('copy_code.item', {
3217
+ n: String(b.index),
3218
+ lang: b.lang || 'text',
3219
+ lines: String(b.text.split(String.fromCharCode(10)).length),
3220
+ })));
3221
+ }
3222
+ continue;
3223
+ }
3224
+ // Prefer the TUI copy path (it reports into the log); fall back to a
3225
+ // direct clipboard write for the legacy editor / non-TTY.
3226
+ const n = arg ? Number(arg) : undefined;
3227
+ if (n !== undefined && (!Number.isInteger(n) || n < 1)) {
3228
+ console.log(theme.warn(t('copy.bad_index', { n: arg })));
3229
+ continue;
3230
+ }
3231
+ if (editor &&
3232
+ 'copyCode' in editor &&
3233
+ typeof editor.copyCode === 'function') {
3234
+ editor.copyCode(n);
3235
+ continue;
3236
+ }
3237
+ const pick = n !== undefined
3238
+ ? blocks.find((b) => b.index === n)
3239
+ : blocks[blocks.length - 1];
3240
+ if (!pick) {
3241
+ console.log(theme.warn(t('copy.bad_index', { n: String(n) })));
3242
+ continue;
3243
+ }
3244
+ const res2 = writeClipboardText(pick.text);
3245
+ if (res2.ok) {
3246
+ console.log(theme.assistant(t('copy.done', { tool: res2.via })));
3247
+ }
3248
+ else {
3249
+ console.error(theme.warn(t('copy.failed', { tool: res2.via })));
3250
+ }
3251
+ continue;
3252
+ }
2851
3253
  if (lower === '/retry') {
2852
3254
  if (!lastTaskText) {
2853
3255
  console.log(theme.dim(t('retry.none')));
@@ -3351,13 +3753,16 @@ async function main() {
3351
3753
  }
3352
3754
  continue;
3353
3755
  }
3756
+ // A `/goal <condition>` turn is injected directly: skip the slash-command
3757
+ // resolution (the text is NOT a command) and use the override as the task.
3758
+ const isGoalOverride = taskOverride !== null;
3354
3759
  // Custom command or skill invoked as a slash command? Expand it into a
3355
3760
  // task. Skills are instructions the agent follows; custom commands are
3356
3761
  // prompt templates with $ARGUMENTS / {{args}} placeholders.
3357
3762
  let expandedTask = null;
3358
3763
  let missingArgs = null;
3359
3764
  let skillAllowedTools = null;
3360
- if (lower.startsWith('/')) {
3765
+ if (lower.startsWith('/') && !isGoalOverride) {
3361
3766
  const name = trimmed.slice(1).split(/\s+/)[0];
3362
3767
  const rest = trimmed.slice(1 + name.length).trim();
3363
3768
  const res = await expandSlashTarget(currentWorkdir, name, rest);
@@ -3376,12 +3781,16 @@ async function main() {
3376
3781
  console.error(theme.error(t('msg.missing_args', { v: missingArgs.join(', ') })));
3377
3782
  continue;
3378
3783
  }
3379
- if (lower.startsWith('/') && expandedTask === null) {
3784
+ if (lower.startsWith('/') && expandedTask === null && !isGoalOverride) {
3380
3785
  console.error(theme.error(t('msg.unknown_cmd', { v: trimmed })));
3381
3786
  continue;
3382
3787
  }
3383
3788
  // ---- Regular task (including in review mode) ----
3384
- const taskText = expandedTask !== null ? expandedTask : trimmed;
3789
+ const taskText = isGoalOverride
3790
+ ? taskOverride
3791
+ : expandedTask !== null
3792
+ ? expandedTask
3793
+ : trimmed;
3385
3794
  // B5: inline any `@path` references into the task so the agent does not
3386
3795
  // spend a round-trip reading a file the operator already pointed at. Only
3387
3796
  // existing files are inlined; a bare `@name` in prose is left untouched.
@@ -3506,6 +3915,19 @@ async function main() {
3506
3915
  contextLimit: config.ui.contextLimit,
3507
3916
  getTokenUsage: () => browser.getLastTokenUsage(),
3508
3917
  goal: sessionGoal,
3918
+ // Completion-goal mode (Claude Code parity): the goal is a CONDITION,
3919
+ // judged by a fresh model after each turn; while NOT met the loop
3920
+ // keeps working. goalQuery re-reads sessionGoal so a mid-run
3921
+ // `/goal clear` stops the auto-continuation immediately.
3922
+ goalIsCondition: true,
3923
+ goalQuery: () => sessionGoal,
3924
+ onGoalCleared: async () => {
3925
+ // The evaluator ended the goal (MET or IMPOSSIBLE). Clear the
3926
+ // persisted condition so it does not re-arm on the next task; the
3927
+ // run-task loop already surfaced the verdict line to the operator.
3928
+ sessionGoal = null;
3929
+ await saveGoal(currentWorkdir, null);
3930
+ },
3509
3931
  todosQuery: () => todosSummary(),
3510
3932
  selfImprovement: devMode,
3511
3933
  onSubagent: makeSubagentRunner(),