zames_pro 2.75.5 → 2.76.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 (82) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +10 -4
  3. package/dist/agent-loop.js +61 -1
  4. package/dist/browser.js +40 -8
  5. package/dist/commands.js +0 -1
  6. package/dist/config.js +8 -3
  7. package/dist/context.js +99 -0
  8. package/dist/deepseek-ui.js +1 -3
  9. package/dist/i18n/common.js +13 -16
  10. package/dist/i18n/config.js +2 -2
  11. package/dist/i18n/help.js +48 -6
  12. package/dist/i18n/spinner.js +7 -1
  13. package/dist/index.js +10 -50
  14. package/dist/net-capture.js +43 -11
  15. package/dist/provider-handoff.js +0 -4
  16. package/dist/qwen-ui.js +0 -8
  17. package/dist/run-task.js +24 -1
  18. package/dist/shell.js +0 -6
  19. package/dist/system-prompt.js +52 -2
  20. package/dist/tools.js +33 -0
  21. package/dist/tui/AskQuestion.js +108 -0
  22. package/dist/tui/FullScreenApp.js +2 -1
  23. package/dist/tui/InputLine.js +0 -3
  24. package/dist/tui/StatusBar.js +0 -9
  25. package/dist/tui/TuiEditor.js +4 -43
  26. package/dist/tui/edit.js +13 -22
  27. package/dist/tui/keys.js +12 -14
  28. package/dist/tui/run-ask.js +22 -0
  29. package/dist/tui/store.js +0 -2
  30. package/dist/tui/widgets.js +3 -9
  31. package/package.json +2 -2
  32. package/scripts/build-readme.mts +0 -101
  33. package/scripts/changelog-draft.mts +0 -93
  34. package/scripts/clean-dist.mts +0 -17
  35. package/scripts/coverage-gate.mts +0 -112
  36. package/scripts/ctd-live-driver.mts +0 -51
  37. package/scripts/ctd-live.tsx +0 -33
  38. package/scripts/escd-live-driver.mts +0 -37
  39. package/scripts/eslint-ts6.mts +0 -58
  40. package/scripts/inline-status-driver.mts +0 -31
  41. package/scripts/inline-status.tsx +0 -33
  42. package/scripts/mouse-churn.mts +0 -34
  43. package/scripts/qwen-models-smoke.mts +0 -117
  44. package/scripts/qwen-smoke.mts +0 -221
  45. package/scripts/release.mts +0 -199
  46. package/scripts/render-demo.mts +0 -154
  47. package/scripts/render-how-it-works.mts +0 -229
  48. package/scripts/ru-shot.mts +0 -20
  49. package/scripts/sched-smoke.mts +0 -150
  50. package/scripts/self-smoke.mts +0 -309
  51. package/scripts/tui-comp-shot.mts +0 -63
  52. package/scripts/tui-components.tsx +0 -62
  53. package/scripts/tui-config-driver.mts +0 -82
  54. package/scripts/tui-config-probe.tsx +0 -74
  55. package/scripts/tui-edit-driver.mts +0 -100
  56. package/scripts/tui-edit-probe.tsx +0 -38
  57. package/scripts/tui-editor-probe.tsx +0 -24
  58. package/scripts/tui-input-driver.mts +0 -54
  59. package/scripts/tui-input-shot.tsx +0 -59
  60. package/scripts/tui-log-driver.mts +0 -35
  61. package/scripts/tui-log-shot.tsx +0 -48
  62. package/scripts/tui-longpaste-driver.mts +0 -56
  63. package/scripts/tui-longpaste.tsx +0 -35
  64. package/scripts/tui-manyselect-shot.mts +0 -34
  65. package/scripts/tui-manyselect.tsx +0 -17
  66. package/scripts/tui-mouse-churn-probe.tsx +0 -28
  67. package/scripts/tui-paste-driver.mts +0 -88
  68. package/scripts/tui-paste-kinds.tsx +0 -76
  69. package/scripts/tui-pastetest.mts +0 -38
  70. package/scripts/tui-probe.tsx +0 -62
  71. package/scripts/tui-repro.tsx +0 -64
  72. package/scripts/tui-resize.mts +0 -70
  73. package/scripts/tui-scroll-driver.mts +0 -53
  74. package/scripts/tui-scroll-probe.tsx +0 -46
  75. package/scripts/tui-shot.mts +0 -106
  76. package/scripts/tui-status-driver.mts +0 -24
  77. package/scripts/tui-status-shot.tsx +0 -27
  78. package/scripts/tui-submit.tsx +0 -57
  79. package/scripts/tui-wheel-driver.mts +0 -61
  80. package/scripts/tui-wheel-probe.tsx +0 -48
  81. package/scripts/tui-widgets-driver.mts +0 -20
  82. package/scripts/tui-widgets.tsx +0 -44
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs/promises';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
- import { FINISHED_STATUS_RE, RESPONSE_CONTENT_RE } from './deepseek-ui.js';
4
+ import { FINISHED_STATUS_RE } from './deepseek-ui.js';
5
5
  // Extraction of the RAW model answer text from DeepSeek's network data.
6
6
  //
7
7
  // Why: the answer is read from the RENDERED DOM, and DeepSeek's rendering
@@ -160,15 +160,43 @@ export function extractAnswer(body) {
160
160
  return sse;
161
161
  return extractFromJson(body);
162
162
  }
163
+ // The smallest RESPONSE that counts as a REAL answer rather than a truncated
164
+ // head. A live hang: DeepSeek began the RESPONSE fragment with a single `{`
165
+ // (`"type":"RESPONSE","content":"{"`), then the stream ended with
166
+ // `quasi_status: FINISHED` and no tool-call JSON ever followed. The OLD
167
+ // RESPONSE_CONTENT_RE (one non-quote char) matched that `{`, so the body looked
168
+ // "finished WITH an answer" (isFinishedWithAnswer === true,
169
+ // isFinishedWithoutAnswer === false): the finish loop kept polling for a
170
+ // complete answer and `ask()` gave up only at the askDeadlineMs watchdog — the
171
+ // operator watched a spinner for ~4 minutes with no tool call.
172
+ // A real answer (a tool-call JSON, prose, a `respond` call) is far longer, so
173
+ // anything below this is treated as a truncated/empty turn and retried.
174
+ const MIN_FINISHED_ANSWER_CHARS = 4;
175
+ // How many chars of RESPONSE text the body carries, or -1 when there is NO
176
+ // RESPONSE fragment at all. The fragment is SSE/JSON-encoded, so the content
177
+ // may contain `\"` escapes AND raw `"` inside the JSON (the capture is the RAW
178
+ // body — `{"type":"RESPONSE","content":"{"tool": ...` is a real shape). A naive
179
+ // `[^"]*` therefore stops at the first escaped quote and MIS-reads a long
180
+ // answer as one char. Reuse the real extractor instead of a regex: it decodes
181
+ // the content properly, so the length is exact (we only compare it to a small
182
+ // threshold; the answer text itself comes from extractAnswer()).
183
+ function responseContentLength(body) {
184
+ const b = String(body || '');
185
+ if (!/"type":"RESPONSE"/.test(b))
186
+ return -1;
187
+ return extractFromSse(b).length;
188
+ }
163
189
  // True when the SSE body shows a turn that ENDED (`quasi_status: FINISHED`)
164
- // but produced NO answer text — the reasoning (THINK fragment) was generated
165
- // and then the model stopped WITHOUT writing a RESPONSE fragment.
190
+ // but produced NO real answer text — the reasoning (THINK fragment) was
191
+ // generated and then the model stopped WITHOUT writing a RESPONSE fragment,
192
+ // OR the RESPONSE fragment is a truncated head (a lone `{`, a couple of
193
+ // chars).
166
194
  //
167
195
  // This is the "Stopped + Continue button" case: DeepSeek finishes the turn
168
196
  // with only reasoning, the UI shows a Continue button, and extractAnswer()
169
- // returns '' (it only collects RESPONSE fragments). The old code then saw an
170
- // empty capture, ignored it, and the finish loop waited out the whole timeout
171
- // and threw ds.send_no_new_answer — to the operator the agent "hung on Stopped".
197
+ // returns '' or a fragment. The old code saw a non-empty capture (the `{`),
198
+ // treated the turn as answered, and the finish loop waited out the whole
199
+ // askDeadlineMs — to the operator the agent "hung on Stopped".
172
200
  // Unlike generation_err, `quasi_status` here is FINISHED, so
173
201
  // isGenerationIncompleteText() does NOT catch it.
174
202
  //
@@ -179,11 +207,12 @@ export function isFinishedWithoutAnswer(body) {
179
207
  const finished = FINISHED_STATUS_RE.test(b);
180
208
  if (!finished)
181
209
  return false;
182
- // A RESPONSE fragment with actual content means the model DID answer.
183
- if (RESPONSE_CONTENT_RE.test(b))
210
+ // A RESPONSE fragment with real content means the model DID answer.
211
+ const len = responseContentLength(b);
212
+ if (len >= MIN_FINISHED_ANSWER_CHARS)
184
213
  return false;
185
- // The turn carried reasoning (THINK) or was truncated mid-way; treat the
186
- // absence of response content as "no answer produced".
214
+ // No RESPONSE fragment at all (len === -1) or only a truncated head:
215
+ // treat the absence of a real answer as "no answer produced".
187
216
  return true;
188
217
  }
189
218
  /**
@@ -202,7 +231,10 @@ export function isFinishedWithAnswer(body) {
202
231
  return false;
203
232
  if (!FINISHED_STATUS_RE.test(b))
204
233
  return false;
205
- return RESPONSE_CONTENT_RE.test(b);
234
+ // A truncated RESPONSE head (a lone `{`) is NOT a complete answer: the
235
+ // finish loop would return it as the model's text and the tool-call parser
236
+ // would fail on it. Require real content (see MIN_FINISHED_ANSWER_CHARS).
237
+ return responseContentLength(b) >= MIN_FINISHED_ANSWER_CHARS;
206
238
  }
207
239
  // The CONTEXT size (in tokens) DeepSeek reports for the current answer.
208
240
  //
@@ -67,7 +67,3 @@ export function clearProviderHandoff() {
67
67
  }
68
68
  catch { }
69
69
  }
70
- /** For tests: the file the handoff lives in. */
71
- export function providerHandoffFile() {
72
- return HANDOFF_FILE;
73
- }
package/dist/qwen-ui.js CHANGED
@@ -21,9 +21,6 @@ export const CHAT_ID_RE = /\/c\/([A-Za-z0-9_-]+)/;
21
21
  export function chatUrl(id) {
22
22
  return CHAT_URL + 'c/' + id;
23
23
  }
24
- // The two-step API. `NEW_CHAT_URL` creates a chat and returns its id;
25
- // `COMPLETIONS_URL(id)` streams the answer.
26
- export const NEW_CHAT_URL = 'https://chat.qwen.ai/api/v2/chats/new';
27
24
  export const MODELS_URL = 'https://chat.qwen.ai/api/v2/models/';
28
25
  // The chat list, newest first, EXCLUDING project chats (verified live on a
29
26
  // signed-in session). Used by listChats() instead of scraping the sidebar.
@@ -36,10 +33,6 @@ export function chatHistoryUrl(chatId, limit = 50) {
36
33
  '?direction=up&limit=' +
37
34
  String(limit));
38
35
  }
39
- export function completionsUrl(chatId) {
40
- return ('https://chat.qwen.ai/api/v2/chat/completions?chat_id=' +
41
- encodeURIComponent(chatId));
42
- }
43
36
  // The completions SSE endpoint carries the real answer; used to gate the
44
37
  // network capture so an unrelated JSON endpoint never clobbers it.
45
38
  export const ANSWER_URL_RE = /\/api\/v2\/chat\/completions/i;
@@ -164,7 +157,6 @@ export const ATTACH_SELECTORS = ['input#filesUpload', 'input[type="file"]'];
164
157
  // ---------- login ----------
165
158
  // Qwen's sign-in is a SEPARATE origin (auth.qwen.ai) opened from the header
166
159
  // "Log in" button. Email / Google / GitHub are accepted; no phone required.
167
- export const LOGIN_BUTTON_RE = /^\s*(log ?in|sign ?in|войти)\s*$/i;
168
160
  export const PASSWORD_SELECTORS = [
169
161
  'input[type="password"]',
170
162
  'input[autocomplete="current-password"]',
package/dist/run-task.js CHANGED
@@ -278,7 +278,7 @@ async function evaluateGoal(browser, condition, config, transcript) {
278
278
  }
279
279
  export async function runTask(deps, browser, tools, taskText, workdir, opts, attachments = []) {
280
280
  const { t, locale, debug, maxIter, config, runAgentLoop, createSpinner } = deps;
281
- const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, goalIsCondition = false, onGoalCleared = null, goalMaxTurns = GOAL_MAX_TURNS, goalQuery = null, todosQuery = null, selfImprovement = false, onSubagent = null, } = opts;
281
+ const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, goalIsCondition = false, onGoalCleared = null, goalMaxTurns = GOAL_MAX_TURNS, goalQuery = null, todosQuery = null, selfImprovement = false, onSubagent = null, onAskUser = undefined, } = opts;
282
282
  // The ACTIVE goal for the duration of this run. It is mutable because a
283
283
  // completion goal can be cleared mid-run (verdict MET/IMPOSSIBLE, or the
284
284
  // operator runs `/goal clear` while the task is in flight). `goalQuery`
@@ -410,6 +410,29 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
410
410
  getTokenUsage,
411
411
  selfImprovement,
412
412
  onSubagent,
413
+ // The `AskUser` seam. Default: pause the editor, show the Ink modal,
414
+ // resume; on a non-TTY (or no editor) it resolves null so the model is
415
+ // told to pick a default rather than being stuck on a question nobody
416
+ // can see.
417
+ onAskUser: onAskUser === undefined
418
+ ? async (req) => {
419
+ if (!editor)
420
+ return null;
421
+ editor.pause();
422
+ try {
423
+ const { askQuestionInk } = await import('./tui/run-ask.js');
424
+ return await askQuestionInk({
425
+ question: req.question,
426
+ options: req.options,
427
+ recommended: req.recommended,
428
+ allowText: req.allowText,
429
+ });
430
+ }
431
+ finally {
432
+ editor.resume();
433
+ }
434
+ }
435
+ : onAskUser,
413
436
  // N38: the check command appended to a tool result after an edit
414
437
  // (off by default; config.diagnostics).
415
438
  diagnostics: deps.config.diagnostics,
package/dist/shell.js CHANGED
@@ -195,12 +195,6 @@ export function killBackground(id) {
195
195
  BG_PROCS.delete(id);
196
196
  return `Killed background process ${id}.`;
197
197
  }
198
- /** List running background processes (for /status and cleanup). */
199
- export function listBackground() {
200
- return [...BG_PROCS.values()]
201
- .filter((p) => p.status === 'running')
202
- .map((p) => ({ id: p.id, command: p.command }));
203
- }
204
198
  /** Kill every background process — called on exit so no orphan survives. */
205
199
  export function killAllBackground() {
206
200
  for (const id of [...BG_PROCS.keys()])
@@ -21,11 +21,56 @@ Rules for that note:
21
21
  - this is the ONE exception to "do not touch unrelated files". If nothing
22
22
  worth noting, add nothing.
23
23
  `;
24
+ // The skills DISCIPLINE, appended to the `## Skills` section (and mirrored in
25
+ // the section above it): the model must CHECK the list before a non-trivial
26
+ // task and READ a matching skill's body, instead of only seeing a static list.
27
+ // This is the zames equivalent of superpowers' `using-superpowers` bootstrap —
28
+ // no SessionStart hook needed, the text simply lives in the system prompt. Kept
29
+ // narrow ("when relevant", not "always") so the model does not burn iterations
30
+ // Reading SKILL.md files for a trivial edit.
31
+ const SKILLS_DISCIPLINE = `
32
+ Before starting a NON-TRIVIAL task (a new feature, a bug hunt, a multi-file
33
+ change), scan the list above for a matching skill and, if one applies, Read
34
+ its \`file:\` path FIRST — the body is the workflow you must follow. Skip this
35
+ for a trivial edit or a single read: the scan must never cost more than the
36
+ task itself. If a skill looks relevant but you are unsure, reading it is
37
+ cheap; guessing is not.
38
+ `;
24
39
  // Guidance for the `Task` tool (subagents), injected only when that tool is in
25
40
  // the model's set. Without it the model under-uses subagents (it does every
26
41
  // broad search itself and floods the main context) or misuses them (delegates
27
42
  // work that needs THIS conversation's history, which the isolated subagent
28
43
  // cannot see). Kept short: it competes with the tool list for space.
44
+ // Guidance for the `AskUser` tool, injected only when that tool is offered.
45
+ // Without it the model either never asks (and silently guesses) or asks about
46
+ // things it could verify itself. Kept short and biased toward NOT asking.
47
+ const ASKUSER_SECTION = `
48
+ ## AskUser tool (asking the operator)
49
+
50
+ AskUser shows the operator a MODAL multi-choice question: a short list of
51
+ options, one of them marked recommended, plus an "other" escape where they can
52
+ type free text. It BLOCKS the run until answered and costs the operator a
53
+ context switch, so use it SPARINGLY — only when the decision is genuinely
54
+ ambiguous AND you cannot proceed safely by choosing a sensible default.
55
+
56
+ Ask when:
57
+ - the request has two or more materially different interpretations and the
58
+ wrong one wastes real work (e.g. "delete the feature" — code only, or code
59
+ plus its tests and docs?);
60
+ - a destructive/irreversible action needs a decision you cannot derive from
61
+ the repo (which remote, which branch to force-push);
62
+ - the operator explicitly asked you to check before deciding.
63
+
64
+ Do NOT ask when:
65
+ - you can read the answer from the code, the config or the git history;
66
+ - one option is clearly right — pick it and state the assumption in your
67
+ final answer instead;
68
+ - the task is trivial or already precise.
69
+
70
+ Keep 2–4 options, put the RECOMMENDED one FIRST and mark it, and give each a
71
+ one-line consequence ("X — because …"). The question text says WHY you are
72
+ asking and what you will do with the answer.
73
+ `;
29
74
  const SUBAGENT_SECTION = `
30
75
  ## Task tool (subagents)
31
76
 
@@ -126,7 +171,8 @@ export function renderContextSection(context) {
126
171
  '\n## Skills\n\n' +
127
172
  'Specialized workflows. A skill with a `file:` is a SKILL.md on disk (read it when it applies); a `(built-in)` skill has no file — the operator can invoke it with /<name> and its instructions arrive as the task. Skills are progressive disclosure: only read the body when the skill applies.\n\n' +
128
173
  lines.join('\n') +
129
- '\n';
174
+ '\n' +
175
+ SKILLS_DISCIPLINE;
130
176
  }
131
177
  if (context.commands.length) {
132
178
  const lines = context.commands.map((c) => '- /' + c.name + ': ' + (c.description || '(no description)'));
@@ -158,6 +204,10 @@ export function buildSystemPrompt({ workdir, tools, gitContext = null, locale =
158
204
  const subagentSection = tools.some((x) => x.name === 'Task')
159
205
  ? SUBAGENT_SECTION
160
206
  : '';
207
+ // Same idea for AskUser: guidance only when the tool is in the set.
208
+ const askUserSection = tools.some((x) => x.name === 'AskUser')
209
+ ? ASKUSER_SECTION
210
+ : '';
161
211
  const gitSection = gitContext
162
212
  ? `\n## Git context\n\n${gitContext}\n`
163
213
  : '\n## Git context\n\nNot a git repository (or git is not installed).\n';
@@ -261,7 +311,7 @@ Rules for a batch:
261
311
  - Typical wins: read all the files a task mentions at once; grep for all the
262
312
  symbols you need at once; after planning a multi-file change, write all the
263
313
  files you can write independently at once.
264
- ${subagentSection}
314
+ ${subagentSection}${askUserSection}
265
315
  ## MCP tools
266
316
 
267
317
  Some tools are provided by external MCP servers and their names look
package/dist/tools.js CHANGED
@@ -303,6 +303,33 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
303
303
  },
304
304
  fn: async () => 'Subagents are not available in this run. Do the work yourself.',
305
305
  };
306
+ // The AskUser seam: a MODAL question to the operator (multi-choice with a
307
+ // recommended option and a free-text escape). Like `Task`, it is handled by
308
+ // the CALLER (onAskUser), not by `fn` — the live path intercepts the name in
309
+ // agent-loop. `fn` only answers a direct unit-test call. It is not offered in
310
+ // plan/read-only mode (a plan must not interrupt).
311
+ const askUserTool = {
312
+ name: 'AskUser',
313
+ description: 'Ask the operator a question with a short list of OPTIONS when a decision ' +
314
+ 'is genuinely ambiguous and you cannot proceed safely without it. One ' +
315
+ 'option may be marked recommended (the model puts it FIRST and sets ' +
316
+ '`recommended` to its index/label). The operator can also pick "other" ' +
317
+ 'and type free text. Use it SPARINGLY: prefer a reasonable default and ' +
318
+ 'state it in your final answer — a question costs the operator a ' +
319
+ 'context switch and blocks the run until answered. Do NOT ask for ' +
320
+ 'confirmation of something you can verify yourself (read the file, run ' +
321
+ 'the test). Returns the chosen option label, or the typed text, or an ' +
322
+ 'empty answer when the operator dismissed the dialog.',
323
+ parameters: {
324
+ question: 'string',
325
+ options: 'array',
326
+ recommended: 'number?',
327
+ allow_text: 'boolean?',
328
+ },
329
+ fn: async ({ question }) => 'AskUser is not available in this run (no interactive UI). Proceed with ' +
330
+ 'your best judgement and state the assumption. Question was: ' +
331
+ String(question ?? ''),
332
+ };
306
333
  // In plan (read-only) mode `Task` is dropped too: a `general` subagent could
307
334
  // otherwise write through it and defeat the read-only promise.
308
335
  const all = [
@@ -311,6 +338,7 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
311
338
  ...gitTools,
312
339
  ...webTools,
313
340
  ...(subagents && !readOnly ? [taskTool] : []),
341
+ ...(readOnly ? [] : [askUserTool]),
314
342
  respondTool,
315
343
  ];
316
344
  return readOnly ? filterToolsForReadOnly(all) : all;
@@ -330,6 +358,11 @@ export const MUTATING_TOOLS = new Set([
330
358
  'GitAdd',
331
359
  'GitCommit',
332
360
  'GitPush',
361
+ // AskUser is NOT mutating (it touches no file), but plan mode exists to
362
+ // PRODUCE a plan with no side effects and no interruptions; a modal question
363
+ // mid-plan is exactly what plan mode must not do. Dropped in read-only mode
364
+ // so the model records the open question as a plan note instead.
365
+ 'AskUser',
333
366
  ]);
334
367
  /** Drop mutating tools for read-only / plan mode. Pure; unit-tested. */
335
368
  export function filterToolsForReadOnly(tools) {
@@ -0,0 +1,108 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A modal QUESTION with CHOICES for the model's `AskUser` tool.
3
+ //
4
+ // The operator sees the model's question and a short list of options; one may
5
+ // be marked as the model's recommendation and is highlighted. The operator can
6
+ // ALWAYS answer in their own words via a last "other" row, so imperfect
7
+ // options never block the run. Esc dismisses the question (the model is then
8
+ // told to pick a default and continue).
9
+ //
10
+ // Visual language matches SelectList / ConfirmDialog (rounded border, title,
11
+ // hint bar); it is a separate component because the free-text escape needs an
12
+ // inline one-line editor, which SelectList does not have.
13
+ import { useState } from 'react';
14
+ import { Box, Text, useInput, useApp, useWindowSize } from 'ink';
15
+ import { UI, HintBar } from './widgets.js';
16
+ import { translate } from '../i18n.js';
17
+ export function AskQuestion({ question, options, recommended, allowText, locale = 'ru', onResult, }) {
18
+ const { exit } = useApp();
19
+ const { columns } = useWindowSize();
20
+ const t = translate(locale);
21
+ const innerCols = Math.max(10, (columns || 80) - 6);
22
+ const otherLabel = t('askuser.other');
23
+ const rows = allowText ? [...options, otherLabel] : options;
24
+ const [cursor, setCursor] = useState(recommended !== null && recommended >= 0 && recommended < options.length
25
+ ? recommended
26
+ : 0);
27
+ const [typing, setTyping] = useState(false);
28
+ const [text, setText] = useState('');
29
+ const finish = (v) => {
30
+ onResult(v);
31
+ exit();
32
+ };
33
+ useInput((input, key) => {
34
+ if (key.escape || (key.ctrl && input === 'c')) {
35
+ finish(null);
36
+ return;
37
+ }
38
+ if (typing) {
39
+ if (key.return) {
40
+ const v = text.trim();
41
+ finish(v || null);
42
+ return;
43
+ }
44
+ if (key.backspace || key.delete) {
45
+ setText((s) => Array.from(s).slice(0, -1).join(''));
46
+ return;
47
+ }
48
+ if (input && input >= ' ')
49
+ setText((s) => s + input);
50
+ return;
51
+ }
52
+ if (key.upArrow || input === 'k') {
53
+ setCursor((c) => (rows.length ? (c - 1 + rows.length) % rows.length : 0));
54
+ return;
55
+ }
56
+ if (key.downArrow || input === 'j') {
57
+ setCursor((c) => (rows.length ? (c + 1) % rows.length : 0));
58
+ return;
59
+ }
60
+ if (key.return) {
61
+ if (allowText && cursor === options.length) {
62
+ setTyping(true);
63
+ return;
64
+ }
65
+ finish(rows[cursor] ?? null);
66
+ return;
67
+ }
68
+ // A printable char on the "other" row opens the editor immediately.
69
+ if (allowText && cursor === options.length && input && input >= ' ') {
70
+ setTyping(true);
71
+ setText(input);
72
+ }
73
+ });
74
+ // Word-wrap the question to the inner width (Ink wraps Text too, but a
75
+ // wrapped box row is not counted by the layout, so wrap it here).
76
+ const wrap = (s) => {
77
+ const words = String(s).split(/\s+/);
78
+ const out = [];
79
+ let line = '';
80
+ for (const w of words) {
81
+ const cand = line ? line + ' ' + w : w;
82
+ if (cand.length > innerCols && line) {
83
+ out.push(line);
84
+ line = w;
85
+ }
86
+ else {
87
+ line = cand;
88
+ }
89
+ }
90
+ if (line)
91
+ out.push(line);
92
+ return out.length ? out : [''];
93
+ };
94
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.accent, paddingX: 2, children: [_jsx(Text, { color: UI.title, bold: true, children: '◆ ' + t('askuser.title') }), wrap(question).map((line, i) => (_jsx(Text, { color: UI.text, children: line }, i))), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(innerCols) }), typing ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.dim, children: t('askuser.your_answer') }), _jsxs(Text, { color: UI.accent, children: ['❯ ', text, '▏'] })] })) : (rows.map((label, i) => {
95
+ const sel = i === cursor;
96
+ const isRec = i === recommended;
97
+ return (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, children: sel ? '❯ ' : ' ' }), _jsx(Text, { color: sel ? UI.accent : undefined, bold: sel, children: label }), isRec ? (_jsx(Text, { color: "#6fd0b0", children: ' (' + t('askuser.recommended') + ')' })) : null] }, i));
98
+ })), _jsx(HintBar, { hints: typing
99
+ ? [
100
+ ['Enter', t('askuser.hint_send')],
101
+ ['Esc', t('askuser.hint_cancel')],
102
+ ]
103
+ : [
104
+ ['↑/↓', t('askuser.hint_select')],
105
+ ['Enter', t('askuser.hint_ok')],
106
+ ['Esc', t('askuser.hint_dismiss')],
107
+ ] })] }));
108
+ }
@@ -113,6 +113,7 @@ export function FullScreenApp({ store, inputProps, }) {
113
113
  }, state.mouseScroll);
114
114
  return (_jsxs(Box, { flexDirection: "column", height: termRows, children: [_jsx(Header, { store: store }), _jsx(Box, { flexDirection: "column", height: logHeight, overflow: "hidden", justifyContent: "flex-end", children: all.length === 0 ? (_jsx(Text, { color: "#5b616e", children: translate(state.locale)('editor.empty_hint') })) : (_jsx(LogList, { entries: visible })) }), _jsx(StatusBar, { state: state, divider: false }), _jsx(InputLine, { ...inputProps, store: store,
115
115
  // Cursor rows above the input box: header + log viewport + status.
116
- cursorTop: HEADER_ROWS + logHeight + suggestRows + STATUS_ROWS + 1, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked }), _jsx(Text, { color: "#5b616e", wrap: "truncate", children: ' ' + translate(state.locale)('editor.keys_hint') })] }));
116
+ cursorTop: HEADER_ROWS + logHeight + suggestRows + STATUS_ROWS + 1, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked }), _jsx(Text, { color: "#5b616e", wrap: "truncate", children: ' ' +
117
+ translate(state.locale)(state.mouseScroll ? 'editor.keys_hint' : 'editor.keys_hint_select') })] }));
117
118
  }
118
119
  void contentWidth;
@@ -213,9 +213,6 @@ export function InputLine(props) {
213
213
  case 'clipboard':
214
214
  void tryClipboard();
215
215
  return;
216
- case 'copyCode':
217
- props.onCopyCode();
218
- return;
219
216
  default:
220
217
  return;
221
218
  }
@@ -37,10 +37,6 @@ export function badgeTextWidth(state) {
37
37
  w += visLen('[' + state.provider + '] ');
38
38
  if (state.queueLength > 0)
39
39
  w += visLen('⧗' + state.queueLength + ' ');
40
- if (state.codeBlocks.length > 0) {
41
- const idx = state.codeFocus >= 0 ? state.codeFocus + 1 : state.codeBlocks.length;
42
- w += visLen('⌘ ' + idx + '/' + state.codeBlocks.length + ' ');
43
- }
44
40
  if (state.tasks)
45
41
  w += visLen(state.tasks) + 1;
46
42
  const toggles = [];
@@ -80,11 +76,6 @@ function Badges({ state, budget = Infinity, }) {
80
76
  if (state.queueLength > 0) {
81
77
  add('q', visLen('⧗' + state.queueLength + ' '), _jsxs(Text, { color: "#c9a86a", children: ['⧗' + state.queueLength, ' '] }, "q"));
82
78
  }
83
- if (state.codeBlocks.length > 0) {
84
- const idx = state.codeFocus >= 0 ? state.codeFocus + 1 : state.codeBlocks.length;
85
- const label = '⌘ ' + idx + '/' + state.codeBlocks.length + ' ';
86
- add('code', visLen(label), _jsx(Text, { color: "#7fc4f0", children: label }, "code"));
87
- }
88
79
  if (state.tasks) {
89
80
  add('t', visLen(state.tasks) + 1, _jsxs(Text, { color: "#808896", children: [state.tasks, ' '] }, "t"));
90
81
  }
@@ -26,7 +26,6 @@ import { contentWidth } from '../width.js';
26
26
  import { theme } from '../theme.js';
27
27
  import { renderMarkdown } from '../markdown.js';
28
28
  import { summarizeToolCall, looksLikeToolError } from '../tool-summary.js';
29
- import { extractCodeBlocks } from '../code-blocks.js';
30
29
  import { randomThinkingPhrase, stripEllipsis } from '../spinner.js';
31
30
  export class TuiEditor {
32
31
  store = new TuiStore();
@@ -42,9 +41,6 @@ export class TuiEditor {
42
41
  onTasksQuery = null;
43
42
  onContextQuery = null;
44
43
  onToggleQuery = null;
45
- /** Copy text to the OS clipboard. Wired to writeClipboardText in index.ts;
46
- * returns { ok, via } so the editor can report the outcome. */
47
- onCopyText = null;
48
44
  busy = false;
49
45
  locked = false;
50
46
  history = [];
@@ -98,7 +94,6 @@ export class TuiEditor {
98
94
  onSubmit: (text, atts) => this.handleSubmit(text, atts),
99
95
  onEscape: () => this.onEscape?.(),
100
96
  onCtrlC: () => this.onCtrlC?.(),
101
- onCopyCode: () => this.copyCode(),
102
97
  onAttach: (raw) => this.onAttach?.(raw) ?? null,
103
98
  onClipboard: () => this.onClipboard?.() ?? Promise.resolve(null),
104
99
  onHistoryChange: (h) => {
@@ -259,6 +254,10 @@ export class TuiEditor {
259
254
  this.printAbove(theme.userBar('▌ ') +
260
255
  theme.user(wrapToWidth(display, contentWidth() - 2)));
261
256
  this.store.set({ pastes: [] });
257
+ // Sending a message pins the log back to the newest content: the operator
258
+ // expects their message (and the answer) at the BOTTOM, so a viewport left
259
+ // scrolled-up (PgUp / wheel) must snap to scroll=0 on submit.
260
+ this.store.set({ scroll: 0 });
262
261
  this.onSubmit?.(text, atts);
263
262
  if (this.busy)
264
263
  this.thinking();
@@ -472,44 +471,6 @@ export class TuiEditor {
472
471
  this.printAbove('\n' + theme.assistant(translate(this.locale)('editor.answer')));
473
472
  this.printAbove(rendered);
474
473
  this.printAbove('\n');
475
- // Remember the answer's code blocks so the operator can copy one by hotkey
476
- // (a terminal has no copy buttons). Extraction is on the RAW markdown —
477
- // the rendered text is full of ANSI and must never be copied.
478
- const blocks = extractCodeBlocks(msg);
479
- this.store.set({ codeBlocks: blocks, codeFocus: -1 });
480
- if (blocks.length)
481
- this.refreshBadges();
482
- }
483
- /** Copy the focused code block (or the LAST when none is focused). */
484
- copyCode(index) {
485
- const snap = this.store.getSnapshot();
486
- const blocks = snap.codeBlocks;
487
- if (!blocks.length) {
488
- this.printAbove(theme.dim(translate(this.locale)('copy.no_code')));
489
- return false;
490
- }
491
- const pick = typeof index === 'number'
492
- ? (blocks.find((b) => b.index === index) ?? null)
493
- : snap.codeFocus >= 0
494
- ? (blocks[snap.codeFocus] ?? null)
495
- : blocks[blocks.length - 1];
496
- if (!pick) {
497
- this.printAbove(theme.warn(translate(this.locale)('copy.bad_index', { n: String(index) })));
498
- return false;
499
- }
500
- const res = this.onCopyText?.(pick.text) ?? { ok: false, via: 'none' };
501
- const label = 'code #' + pick.index + (pick.lang ? ' (' + pick.lang + ')' : '');
502
- if (res.ok) {
503
- this.printAbove(theme.assistant(translate(this.locale)('copy.done', { tool: res.via }) +
504
- ' — ' +
505
- label));
506
- }
507
- else {
508
- this.printAbove(theme.warn(translate(this.locale)('copy.failed', { tool: res.via }) +
509
- ' — ' +
510
- label));
511
- }
512
- return res.ok;
513
474
  }
514
475
  warning(msg) {
515
476
  const wrapped = wrapToWidth('⚠ ' + msg, contentWidth());
package/dist/tui/edit.js CHANGED
@@ -82,35 +82,26 @@ export function deleteWordLeft(s) {
82
82
  return { ...s, buf: chars.join(''), cursor: i };
83
83
  }
84
84
  /**
85
- * Delete the word on the RIGHT of the cursor (Ctrl+Delete / Alt+Delete).
86
- *
87
- * FALLBACK: at the END of the line there is no word to the right, so the key
88
- * did NOTHING and the operator (whose terminal sends the standard xterm
89
- * Ctrl+Delete) read it as "Ctrl+Delete does not delete the word before the
90
- * cursor". Deleting the word on the LEFT there matches what other terminal
91
- * editors do and what the operator expects, while keeping the standard
92
- * right-delete when the cursor is mid-line.
85
+ * Delete the word on the RIGHT of the cursor (Ctrl+Delete / Alt+Delete):
86
+ * skip the whitespace after the cursor, then delete up to the end of the next
87
+ * word — the mirror of deleteWordLeft(). At the end of the line there is
88
+ * nothing to the right, so the buffer is left UNCHANGED (never a surprise
89
+ * leftward delete), matching the legacy LineEditor's _deleteWordRight().
93
90
  */
94
91
  export function deleteWordRight(s) {
95
92
  const chars = charsOf(s);
96
- // Nothing to the right (end of buffer or only whitespace to the newline):
97
- // delete the word on the LEFT instead of doing nothing.
98
- let probe = s.cursor;
99
- while (probe < chars.length &&
100
- chars[probe] !== NL &&
101
- /\s/.test(chars[probe])) {
102
- probe++;
103
- }
104
- if (probe >= chars.length || chars[probe] === NL) {
105
- return deleteWordLeft(s);
106
- }
93
+ // Stop at a NEWLINE: it is whitespace for /\s/ but must never be swallowed
94
+ // by a word delete (the old code guarded this; without the guard the key
95
+ // ate the line break).
107
96
  let i = s.cursor;
108
- while (i < chars.length && /\s/.test(chars[i]))
97
+ while (i < chars.length && chars[i] !== NL && /\s/.test(chars[i]))
109
98
  i++;
110
- while (i < chars.length && !/\s/.test(chars[i]))
99
+ while (i < chars.length && chars[i] !== NL && !/\s/.test(chars[i]))
111
100
  i++;
101
+ if (i === s.cursor)
102
+ return s;
112
103
  chars.splice(s.cursor, i - s.cursor);
113
- return { ...s, buf: chars.join('') };
104
+ return { ...s, buf: chars.join(''), cursor: s.cursor };
114
105
  }
115
106
  /** Delete from the cursor to the end of the current LOGICAL line (Ctrl+K). */
116
107
  export function killToLineEnd(s) {