zames_pro 2.73.10 → 2.74.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 (52) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +13 -7
  3. package/dist/banner.js +71 -0
  4. package/dist/browser.js +84 -27
  5. package/dist/code-blocks.js +48 -0
  6. package/dist/commands.js +1 -0
  7. package/dist/config-live.js +53 -0
  8. package/dist/config.js +38 -0
  9. package/dist/goal.js +81 -0
  10. package/dist/i18n/common.js +31 -0
  11. package/dist/i18n/config.js +20 -0
  12. package/dist/i18n/deepseek.js +14 -0
  13. package/dist/i18n/help.js +22 -2
  14. package/dist/i18n/qwen.js +4 -0
  15. package/dist/i18n/self-review.js +8 -0
  16. package/dist/i18n/spinner.js +6 -0
  17. package/dist/index.js +478 -65
  18. package/dist/input/layout.js +19 -0
  19. package/dist/input/suggest.js +13 -0
  20. package/dist/input.js +14 -3
  21. package/dist/net-capture.js +18 -0
  22. package/dist/qwen-browser.js +43 -20
  23. package/dist/run-task.js +97 -4
  24. package/dist/theme.js +7 -1
  25. package/dist/tool-summary.js +83 -0
  26. package/dist/tui/ConfigMenu.js +304 -0
  27. package/dist/tui/ConfirmDialog.js +42 -0
  28. package/dist/tui/FullScreenApp.js +85 -0
  29. package/dist/tui/InputLine.js +285 -0
  30. package/dist/tui/LogView.js +48 -0
  31. package/dist/tui/LoginForm.js +83 -0
  32. package/dist/tui/SelectList.js +50 -0
  33. package/dist/tui/StatusBar.js +140 -0
  34. package/dist/tui/TextPrompt.js +41 -0
  35. package/dist/tui/TuiApp.js +28 -0
  36. package/dist/tui/TuiEditor.js +490 -0
  37. package/dist/tui/edit.js +297 -0
  38. package/dist/tui/keys.js +162 -0
  39. package/dist/tui/log-window.js +93 -0
  40. package/dist/tui/login-pure.js +20 -0
  41. package/dist/tui/run-config-menu.js +19 -0
  42. package/dist/tui/run-confirm.js +22 -0
  43. package/dist/tui/run-login.js +43 -0
  44. package/dist/tui/run-select.js +18 -0
  45. package/dist/tui/store.js +104 -0
  46. package/dist/tui/types.js +10 -0
  47. package/dist/tui/useMouseWheel.js +113 -0
  48. package/dist/tui/widgets.js +55 -0
  49. package/package.json +6 -4
  50. package/scripts/coverage-gate.mts +10 -0
  51. package/scripts/render-demo.mts +1 -1
  52. package/scripts/tui-probe.tsx +62 -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);
@@ -780,7 +791,7 @@ function runTaskDeps() {
780
791
  t,
781
792
  locale: currentLocale,
782
793
  debug,
783
- maxIter,
794
+ maxIter: currentMaxIter(),
784
795
  config,
785
796
  runAgentLoop: mod.runAgentLoop,
786
797
  createSpinner: mod.createSpinner,
@@ -817,8 +828,21 @@ async function main() {
817
828
  console.error(theme.error(t('msg.workdir_error')), e.message);
818
829
  process.exit(1);
819
830
  }
820
- console.log(theme.system(t('msg.working_dir', { v: currentWorkdir })));
821
- console.log(theme.system(t('msg.version', { v: pkgVersion || '?' })));
831
+ // A styled startup banner in an interactive terminal; a plain two-line
832
+ // header when the output is piped (scripts/CI parse it).
833
+ if (process.stdout.isTTY && !jsonMode && config.ui.tui) {
834
+ console.log(renderStartupBanner({
835
+ title: 'zames',
836
+ version: pkgVersion || '?',
837
+ workdir: currentWorkdir,
838
+ provider: providerLabel(config.provider),
839
+ hint: t('msg.banner_hint'),
840
+ }, contentWidth()));
841
+ }
842
+ else {
843
+ console.log(theme.system(t('msg.working_dir', { v: currentWorkdir })));
844
+ console.log(theme.system(t('msg.version', { v: pkgVersion || '?' })));
845
+ }
822
846
  const transcript = new Transcript({
823
847
  dir: config.transcript.dir,
824
848
  enabled: config.transcript.enabled,
@@ -1193,6 +1217,10 @@ async function main() {
1193
1217
  // the typed text is never overwritten by output. In non-TTY (pipe) —
1194
1218
  // the old promptOnce.
1195
1219
  let editor = null;
1220
+ // Re-wire the browser login callbacks after the editor exists / a provider
1221
+ // swap. Assigned where the editor is built (see the login wiring below);
1222
+ // a no-op until then so /provider before setup is harmless.
1223
+ let wireLoginCallbacks = () => { };
1196
1224
  // N37 statusLine hook timer (cleared on exit).
1197
1225
  let statusLineTimer = null;
1198
1226
  let waiter = null;
@@ -1218,19 +1246,36 @@ async function main() {
1218
1246
  };
1219
1247
  await refreshDynamicCommands(currentWorkdir);
1220
1248
  if (process.stdin.isTTY && process.stdout.isTTY) {
1221
- const ed = new LineEditor({
1222
- prompt: buildPrompt(),
1223
- commands: buildSlashCommands(),
1224
- locale: currentLocale,
1225
- });
1249
+ const ed = config.ui.tui
1250
+ ? new TuiEditor({
1251
+ prompt: buildPrompt(),
1252
+ commands: buildSlashCommands(),
1253
+ locale: currentLocale,
1254
+ alternateScreen: config.ui.alternateScreen,
1255
+ })
1256
+ : new LineEditor({
1257
+ prompt: buildPrompt(),
1258
+ commands: buildSlashCommands(),
1259
+ locale: currentLocale,
1260
+ });
1226
1261
  editor = ed;
1227
1262
  ed.setTmpDir(TMP_DIR);
1228
1263
  // Provider badge: show which backend is answering ([Qwen]/[DeepSeek]).
1229
1264
  ed.setProvider(providerLabel(config.provider));
1265
+ // Header for full-screen mode (title / version / working directory).
1266
+ ed.setHeader?.({
1267
+ title: 'zames',
1268
+ version: pkgVersion || '',
1269
+ workdir: currentWorkdir,
1270
+ mouseScroll: config.ui.mouseScroll,
1271
+ });
1230
1272
  // Restore the input history from the previous runs and persist every new
1231
1273
  // entry, so Ctrl+R / arrows can recall older messages after a restart.
1232
1274
  ed.history = loadHistory();
1233
- ed._histIndex = ed.history.length;
1275
+ // The legacy LineEditor keeps its own browse index; the TUI seeds it from
1276
+ // the history itself.
1277
+ if (ed instanceof LineEditor)
1278
+ ed._histIndex = ed.history.length;
1234
1279
  ed.onHistoryChange = (h) => saveHistory(h);
1235
1280
  // The token context right-aligned on the status line (above the input).
1236
1281
  // The editor pulls the number on every render, so it follows the live
@@ -1247,6 +1292,81 @@ async function main() {
1247
1292
  // Toggle icons (🧠 deep thinking, 🌐 web search) before the context
1248
1293
  // counter. The editor pulls the live state on every render.
1249
1294
  ed.onToggleQuery = () => browser.getToggleStatesSync();
1295
+ // Interactive login goes through the Ink form when the TUI owns the
1296
+ // terminal: the browser's readline/askPassword would PAUSE the whole TUI
1297
+ // and fight Ink for stdin. A NAMED function so /provider can re-wire the
1298
+ // freshly created browser (the factory runs before `editor` exists, so the
1299
+ // callbacks cannot be passed there without a forward reference).
1300
+ wireLoginCallbacks = (b) => {
1301
+ // The DeepSeek and Qwen browsers expose DIFFERENT login callback shapes
1302
+ // (DeepSeek: login+password / MFA; Qwen: email -> one-time code), so the
1303
+ // wiring is branched on the provider rather than forced through one
1304
+ // shape.
1305
+ const bb = b;
1306
+ if (config.provider === 'qwen') {
1307
+ bb.onLoginPrompt = async (info) => {
1308
+ if (!config.ui.tui || !(ed instanceof TuiEditor))
1309
+ return null;
1310
+ ed.pause();
1311
+ try {
1312
+ const { runTextPrompt } = await import('./tui/run-login.js');
1313
+ const isCode = info.step === 'code';
1314
+ return await runTextPrompt({
1315
+ t,
1316
+ label: isCode
1317
+ ? t('qwen.login_code_prompt')
1318
+ : t('qwen.login_email_prompt', {
1319
+ def: info.savedUsername
1320
+ ? ' [' + info.savedUsername + ']'
1321
+ : '',
1322
+ }),
1323
+ secret: isCode,
1324
+ });
1325
+ }
1326
+ finally {
1327
+ ed.resume();
1328
+ }
1329
+ };
1330
+ }
1331
+ else {
1332
+ bb.onLoginPrompt = async (info) => {
1333
+ if (!config.ui.tui || !(ed instanceof TuiEditor))
1334
+ return null;
1335
+ ed.pause();
1336
+ try {
1337
+ const { runLoginForm } = await import('./tui/run-login.js');
1338
+ return await runLoginForm({
1339
+ t,
1340
+ initialUsername: config.browser.auth.username,
1341
+ initialPassword: config.browser.auth.password,
1342
+ note: info.hasSavedPassword || info.savedUsername
1343
+ ? t('auth.form.note_saved')
1344
+ : undefined,
1345
+ });
1346
+ }
1347
+ finally {
1348
+ ed.resume();
1349
+ }
1350
+ };
1351
+ }
1352
+ bb.onMfaPrompt = async () => {
1353
+ if (!config.ui.tui || !(ed instanceof TuiEditor))
1354
+ return null;
1355
+ ed.pause();
1356
+ try {
1357
+ const { runTextPrompt } = await import('./tui/run-login.js');
1358
+ return await runTextPrompt({
1359
+ t,
1360
+ label: t('auth.mfa_prompt'),
1361
+ secret: true,
1362
+ });
1363
+ }
1364
+ finally {
1365
+ ed.resume();
1366
+ }
1367
+ };
1368
+ };
1369
+ wireLoginCallbacks(browser);
1250
1370
  // N37 statusLine hook: run the configured command on its own timer (never
1251
1371
  // per-render — a spawn would block typing) and show its first stdout line
1252
1372
  // in the status line. Off when ui.statusLineCommand is empty.
@@ -1363,6 +1483,7 @@ async function main() {
1363
1483
  }) + theme.dim(' (' + res.via + ')')));
1364
1484
  return att;
1365
1485
  };
1486
+ ed.onCopyText = (text) => writeClipboardText(text);
1366
1487
  ed.onSubmit = (text, items) => {
1367
1488
  // While the agent is busy the main command loop is blocked on
1368
1489
  // `await runTask()`, so ONLY commands that make sense without the main
@@ -1473,7 +1594,16 @@ async function main() {
1473
1594
  }
1474
1595
  }
1475
1596
  };
1476
- ed.start();
1597
+ try {
1598
+ ed.start();
1599
+ }
1600
+ catch (e) {
1601
+ // A TUI mount failure must not kill the agent. We surface it and let the
1602
+ // operator retry with `ui.tui: false` if it persists; the imperative
1603
+ // callbacks stay bound to the same editor object, so no state is lost.
1604
+ console.error(theme.warn('UI start failed (set ui.tui=false to use the legacy editor): ' +
1605
+ String(e)));
1606
+ }
1477
1607
  // All command output (console.log/error) must go ABOVE the input line,
1478
1608
  // otherwise it overwrites the text being typed. While the editor is
1479
1609
  // active, we wrap both streams in ed.printAbove.
@@ -1560,6 +1690,63 @@ async function main() {
1560
1690
  editor.refreshStatus();
1561
1691
  }
1562
1692
  }
1693
+ // Browser TIMING knobs (timeouts, retries, the send throttle) are COPIED
1694
+ // by the browser at construction, so push them onto the live instance.
1695
+ // (deepThinking/webSearch are handled above because they also repaint the
1696
+ // status icons.)
1697
+ if (path.startsWith('browser.')) {
1698
+ const b = browser;
1699
+ if (applyBrowserLiveConfig(path, value, b)) {
1700
+ // minSendIntervalMs feeds the send countdown the UI renders.
1701
+ if (editor)
1702
+ editor.refreshStatus();
1703
+ }
1704
+ }
1705
+ // The undo/checkpoint stores also copy their config at construction.
1706
+ applyStoreLiveConfig(path, value, {
1707
+ undo: undo,
1708
+ checkpoints: checkpoints,
1709
+ });
1710
+ // Qwen timing knobs feed the QwenBrowser constructor. Its LIVE fields use
1711
+ // the SAME names, so push them there when the active backend is Qwen (a
1712
+ // no-op on DeepSeek, which has no qwen-only knobs).
1713
+ if (path === 'qwen.minSendIntervalMs' || path === 'qwen.answerTimeoutMs') {
1714
+ const b = browser;
1715
+ if (config.provider === 'qwen') {
1716
+ const field = path === 'qwen.minSendIntervalMs'
1717
+ ? 'minSendIntervalMs'
1718
+ : 'answerTimeoutMs';
1719
+ if (field in b) {
1720
+ b[field] = value;
1721
+ if (editor)
1722
+ editor.refreshStatus();
1723
+ }
1724
+ }
1725
+ }
1726
+ // Switching the provider from /config cannot be applied live (it needs a
1727
+ // new browser); tell the operator instead of silently doing nothing.
1728
+ if (path === 'provider') {
1729
+ const line = t('cfg.provider_restart');
1730
+ if (editor)
1731
+ editor.printAbove(theme.warn(line));
1732
+ else
1733
+ console.log(theme.warn(line));
1734
+ }
1735
+ // headless is fixed at launch (the browser is already open); a /config set
1736
+ // only takes effect on the next run.
1737
+ if (path === 'headless') {
1738
+ const line = t('cfg.restart_required');
1739
+ if (editor)
1740
+ editor.printAbove(theme.dim(line));
1741
+ else
1742
+ console.log(theme.dim(line));
1743
+ }
1744
+ // transcript.enabled: the Transcript copies the flag at construction. A
1745
+ // false -> true flip cannot open a NEW stream mid-run, but true -> false
1746
+ // can (and should) stop writing immediately.
1747
+ if (path === 'transcript.enabled' && value === false) {
1748
+ transcript.enabled = false;
1749
+ }
1563
1750
  }
1564
1751
  // Apply `/thinking` / `/web` immediately: flip the browser's DESIRED state
1565
1752
  // and the status icons, and persist to config (so it survives a restart).
@@ -1598,11 +1785,13 @@ async function main() {
1598
1785
  // Handle a `/goal` command shared by the main loop and the live interception
1599
1786
  // (so a goal can be set even while the agent is busy — it only touches a
1600
1787
  // variable and a file, never the chat). Output goes above the input line
1601
- // when the editor is active, otherwise to stdout.
1788
+ // when the editor is active, otherwise to stdout. Returns which subcommand
1789
+ // ran so the main loop can START A TURN when a goal is freshly set (Claude
1790
+ // Code parity: setting a goal begins working toward it immediately).
1602
1791
  async function handleGoal(input) {
1603
1792
  const g = mod.commands.parseGoalCommand(input);
1604
1793
  if (!g)
1605
- return;
1794
+ return null;
1606
1795
  const emit = (line) => {
1607
1796
  if (editor)
1608
1797
  editor.printAbove(line);
@@ -1613,16 +1802,19 @@ async function main() {
1613
1802
  sessionGoal = null;
1614
1803
  await saveGoal(currentWorkdir, null);
1615
1804
  emit(theme.system(t('goal.cleared')));
1805
+ return 'cleared';
1616
1806
  }
1617
1807
  else if (g.sub === 'show') {
1618
1808
  emit(sessionGoal
1619
1809
  ? theme.system(t('goal.current', { goal: sessionGoal }))
1620
1810
  : theme.dim(t('goal.none')));
1811
+ return 'shown';
1621
1812
  }
1622
1813
  else {
1623
- sessionGoal = g.goal;
1624
- await saveGoal(currentWorkdir, g.goal);
1625
- emit(theme.system(t('goal.set', { goal: g.goal })));
1814
+ sessionGoal = clampGoalCondition(g.goal);
1815
+ await saveGoal(currentWorkdir, sessionGoal);
1816
+ emit(theme.system(t('goal.set', { goal: sessionGoal })));
1817
+ return 'set';
1626
1818
  }
1627
1819
  }
1628
1820
  function configSetRaw(field, raw) {
@@ -1650,19 +1842,27 @@ async function main() {
1650
1842
  return;
1651
1843
  }
1652
1844
  // 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"
1845
+ // output doesn't overlap the editor's permanent input line, we "pause"
1654
1846
  // the editor for the duration of the menu and restore it afterwards.
1655
1847
  if (editor)
1656
1848
  editor.pause();
1849
+ const menuOpts = {
1850
+ fields: CONFIG_SCHEMA,
1851
+ t,
1852
+ get: (path) => getByPath(config, path),
1853
+ getDefault: (path) => getByPath(DEFAULTS, path),
1854
+ set: (field, raw) => configSetRaw(field, raw),
1855
+ reset: (field) => configResetField(field),
1856
+ };
1657
1857
  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
- });
1858
+ // Prefer the Ink menu (nicer, resize-aware) when the TUI is enabled;
1859
+ // fall back to the legacy hand-rolled menu otherwise.
1860
+ if (config.ui.tui) {
1861
+ await runConfigMenuInk(menuOpts);
1862
+ }
1863
+ else {
1864
+ await runConfigMenu(menuOpts);
1865
+ }
1666
1866
  }
1667
1867
  catch (e) {
1668
1868
  console.error(theme.error(String(e.message || e)));
@@ -1690,9 +1890,114 @@ async function main() {
1690
1890
  projectConfigPath: CONFIG_PATHS.PROJECT_CONFIG,
1691
1891
  });
1692
1892
  }
1893
+ // Open a chat by its ChatInfo and print the restored dialogue. Shared by
1894
+ // /resume and the interactive /chats list, so both paths behave identically
1895
+ // (todos reloaded, prompt-resend decision, transcript event).
1896
+ const openResumedChat = async (pick) => {
1897
+ if (editor)
1898
+ editor.lock(t('msg.input_locked'));
1899
+ try {
1900
+ await browser.openChat(pick.id);
1901
+ currentChatId = pick.id;
1902
+ freshChatNext = false;
1903
+ sendSystemPromptNext = promptOnResume();
1904
+ saveLastChat(pick.id, currentWorkdir, pick.title);
1905
+ transcript.log('resume_chat', { id: pick.id, title: pick.title });
1906
+ if (editor)
1907
+ editor.clearScreen();
1908
+ console.log(theme.system(t('chats.opening', { v: pick.title })));
1909
+ console.log(theme.assistant(t('msg.chat_opened')) +
1910
+ theme.system(sendSystemPromptNext
1911
+ ? String.fromCharCode(10) + t('chats.prompt_will_resend')
1912
+ : String.fromCharCode(10) + t('chats.prompt_no_resend')));
1913
+ loadSessionTodos(pick.id);
1914
+ await printRestoredHistory(browser, editor, pick.id);
1915
+ }
1916
+ catch (e) {
1917
+ console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
1918
+ }
1919
+ finally {
1920
+ if (editor)
1921
+ editor.unlock();
1922
+ }
1923
+ };
1924
+ // Show the recent chats in an Ink SelectList and open the chosen one.
1925
+ // Returns true when the operator picked a chat (the caller then skips the
1926
+ // text listing). Only called when the TUI owns the terminal.
1927
+ const pickChatInteractively = async (chats) => {
1928
+ if (!editor)
1929
+ return false;
1930
+ editor.pause();
1931
+ let picked = null;
1932
+ try {
1933
+ const { selectListInk } = await import('./tui/run-select.js');
1934
+ picked = await selectListInk({
1935
+ title: t('chats.pick_title'),
1936
+ items: chats.map((c, i) => ({
1937
+ value: c.id,
1938
+ label: String(i + 1).padStart(2, ' ') + '. ' + c.title,
1939
+ detail: c.id.slice(0, 8) + '…',
1940
+ })),
1941
+ });
1942
+ }
1943
+ catch {
1944
+ picked = null;
1945
+ }
1946
+ finally {
1947
+ editor.resume();
1948
+ }
1949
+ if (!picked)
1950
+ return false;
1951
+ const pick = chats.find((c) => c.id === picked);
1952
+ if (!pick)
1953
+ return false;
1954
+ await openResumedChat(pick);
1955
+ return true;
1956
+ };
1957
+ // Show the saved SESSIONS in an Ink SelectList and resume the chosen one.
1958
+ // Sessions carry the full chat id (unlike the /chats sidebar), so this is
1959
+ // the safe resume path; it reuses openResumedChat for identical behavior.
1960
+ const pickSessionInteractively = async (sessions) => {
1961
+ if (!editor)
1962
+ return false;
1963
+ editor.pause();
1964
+ let picked = null;
1965
+ try {
1966
+ const { selectListInk } = await import('./tui/run-select.js');
1967
+ picked = await selectListInk({
1968
+ title: t('sessions.pick_title'),
1969
+ items: sessions.map((s, i) => ({
1970
+ value: s.id,
1971
+ label: String(i + 1).padStart(2, ' ') +
1972
+ '. ' +
1973
+ (s.title || s.id.slice(0, 8)),
1974
+ detail: s.id.slice(0, 8) +
1975
+ '…' +
1976
+ (s.workdir ? ' [' + dirLabel(s.workdir) + ']' : ''),
1977
+ })),
1978
+ });
1979
+ }
1980
+ catch {
1981
+ picked = null;
1982
+ }
1983
+ finally {
1984
+ editor.resume();
1985
+ }
1986
+ if (!picked)
1987
+ return false;
1988
+ const pick = sessions.find((s) => s.id === picked);
1989
+ if (!pick)
1990
+ return false;
1991
+ await openResumedChat({ id: pick.id, title: pick.title || '' });
1992
+ return true;
1993
+ };
1693
1994
  while (running) {
1694
1995
  let input;
1695
1996
  let inputAttachments = [];
1997
+ // Set by `/goal <condition>` to fall through into a task turn with this
1998
+ // text instead of returning to the prompt (Claude Code starts a turn the
1999
+ // moment a goal is set). Reset every iteration because it is declared here.
2000
+ let taskOverride = null;
1696
2001
  try {
1697
2002
  if (editor) {
1698
2003
  editor.setPrompt(buildPrompt());
@@ -1898,7 +2203,7 @@ async function main() {
1898
2203
  tools,
1899
2204
  task: focus,
1900
2205
  workdir: snapRoot,
1901
- maxIterations: maxIter,
2206
+ maxIterations: currentMaxIter(),
1902
2207
  freshChat: false,
1903
2208
  sendSystemPrompt: false,
1904
2209
  transcript,
@@ -2003,6 +2308,15 @@ async function main() {
2003
2308
  console.log(theme.system(t('chats.none_hint')));
2004
2309
  }
2005
2310
  else {
2311
+ // In the TUI, open a real SELECT LIST (like /model) instead of
2312
+ // dumping text: pick a chat and it opens directly. The text path
2313
+ // below stays for non-TTY / the legacy editor.
2314
+ const canPick = editor && 'isTui' in editor && config.ui.tui && !filter;
2315
+ if (canPick && editor) {
2316
+ const openChat = await pickChatInteractively(shown);
2317
+ if (openChat)
2318
+ continue;
2319
+ }
2006
2320
  console.log(theme.system(t('chats.recent')));
2007
2321
  // The number is the index in the FULL list, so `/resume <n>` keeps
2008
2322
  // pointing at the same chat even when a filter narrows the view.
@@ -2044,33 +2358,7 @@ async function main() {
2044
2358
  console.error(theme.error(t('chats.no_n', { n, total: lastChats.length })));
2045
2359
  continue;
2046
2360
  }
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
- }
2361
+ await openResumedChat(pick);
2074
2362
  continue;
2075
2363
  }
2076
2364
  if (lower === '/provider' || lower.startsWith('/provider ')) {
@@ -2195,6 +2483,9 @@ async function main() {
2195
2483
  // launch failed, `browser` would point at a dead instance and every
2196
2484
  // later command would break.
2197
2485
  const nextBrowser = createProviderBrowser(arg);
2486
+ // Wire the Ink login BEFORE waitForLogin(), or the new backend would
2487
+ // fall back to readline and pause the TUI for its sign-in.
2488
+ wireLoginCallbacks(nextBrowser);
2198
2489
  await nextBrowser.launch();
2199
2490
  await nextBrowser.waitForLogin();
2200
2491
  const oldBrowser = browser;
@@ -2248,11 +2539,46 @@ async function main() {
2248
2539
  const models = qb.listModels ? await qb.listModels() : [];
2249
2540
  if (!models.length) {
2250
2541
  console.log(theme.system(t('qwen.model_list', { v: '(none)' })));
2542
+ continue;
2251
2543
  }
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 })));
2544
+ // In the TUI, open a proper SELECT LIST instead of dumping text.
2545
+ if (editor && 'isTui' in editor && config.ui.tui) {
2546
+ if (editor.pause)
2547
+ editor.pause();
2548
+ let picked;
2549
+ try {
2550
+ const { selectListInk } = await import('./tui/run-select.js');
2551
+ picked = await selectListInk({
2552
+ title: t('qwen.model_pick_title'),
2553
+ initial: config.qwen.model || models[0].id,
2554
+ items: models.map((m) => ({
2555
+ value: m.id,
2556
+ label: m.name || m.id,
2557
+ detail: m.id === config.qwen.model ? '← текущая' : m.id,
2558
+ })),
2559
+ });
2560
+ }
2561
+ catch {
2562
+ picked = null;
2563
+ }
2564
+ finally {
2565
+ if (editor.resume)
2566
+ editor.resume();
2567
+ }
2568
+ if (picked) {
2569
+ config.qwen.model = picked;
2570
+ if (typeof qb.model === 'string')
2571
+ qb.model = picked;
2572
+ try {
2573
+ writeConfigValue('home', 'qwen.model', picked);
2574
+ }
2575
+ catch { }
2576
+ console.log(theme.system(t('qwen.model_set', { v: picked })));
2577
+ }
2578
+ continue;
2255
2579
  }
2580
+ const lines = models.map((m) => ` ${m.id} — ${m.name}`).join(NL);
2581
+ console.log(theme.system(t('qwen.model_list', { v: lines })));
2256
2582
  continue;
2257
2583
  }
2258
2584
  config.qwen.model = arg;
@@ -2294,6 +2620,14 @@ async function main() {
2294
2620
  console.log(theme.system(t('sessions.none')));
2295
2621
  }
2296
2622
  else {
2623
+ // In the TUI, open a SELECT LIST and resume the chosen session
2624
+ // directly (same behavior as /chats), instead of only listing ids.
2625
+ const canPick = editor && 'isTui' in editor && config.ui.tui && !filter;
2626
+ if (canPick && editor) {
2627
+ const opened = await pickSessionInteractively(all);
2628
+ if (opened)
2629
+ continue;
2630
+ }
2297
2631
  all.forEach((s, i) => {
2298
2632
  const n = String(i + 1).padStart(2, ' ');
2299
2633
  const mark = s.id === currentChatId ? theme.user(' *') : '';
@@ -2377,8 +2711,20 @@ async function main() {
2377
2711
  continue;
2378
2712
  }
2379
2713
  if (lower === '/goal' || lower.startsWith('/goal ')) {
2380
- await handleGoal(trimmed);
2381
- continue;
2714
+ const sub = await handleGoal(trimmed);
2715
+ // Claude Code parity: setting a goal STARTS a turn immediately, with the
2716
+ // condition itself as the directive (no separate prompt needed). The
2717
+ // completion loop in runTask() then keeps working until the evaluator
2718
+ // reports MET/IMPOSSIBLE. We fall through to the regular task path with
2719
+ // an override task text instead of returning to the prompt.
2720
+ if (sub === 'set' && sessionGoal) {
2721
+ taskOverride =
2722
+ 'Work toward this completion goal until it is satisfied. ' +
2723
+ 'Do not call respond with a final summary until the condition is met.';
2724
+ }
2725
+ else {
2726
+ continue;
2727
+ }
2382
2728
  }
2383
2729
  if (lower === '/loop' || lower.startsWith('/loop ')) {
2384
2730
  const rest = trimmed.slice('/loop'.length).trim();
@@ -2662,7 +3008,7 @@ async function main() {
2662
3008
  tools: initTools,
2663
3009
  task: initTask,
2664
3010
  workdir: currentWorkdir,
2665
- maxIterations: maxIter,
3011
+ maxIterations: currentMaxIter(),
2666
3012
  freshChat: freshChatNext,
2667
3013
  sendSystemPrompt: sendSystemPromptNext,
2668
3014
  transcript,
@@ -2761,7 +3107,7 @@ async function main() {
2761
3107
  console.log(theme.system(t('status.dev', {
2762
3108
  v: devMode ? t('common.on') : t('common.off'),
2763
3109
  })));
2764
- console.log(theme.system(t('status.max_iter', { v: maxIter })));
3110
+ console.log(theme.system(t('status.max_iter', { v: currentMaxIter() })));
2765
3111
  console.log(theme.system(t('status.headless', { v: headless ? yes : no })));
2766
3112
  console.log(theme.system(t('status.debug', { v: debug ? yes : no })));
2767
3113
  const statusToggles = browser.getToggleStatesSync();
@@ -2848,6 +3194,53 @@ async function main() {
2848
3194
  }
2849
3195
  continue;
2850
3196
  }
3197
+ if (lower === '/copy-code' || lower.startsWith('/copy-code ')) {
3198
+ const arg = trimmed.slice('/copy-code'.length).trim();
3199
+ const blocks = extractCodeBlocks(lastAssistantMessage ?? '');
3200
+ if (!blocks.length) {
3201
+ console.log(theme.dim(t('copy.no_code')));
3202
+ continue;
3203
+ }
3204
+ if (arg === 'list') {
3205
+ console.log(theme.system(t('copy_code.list')));
3206
+ for (const b of blocks) {
3207
+ console.log(theme.assistant(t('copy_code.item', {
3208
+ n: String(b.index),
3209
+ lang: b.lang || 'text',
3210
+ lines: String(b.text.split(String.fromCharCode(10)).length),
3211
+ })));
3212
+ }
3213
+ continue;
3214
+ }
3215
+ // Prefer the TUI copy path (it reports into the log); fall back to a
3216
+ // direct clipboard write for the legacy editor / non-TTY.
3217
+ const n = arg ? Number(arg) : undefined;
3218
+ if (n !== undefined && (!Number.isInteger(n) || n < 1)) {
3219
+ console.log(theme.warn(t('copy.bad_index', { n: arg })));
3220
+ continue;
3221
+ }
3222
+ if (editor &&
3223
+ 'copyCode' in editor &&
3224
+ typeof editor.copyCode === 'function') {
3225
+ editor.copyCode(n);
3226
+ continue;
3227
+ }
3228
+ const pick = n !== undefined
3229
+ ? blocks.find((b) => b.index === n)
3230
+ : blocks[blocks.length - 1];
3231
+ if (!pick) {
3232
+ console.log(theme.warn(t('copy.bad_index', { n: String(n) })));
3233
+ continue;
3234
+ }
3235
+ const res2 = writeClipboardText(pick.text);
3236
+ if (res2.ok) {
3237
+ console.log(theme.assistant(t('copy.done', { tool: res2.via })));
3238
+ }
3239
+ else {
3240
+ console.error(theme.warn(t('copy.failed', { tool: res2.via })));
3241
+ }
3242
+ continue;
3243
+ }
2851
3244
  if (lower === '/retry') {
2852
3245
  if (!lastTaskText) {
2853
3246
  console.log(theme.dim(t('retry.none')));
@@ -3351,13 +3744,16 @@ async function main() {
3351
3744
  }
3352
3745
  continue;
3353
3746
  }
3747
+ // A `/goal <condition>` turn is injected directly: skip the slash-command
3748
+ // resolution (the text is NOT a command) and use the override as the task.
3749
+ const isGoalOverride = taskOverride !== null;
3354
3750
  // Custom command or skill invoked as a slash command? Expand it into a
3355
3751
  // task. Skills are instructions the agent follows; custom commands are
3356
3752
  // prompt templates with $ARGUMENTS / {{args}} placeholders.
3357
3753
  let expandedTask = null;
3358
3754
  let missingArgs = null;
3359
3755
  let skillAllowedTools = null;
3360
- if (lower.startsWith('/')) {
3756
+ if (lower.startsWith('/') && !isGoalOverride) {
3361
3757
  const name = trimmed.slice(1).split(/\s+/)[0];
3362
3758
  const rest = trimmed.slice(1 + name.length).trim();
3363
3759
  const res = await expandSlashTarget(currentWorkdir, name, rest);
@@ -3376,12 +3772,16 @@ async function main() {
3376
3772
  console.error(theme.error(t('msg.missing_args', { v: missingArgs.join(', ') })));
3377
3773
  continue;
3378
3774
  }
3379
- if (lower.startsWith('/') && expandedTask === null) {
3775
+ if (lower.startsWith('/') && expandedTask === null && !isGoalOverride) {
3380
3776
  console.error(theme.error(t('msg.unknown_cmd', { v: trimmed })));
3381
3777
  continue;
3382
3778
  }
3383
3779
  // ---- Regular task (including in review mode) ----
3384
- const taskText = expandedTask !== null ? expandedTask : trimmed;
3780
+ const taskText = isGoalOverride
3781
+ ? taskOverride
3782
+ : expandedTask !== null
3783
+ ? expandedTask
3784
+ : trimmed;
3385
3785
  // B5: inline any `@path` references into the task so the agent does not
3386
3786
  // spend a round-trip reading a file the operator already pointed at. Only
3387
3787
  // existing files are inlined; a bare `@name` in prose is left untouched.
@@ -3506,6 +3906,19 @@ async function main() {
3506
3906
  contextLimit: config.ui.contextLimit,
3507
3907
  getTokenUsage: () => browser.getLastTokenUsage(),
3508
3908
  goal: sessionGoal,
3909
+ // Completion-goal mode (Claude Code parity): the goal is a CONDITION,
3910
+ // judged by a fresh model after each turn; while NOT met the loop
3911
+ // keeps working. goalQuery re-reads sessionGoal so a mid-run
3912
+ // `/goal clear` stops the auto-continuation immediately.
3913
+ goalIsCondition: true,
3914
+ goalQuery: () => sessionGoal,
3915
+ onGoalCleared: async () => {
3916
+ // The evaluator ended the goal (MET or IMPOSSIBLE). Clear the
3917
+ // persisted condition so it does not re-arm on the next task; the
3918
+ // run-task loop already surfaced the verdict line to the operator.
3919
+ sessionGoal = null;
3920
+ await saveGoal(currentWorkdir, null);
3921
+ },
3509
3922
  todosQuery: () => todosSummary(),
3510
3923
  selfImprovement: devMode,
3511
3924
  onSubagent: makeSubagentRunner(),