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.
- package/CHANGELOG.md +25 -0
- package/README.md +10 -4
- package/dist/agent-loop.js +61 -1
- package/dist/browser.js +40 -8
- package/dist/commands.js +0 -1
- package/dist/config.js +8 -3
- package/dist/context.js +99 -0
- package/dist/deepseek-ui.js +1 -3
- package/dist/i18n/common.js +13 -16
- package/dist/i18n/config.js +2 -2
- package/dist/i18n/help.js +48 -6
- package/dist/i18n/spinner.js +7 -1
- package/dist/index.js +10 -50
- package/dist/net-capture.js +43 -11
- package/dist/provider-handoff.js +0 -4
- package/dist/qwen-ui.js +0 -8
- package/dist/run-task.js +24 -1
- package/dist/shell.js +0 -6
- package/dist/system-prompt.js +52 -2
- package/dist/tools.js +33 -0
- package/dist/tui/AskQuestion.js +108 -0
- package/dist/tui/FullScreenApp.js +2 -1
- package/dist/tui/InputLine.js +0 -3
- package/dist/tui/StatusBar.js +0 -9
- package/dist/tui/TuiEditor.js +4 -43
- package/dist/tui/edit.js +13 -22
- package/dist/tui/keys.js +12 -14
- package/dist/tui/run-ask.js +22 -0
- package/dist/tui/store.js +0 -2
- package/dist/tui/widgets.js +3 -9
- package/package.json +2 -2
- package/scripts/build-readme.mts +0 -101
- package/scripts/changelog-draft.mts +0 -93
- package/scripts/clean-dist.mts +0 -17
- package/scripts/coverage-gate.mts +0 -112
- package/scripts/ctd-live-driver.mts +0 -51
- package/scripts/ctd-live.tsx +0 -33
- package/scripts/escd-live-driver.mts +0 -37
- package/scripts/eslint-ts6.mts +0 -58
- package/scripts/inline-status-driver.mts +0 -31
- package/scripts/inline-status.tsx +0 -33
- package/scripts/mouse-churn.mts +0 -34
- package/scripts/qwen-models-smoke.mts +0 -117
- package/scripts/qwen-smoke.mts +0 -221
- package/scripts/release.mts +0 -199
- package/scripts/render-demo.mts +0 -154
- package/scripts/render-how-it-works.mts +0 -229
- package/scripts/ru-shot.mts +0 -20
- package/scripts/sched-smoke.mts +0 -150
- package/scripts/self-smoke.mts +0 -309
- package/scripts/tui-comp-shot.mts +0 -63
- package/scripts/tui-components.tsx +0 -62
- package/scripts/tui-config-driver.mts +0 -82
- package/scripts/tui-config-probe.tsx +0 -74
- package/scripts/tui-edit-driver.mts +0 -100
- package/scripts/tui-edit-probe.tsx +0 -38
- package/scripts/tui-editor-probe.tsx +0 -24
- package/scripts/tui-input-driver.mts +0 -54
- package/scripts/tui-input-shot.tsx +0 -59
- package/scripts/tui-log-driver.mts +0 -35
- package/scripts/tui-log-shot.tsx +0 -48
- package/scripts/tui-longpaste-driver.mts +0 -56
- package/scripts/tui-longpaste.tsx +0 -35
- package/scripts/tui-manyselect-shot.mts +0 -34
- package/scripts/tui-manyselect.tsx +0 -17
- package/scripts/tui-mouse-churn-probe.tsx +0 -28
- package/scripts/tui-paste-driver.mts +0 -88
- package/scripts/tui-paste-kinds.tsx +0 -76
- package/scripts/tui-pastetest.mts +0 -38
- package/scripts/tui-probe.tsx +0 -62
- package/scripts/tui-repro.tsx +0 -64
- package/scripts/tui-resize.mts +0 -70
- package/scripts/tui-scroll-driver.mts +0 -53
- package/scripts/tui-scroll-probe.tsx +0 -46
- package/scripts/tui-shot.mts +0 -106
- package/scripts/tui-status-driver.mts +0 -24
- package/scripts/tui-status-shot.tsx +0 -27
- package/scripts/tui-submit.tsx +0 -57
- package/scripts/tui-wheel-driver.mts +0 -61
- package/scripts/tui-wheel-probe.tsx +0 -48
- package/scripts/tui-widgets-driver.mts +0 -20
- package/scripts/tui-widgets.tsx +0 -44
package/dist/net-capture.js
CHANGED
|
@@ -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
|
|
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
|
|
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 ''
|
|
170
|
-
//
|
|
171
|
-
//
|
|
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
|
|
183
|
-
|
|
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
|
-
//
|
|
186
|
-
// absence of
|
|
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
|
-
|
|
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
|
//
|
package/dist/provider-handoff.js
CHANGED
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()])
|
package/dist/system-prompt.js
CHANGED
|
@@ -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: ' ' +
|
|
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;
|
package/dist/tui/InputLine.js
CHANGED
package/dist/tui/StatusBar.js
CHANGED
|
@@ -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
|
}
|
package/dist/tui/TuiEditor.js
CHANGED
|
@@ -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
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
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) {
|