@hncode/hncode 0.2.0 → 0.3.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/LICENSE +674 -219
- package/README.md +1 -0
- package/README_CN.md +2 -0
- package/package.json +1 -1
- package/src/agent-task.js +178 -0
- package/src/agent.js +96 -8
- package/src/colors.js +14 -0
- package/src/config.js +128 -0
- package/src/index.js +7 -2
- package/src/plugin.js +12 -2
- package/src/prompt-presets.js +429 -0
- package/src/subagent-types.js +147 -0
- package/src/swarm-progress.js +293 -0
- package/src/task-output-viewer.js +135 -0
- package/src/tasks-browser.js +279 -0
- package/src/tools/agent-swarm.js +203 -0
- package/src/tools/agent.js +335 -0
- package/src/tools/ask-user-question.js +206 -0
- package/src/tools/bash.js +46 -28
- package/src/tools/edit.js +117 -6
- package/src/tools/glob.js +8 -1
- package/src/tools/index.js +6 -0
- package/src/tools/tasks.js +140 -126
- package/src/tools/todo.js +29 -2
- package/src/tui.js +2057 -292
- package/src/update.js +107 -0
package/src/tui.js
CHANGED
|
@@ -25,8 +25,15 @@ import { visualWidth, estimateTokens, estimateMessagesTokens, expandTabs } from
|
|
|
25
25
|
import { Agent, SYSTEM_PROMPT } from './agent.js';
|
|
26
26
|
import { LLM } from './llm.js';
|
|
27
27
|
import * as sess from './session.js';
|
|
28
|
-
import { resolveProvider, resolveModelArg, addProvider, removeProvider, addModel, fetchModels, fetchCatalog, modelKey, modelLabel, effortOptions, effortWire, rememberModel, hncodeConfigFile, resolveConfig, setConfigString } from './config.js';
|
|
28
|
+
import { resolveProvider, resolveModelArg, addProvider, removeProvider, addModel, fetchModels, fetchCatalog, modelKey, modelLabel, effortOptions, effortWire, rememberModel, hncodeConfigFile, resolveConfig, setConfigString, readPersonalPrompt, readPersonalPromptRaw, writePersonalPrompt, personalPromptFile, readAgentsMd, agentsFilesFor } from './config.js';
|
|
29
29
|
import { pluginCommands } from './plugin.js';
|
|
30
|
+
import { OTHER_LABEL, SUPPLEMENT_LABEL } from './tools/ask-user-question.js';
|
|
31
|
+
import { FAMILIES, TASKS, buildPreset, presetLabel } from './prompt-presets.js';
|
|
32
|
+
import * as upd from './update.js';
|
|
33
|
+
import { renderTasksBrowser, handleTasksBrowserKey, visibleTasks } from './tasks-browser.js';
|
|
34
|
+
import { renderTaskOutputViewer, handleViewerKey, makeViewerState } from './task-output-viewer.js';
|
|
35
|
+
import { renderSwarmProgress } from './swarm-progress.js';
|
|
36
|
+
import { sortedTasks, getTask, settleTask, STATUS_LABEL } from './agent-task.js';
|
|
30
37
|
|
|
31
38
|
const ESC = '\x1b';
|
|
32
39
|
const VERSION = '0.1.0';
|
|
@@ -49,10 +56,12 @@ export const COMMANDS = [
|
|
|
49
56
|
{ name: 'help', aliases: ['h', '?'], desc: 'Show available commands and shortcuts', priority: 80 },
|
|
50
57
|
{ name: 'new', aliases: ['clear'], desc: 'Start a fresh session in the current workspace', priority: 80 },
|
|
51
58
|
{ name: 'sessions', aliases: ['resume'], desc: 'Browse and resume sessions', priority: 80 },
|
|
52
|
-
{ name: 'tasks', aliases: ['task'], desc: 'Browse background tasks', priority: 80 },
|
|
59
|
+
{ name: 'tasks', aliases: ['task'], desc: 'Browse background tasks (full-screen panel)', priority: 80 },
|
|
60
|
+
{ name: 'swarm', desc: 'Toggle swarm mode: decompose work across parallel subagents', priority: 80, argumentHint: '[on|off]' },
|
|
61
|
+
{ name: 'swarm-sub-agent', aliases: ['sub-agent-model'], desc: 'Choose the model subagents run on (default = this session\u2019s model)', priority: 79, argumentHint: '[model|default]' },
|
|
53
62
|
{ name: 'compact', desc: 'Compact the conversation context (AI summary + keep last 20%)', priority: 80, argumentHint: '[ratio]' },
|
|
54
63
|
{ name: 'goal', aliases: ['objective'], desc: 'Start or manage an autonomous goal', priority: 80, argumentHint: '[status|pause|resume|cancel] | <objective>' },
|
|
55
|
-
{ name: 'init', desc: '
|
|
64
|
+
{ name: 'init', desc: 'Generate or update AGENTS.md from the codebase', priority: 70, argumentHint: '[instructions]' },
|
|
56
65
|
{ name: 'fork', desc: 'Fork the current session into a copy without switching to it', priority: 80 },
|
|
57
66
|
{ name: 'undo', desc: 'Withdraw the last prompt from the transcript', priority: 80, argumentHint: '[count]' },
|
|
58
67
|
{ name: 'title', aliases: ['rename'], desc: 'Set or show session title (also sets window title)', priority: 60, argumentHint: '<title>' },
|
|
@@ -65,7 +74,8 @@ export const COMMANDS = [
|
|
|
65
74
|
{ name: 'export-md', aliases: ['export'], desc: 'Export current session as a Markdown file', priority: 40, argumentHint: '[output-path]' },
|
|
66
75
|
{ name: 'import-session', aliases: ['import'], desc: 'Attach a Markdown file to the prompt so the AI reads it without a tool call', priority: 40, argumentHint: '<file.md>' },
|
|
67
76
|
{ name: 'copy', desc: 'Copy the last assistant message to the clipboard', priority: 40 },
|
|
68
|
-
{ name: 'set-system-prompt', aliases: ['system-prompt'], desc: 'Edit
|
|
77
|
+
{ name: 'set-system-prompt', aliases: ['system-prompt'], desc: 'Edit the system prompt, or load one of the model-family presets', priority: 60 },
|
|
78
|
+
{ name: 'personal', aliases: ['preferences'], desc: 'Edit personal preferences injected into every prompt (global or per-project)', priority: 60, argumentHint: '[global|project]' },
|
|
69
79
|
{ name: 'calm-mode', desc: 'Terse replies: stop the model narrating what it will do and why unless asked', priority: 60, argumentHint: '[on|off]' },
|
|
70
80
|
{ name: 'add-dir', desc: 'Add or list an additional workspace directory', priority: 60, argumentHint: '[list] | <path>' },
|
|
71
81
|
{ name: 'move', desc: 'Move current session to another directory (must exist)', priority: 60, argumentHint: '<path>' },
|
|
@@ -73,6 +83,8 @@ export const COMMANDS = [
|
|
|
73
83
|
{ name: 'plugins', desc: 'List loaded plugins and their status', priority: 60 },
|
|
74
84
|
{ name: 'logout', aliases: ['disconnect'], desc: 'Log out of a configured provider', priority: 40 },
|
|
75
85
|
{ name: 'feedback', aliases: ['bug'], desc: 'Send feedback to the maintainers', priority: 60 },
|
|
86
|
+
{ name: 'update', desc: 'Check for and install a newer version', priority: 40, argumentHint: '[-y]' },
|
|
87
|
+
{ name: 'auto-update', desc: 'Toggle automatic background updates (check at startup + every 30 min)', priority: 40, argumentHint: '[on|off]' },
|
|
76
88
|
{ name: 'version', desc: 'Show version information', priority: 20 },
|
|
77
89
|
{ name: 'exit', aliases: ['quit', 'q'], desc: 'Exit the application', priority: 20 },
|
|
78
90
|
].sort((a, b) => (b.priority || 0) - (a.priority || 0) || a.name.localeCompare(b.name));
|
|
@@ -111,6 +123,71 @@ export const CALM_MODE_INSTRUCTION =
|
|
|
111
123
|
+ '- Explain reasoning only if the user explicitly asks for it, or if a genuine\n'
|
|
112
124
|
+ ' blocker/decision needs their input.';
|
|
113
125
|
|
|
126
|
+
// Appended to the system prompt while PLAN MODE is on (/plan). Without this the
|
|
127
|
+
// mode only removed the write tools, leaving the model with no idea what it was
|
|
128
|
+
// supposed to PRODUCE — it just read files and stopped. The instruction gives it
|
|
129
|
+
// the deliverable and the exact shape it must arrive in.
|
|
130
|
+
export const SWARM_MODE_INSTRUCTION =
|
|
131
|
+
'SWARM MODE is ON. The user expects large parallel decomposition.\n'
|
|
132
|
+
+ '- First do a SMALL amount of exploratory work to decide how to split the task.\n'
|
|
133
|
+
+ '- Then do not handle the main work yourself. Use AgentSwarm with a\n'
|
|
134
|
+
+ ' prompt_template containing the {{item}} placeholder and an items array\n'
|
|
135
|
+
+ ' partitioning the problem, so each subagent gets a distinct part.\n'
|
|
136
|
+
+ '- Give each subagent a distinct scope; avoid duplicating work or assigning\n'
|
|
137
|
+
+ ' conflicting changes.\n'
|
|
138
|
+
+ '- Do not conserve agents: AgentSwarm queues launches automatically. Decompose\n'
|
|
139
|
+
+ ' as finely as the work allows while keeping scopes non-conflicting.\n'
|
|
140
|
+
+ '- This mode IS the explicit permission to delegate: the rule against spawning\n'
|
|
141
|
+
+ ' subagents unprompted still applies everywhere else.\n'
|
|
142
|
+
+ '- If after exploring you conclude no subagent is needed, say why and stop.';
|
|
143
|
+
|
|
144
|
+
export const PLAN_MODE_INSTRUCTION =
|
|
145
|
+
'PLAN MODE is ON. You are in a read-only planning phase.\n'
|
|
146
|
+
+ '- The write tools (Write, Edit, Bash, ...) are NOT available. Investigate with\n'
|
|
147
|
+
+ ' the read-only tools you do have, then produce a PLAN.\n'
|
|
148
|
+
+ '- Do NOT try to make the changes. Your job this turn is the plan, not the code.\n'
|
|
149
|
+
+ '- End your reply with the plan wrapped in <plan> and </plan> tags, on their own\n'
|
|
150
|
+
+ ' lines. Everything between them is shown to the user verbatim and is what they\n'
|
|
151
|
+
+ ' approve. Only the LAST such block is used.\n'
|
|
152
|
+
+ '- Inside the block, use short Markdown: a heading per step, and bullets for the\n'
|
|
153
|
+
+ ' files to touch and why. Be concrete enough that the plan can be executed as\n'
|
|
154
|
+
+ ' written — name files, functions and commands.\n'
|
|
155
|
+
+ '- Keep any text OUTSIDE the tags to a one-line summary of what you found.\n'
|
|
156
|
+
+ '- Once the plan is approved the mode turns off and you get the write tools, so\n'
|
|
157
|
+
+ ' the plan must stand on its own.\n'
|
|
158
|
+
+ '\n'
|
|
159
|
+
+ 'Example shape:\n'
|
|
160
|
+
+ '<plan>\n'
|
|
161
|
+
+ '## Add the /personal command\n'
|
|
162
|
+
+ '- `src/config.js`: add readPersonalPrompt() merging global + project files.\n'
|
|
163
|
+
+ '- `src/tui.js`: register /personal, inject the text each turn.\n'
|
|
164
|
+
+ '## Verify\n'
|
|
165
|
+
+ '- Write a temp workspace, check both scopes merge in order.\n'
|
|
166
|
+
+ '</plan>';
|
|
167
|
+
|
|
168
|
+
// Sent back to the model once the user approves a plan. Plan mode is off by then,
|
|
169
|
+
// so it has the write tools; this frames the approved text as an instruction
|
|
170
|
+
// rather than as another question, and forbids re-planning.
|
|
171
|
+
export const PLAN_EXECUTE_PREFIX =
|
|
172
|
+
'The user approved the plan. Plan mode is OFF — you now have the write tools.\n'
|
|
173
|
+
+ 'Execute the plan you produced. Do not produce another plan and do not re-ask\n'
|
|
174
|
+
+ 'for approval; if a step turns out to be impossible, do the rest and report\n'
|
|
175
|
+
+ 'plainly what you could not do.\n\n';
|
|
176
|
+
|
|
177
|
+
// Pull the LAST <plan>…</plan> block out of an assistant reply. Returns null when
|
|
178
|
+
// there is none, so callers can tell "no plan offered" from "empty plan".
|
|
179
|
+
export function extractPlan(text) {
|
|
180
|
+
const s = String(text || '');
|
|
181
|
+
const open = /<plan>\s*/gi;
|
|
182
|
+
let match = null, m;
|
|
183
|
+
while ((m = open.exec(s)) !== null) match = m;
|
|
184
|
+
if (!match) return null;
|
|
185
|
+
const start = match.index + match[0].length;
|
|
186
|
+
const close = s.indexOf('</plan>', start);
|
|
187
|
+
const body = (close === -1 ? s.slice(start) : s.slice(start, close)).trim();
|
|
188
|
+
return body || null;
|
|
189
|
+
}
|
|
190
|
+
|
|
114
191
|
export const TIPS = [
|
|
115
192
|
'Press Esc to interrupt the current turn at any time',
|
|
116
193
|
'/init generates an AGENTS.md from your codebase for smarter agent context',
|
|
@@ -161,7 +238,7 @@ export const TIPS = [
|
|
|
161
238
|
'Shift+Arrow selects text in the composer; Backspace or Delete removes it',
|
|
162
239
|
'Ctrl+J inserts a newline without sending the message',
|
|
163
240
|
'Ctrl+T expands or collapses the todo panel',
|
|
164
|
-
'Ctrl+O expands or collapses tool output and
|
|
241
|
+
'Ctrl+O expands or collapses tool output, thinking blocks and Edit diffs',
|
|
165
242
|
'Ctrl+B moves a long-running foreground Bash command to the background',
|
|
166
243
|
'Ctrl+Shift+V pastes the clipboard as a bracketed paste',
|
|
167
244
|
'Ctrl+Shift+C copies the mouse selection, or the last answer',
|
|
@@ -204,94 +281,301 @@ const MAX_PICKER = 12; // max picker list rows shown at once
|
|
|
204
281
|
const TIP_INTERVAL = 15000;
|
|
205
282
|
// Braille spinner frames for the "Working" indicator (orange).
|
|
206
283
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
284
|
+
// Stand-in for the LIVE spinner glyph inside a CACHED row. A pending thinking
|
|
285
|
+
// block must not be re-wrapped by the 80ms spin tick (see renderChatLines), so
|
|
286
|
+
// its preview is cached with this sentinel and the real frame is substituted in
|
|
287
|
+
// afterwards. It occupies 0 columns, so the swap never disturbs the layout.
|
|
288
|
+
const SPIN_PLACEHOLDER = '\u0000';
|
|
289
|
+
// How long a foreground Bash command runs before its card advertises Ctrl+B, and
|
|
290
|
+
// the hint text itself. Mirrors kimi-code's tool-call card (DETACH_HINT_*).
|
|
291
|
+
const DETACH_HINT_DELAY_MS = 10_000;
|
|
292
|
+
const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background';
|
|
207
293
|
|
|
208
294
|
// Rotating status messages for the "Working..." line — all mean "the agent is
|
|
209
295
|
// thinking / working", but vary the wording so it doesn't look stuck.
|
|
210
296
|
const WORKING_MESSAGES = [
|
|
211
|
-
|
|
212
|
-
'
|
|
213
|
-
'
|
|
214
|
-
'
|
|
215
|
-
'
|
|
216
|
-
'
|
|
217
|
-
'
|
|
218
|
-
'
|
|
219
|
-
'
|
|
220
|
-
'
|
|
221
|
-
'
|
|
222
|
-
|
|
223
|
-
'
|
|
224
|
-
'
|
|
225
|
-
'
|
|
226
|
-
'
|
|
227
|
-
'
|
|
228
|
-
'
|
|
229
|
-
'
|
|
230
|
-
'
|
|
231
|
-
'
|
|
232
|
-
'
|
|
233
|
-
|
|
234
|
-
'
|
|
235
|
-
'
|
|
236
|
-
'
|
|
237
|
-
'
|
|
238
|
-
'
|
|
239
|
-
'
|
|
240
|
-
'
|
|
241
|
-
'
|
|
242
|
-
'
|
|
243
|
-
'
|
|
244
|
-
|
|
245
|
-
'
|
|
246
|
-
'
|
|
247
|
-
'
|
|
248
|
-
'
|
|
249
|
-
'
|
|
250
|
-
'
|
|
251
|
-
'
|
|
252
|
-
'
|
|
253
|
-
'
|
|
254
|
-
'
|
|
255
|
-
'
|
|
256
|
-
'
|
|
257
|
-
'
|
|
258
|
-
'
|
|
259
|
-
|
|
260
|
-
'
|
|
261
|
-
'
|
|
262
|
-
'
|
|
263
|
-
'
|
|
264
|
-
'
|
|
265
|
-
'
|
|
266
|
-
'
|
|
267
|
-
'
|
|
268
|
-
'
|
|
269
|
-
'
|
|
270
|
-
'
|
|
271
|
-
|
|
272
|
-
'
|
|
273
|
-
'
|
|
274
|
-
'
|
|
275
|
-
'
|
|
276
|
-
'
|
|
277
|
-
'
|
|
278
|
-
'
|
|
279
|
-
'
|
|
280
|
-
'
|
|
281
|
-
'
|
|
282
|
-
|
|
283
|
-
'
|
|
284
|
-
'
|
|
285
|
-
'
|
|
286
|
-
'
|
|
287
|
-
'
|
|
288
|
-
'
|
|
289
|
-
'
|
|
290
|
-
'
|
|
291
|
-
'
|
|
292
|
-
'
|
|
293
|
-
|
|
294
|
-
'
|
|
297
|
+
// ---- A (100) ----
|
|
298
|
+
'Accelerating...', 'Accessing...', 'Acclaiming...', 'Acclimating...', 'Accommodating...', 'Accompanying...', 'Accomplishing...', 'Accounting...', 'Accrediting...', 'Accruing...',
|
|
299
|
+
'Accumulating...', 'Achieving...', 'Acknowledging...', 'Acquainting...', 'Acquiring...', 'Acting...', 'Activating...', 'Actualizing...', 'Adapting...', 'Adding...',
|
|
300
|
+
'Addressing...', 'Adhering...', 'Adjusting...', 'Administering...', 'Admiring...', 'Adopting...', 'Adorning...', 'Advancing...', 'Advocating...', 'Affirming...',
|
|
301
|
+
'Affording...', 'Aggregating...', 'Agreeing...', 'Aiding...', 'Aiming...', 'Aligning...', 'Alleviating...', 'Allocating...', 'Allowing...', 'Alluring...',
|
|
302
|
+
'Altering...', 'Amalgamating...', 'Amassing...', 'Amending...', 'Amplifying...', 'Analyzing...', 'Anchoring...', 'Animating...', 'Annotating...', 'Announcing...',
|
|
303
|
+
'Answering...', 'Anticipating...', 'Appealing...', 'Applauding...', 'Applying...', 'Appointing...', 'Appraising...', 'Appreciating...', 'Approaching...', 'Approving...',
|
|
304
|
+
'Architecting...', 'Archiving...', 'Arranging...', 'Articulating...', 'Ascending...', 'Ascertaining...', 'Aspiring...', 'Assembling...', 'Asserting...', 'Assessing...',
|
|
305
|
+
'Assigning...', 'Assimilating...', 'Assisting...', 'Assuring...', 'Astonishing...', 'Attaining...', 'Attempting...', 'Attending...', 'Attracting...', 'Auditing...',
|
|
306
|
+
'Augmenting...', 'Authenticating...', 'Authoring...', 'Authorizing...', 'Automating...', 'Awakening...', 'Awarding...', 'Attuning...', 'Absorbing...', 'Accenting...',
|
|
307
|
+
'Acquiescing...', 'Adjudicating...', 'Adventuring...', 'Alerting...', 'Ameliorating...', 'Allying...', 'Annexing...', 'Aping...', 'Availing...', 'Averaging...',
|
|
308
|
+
// ---- B (100) ----
|
|
309
|
+
'Babbling...', 'Backing...', 'Backtracking...', 'Balancing...', 'Ballooning...', 'Banking...', 'Bargaining...', 'Bartering...', 'Basing...', 'Basking...',
|
|
310
|
+
'Batching...', 'Bathing...', 'Battling...', 'Beaching...', 'Beaming...', 'Bearing...', 'Beating...', 'Beautifying...', 'Beckoning...', 'Becoming...',
|
|
311
|
+
'Bedazzling...', 'Befriending...', 'Beginning...', 'Behaving...', 'Beholding...', 'Believing...', 'Belonging...', 'Benchmarking...', 'Bending...', 'Benefiting...',
|
|
312
|
+
'Bequeathing...', 'Beseeching...', 'Bestowing...', 'Bettering...', 'Bidding...', 'Biding...', 'Billowing...', 'Binding...', 'Birthing...', 'Biting...',
|
|
313
|
+
'Blasting...', 'Blazing...', 'Bleaching...', 'Bleeding...', 'Blending...', 'Blessing...', 'Blinking...', 'Blooming...', 'Blossoming...', 'Blowing...',
|
|
314
|
+
'Blurring...', 'Blushing...', 'Boarding...', 'Boasting...', 'Boiling...', 'Bolstering...', 'Bonding...', 'Booking...', 'Booming...', 'Boosting...',
|
|
315
|
+
'Bootstrapping...', 'Borrowing...', 'Bottling...', 'Bouncing...', 'Bounding...', 'Bowing...', 'Bowling...', 'Boxing...', 'Bracing...', 'Brainstorming...',
|
|
316
|
+
'Braiding...', 'Branching...', 'Branding...', 'Braving...', 'Breaking...', 'Breathing...', 'Breeding...', 'Breezing...', 'Brewing...', 'Bridging...',
|
|
317
|
+
'Briefing...', 'Brightening...', 'Bringing...', 'Broadcasting...', 'Broadening...', 'Bronzing...', 'Browsing...', 'Brushing...', 'Bubbling...', 'Budding...',
|
|
318
|
+
'Budgeting...', 'Buffering...', 'Building...', 'Bulking...', 'Bundling...', 'Buoying...', 'Burnishing...', 'Bustling...', 'Buying...', 'Buzzing...',
|
|
319
|
+
// ---- C (100) ----
|
|
320
|
+
'Caching...', 'Cajoling...', 'Caking...', 'Calibrating...', 'Calling...', 'Calming...', 'Campaigning...', 'Camping...', 'Canceling...', 'Canoodling...',
|
|
321
|
+
'Canvasing...', 'Capitalizing...', 'Captioning...', 'Captivating...', 'Capturing...', 'Caring...', 'Carving...', 'Cascading...', 'Cashing...', 'Casting...',
|
|
322
|
+
'Cataloging...', 'Catapulting...', 'Catching...', 'Categorizing...', 'Catering...', 'Cautioning...', 'Ceasing...', 'Celebrating...', 'Cementing...', 'Centralizing...',
|
|
323
|
+
'Certifying...', 'Chaining...', 'Chairing...', 'Chalking...', 'Challenging...', 'Championing...', 'Channeling...', 'Chanting...', 'Chaperoning...', 'Charging...',
|
|
324
|
+
'Charting...', 'Chasing...', 'Chatting...', 'Cheering...', 'Cherishing...', 'Chewing...', 'Chilling...', 'Chiming...', 'Chipping...', 'Chirping...',
|
|
325
|
+
'Chiseling...', 'Choosing...', 'Chopping...', 'Choreographing...', 'Chronicling...', 'Chugging...', 'Ciphering...', 'Circling...', 'Circulating...', 'Citing...',
|
|
326
|
+
'Civilizing...', 'Clamping...', 'Clanging...', 'Clapping...', 'Clarifying...', 'Clashing...', 'Classifying...', 'Clawing...', 'Cleaning...', 'Cleansing...',
|
|
327
|
+
'Clearing...', 'Cleaving...', 'Climbing...', 'Clinching...', 'Clipping...', 'Cloaking...', 'Clocking...', 'Cloning...', 'Closing...', 'Clouding...',
|
|
328
|
+
'Clustering...', 'Clutching...', 'Coaching...', 'Coalescing...', 'Coating...', 'Coaxing...', 'Cocooning...', 'Codifying...', 'Coexisting...', 'Cogitating...',
|
|
329
|
+
'Cohering...', 'Coiling...', 'Coinciding...', 'Collaborating...', 'Collapsing...', 'Collating...', 'Collecting...', 'Coloring...', 'Combining...', 'Comforting...',
|
|
330
|
+
// ---- D (100) ----
|
|
331
|
+
'Dabbling...', 'Dampening...', 'Dancing...', 'Daring...', 'Darting...', 'Dashing...', 'Dating...', 'Dawdling...', 'Dazzling...', 'Deactivating...',
|
|
332
|
+
'Debugging...', 'Debuting...', 'Decanting...', 'Decelerating...', 'Decentralizing...', 'Deciding...', 'Deciphering...', 'Decking...', 'Declaring...', 'Decoding...',
|
|
333
|
+
'Decomposing...', 'Decorating...', 'Decoupling...', 'Dedicating...', 'Deducing...', 'Deepening...', 'Defeating...', 'Defending...', 'Deferring...', 'Defining...',
|
|
334
|
+
'Deflecting...', 'Defragmenting...', 'Defusing...', 'Degreasing...', 'Dehydrating...', 'Delegating...', 'Deliberating...', 'Delighting...', 'Delivering...', 'Delving...',
|
|
335
|
+
'Demanding...', 'Demarcating...', 'Demonstrating...', 'Demystifying...', 'Denoting...', 'Densifying...', 'Departing...', 'Depending...', 'Deploying...', 'Depicting...',
|
|
336
|
+
'Depositing...', 'Depressurizing...', 'Deriving...', 'Descending...', 'Describing...', 'Desiring...', 'Despatching...', 'Detailing...', 'Detecting...', 'Deterring...',
|
|
337
|
+
'Determining...', 'Detoxing...', 'Devaluing...', 'Developing...', 'Deviating...', 'Devising...', 'Devoting...', 'Diagnosing...', 'Diagramming...', 'Dialing...',
|
|
338
|
+
'Digesting...', 'Digging...', 'Digitizing...', 'Diluting...', 'Dimensioning...', 'Directing...', 'Disarming...', 'Disbursing...', 'Discarding...', 'Discerning...',
|
|
339
|
+
'Discharging...', 'Disciplining...', 'Disclosing...', 'Disconnecting...', 'Discounting...', 'Discovering...', 'Discrediting...', 'Discussing...', 'Disentangling...', 'Disguising...',
|
|
340
|
+
'Disinfecting...', 'Dislodging...', 'Dismantling...', 'Dismissing...', 'Dispatching...', 'Dispensing...', 'Dispersing...', 'Displaying...', 'Disposing...', 'Disproving...',
|
|
341
|
+
'Dissecting...', 'Disseminating...', 'Dissipating...', 'Dissolving...', 'Distilling...', 'Distinguishing...', 'Distributing...', 'Diversifying...', 'Diverting...', 'Dividing...',
|
|
342
|
+
'Divining...', 'Divulging...', 'Docking...', 'Documenting...', 'Dodging...', 'Domesticating...', 'Dominating...', 'Donating...', 'Doodling...', 'Dosing...',
|
|
343
|
+
'Doubling...', 'Dovetailing...', 'Downloading...', 'Drafting...', 'Draining...', 'Dramatizing...', 'Drawing...', 'Dreaming...', 'Dredging...', 'Dressing...',
|
|
344
|
+
'Drifting...', 'Drilling...', 'Driving...', 'Dropping...', 'Drumming...', 'Drying...', 'Ducking...', 'Dusting...', 'Dwelling...', 'Dyeing...',
|
|
345
|
+
// ---- E (100) ----
|
|
346
|
+
'Earning...', 'Easing...', 'Echoing...', 'Eclipsing...', 'Economizing...', 'Edging...', 'Editing...', 'Educating...', 'Effecting...', 'Effervescing...',
|
|
347
|
+
'Elaborating...', 'Elating...', 'Electing...', 'Electrifying...', 'Elevating...', 'Eliciting...', 'Eliminating...', 'Elucidating...', 'Emancipating...', 'Embarking...',
|
|
348
|
+
'Embedding...', 'Embellishing...', 'Embodying...', 'Embracing...', 'Embroidering...', 'Emerging...', 'Emitting...', 'Empathizing...', 'Emphasizing...', 'Empowering...',
|
|
349
|
+
'Emulating...', 'Enabling...', 'Enacting...', 'Encapsulating...', 'Enchanting...', 'Encircling...', 'Enclosing...', 'Encoding...', 'Encouraging...', 'Encrypting...',
|
|
350
|
+
'Endearing...', 'Endeavoring...', 'Endorsing...', 'Endowing...', 'Enduring...', 'Energizing...', 'Enforcing...', 'Engaging...', 'Engineering...', 'Engraving...',
|
|
351
|
+
'Enhancing...', 'Enjoying...', 'Enlarging...', 'Enlightening...', 'Enlisting...', 'Enlivening...', 'Enriching...', 'Enrolling...', 'Ensuring...', 'Entering...',
|
|
352
|
+
'Entertaining...', 'Enthralling...', 'Enthusing...', 'Enticing...', 'Entitling...', 'Enumerating...', 'Enveloping...', 'Envisioning...', 'Equipping...', 'Equalizing...',
|
|
353
|
+
'Erecting...', 'Erasing...', 'Escalating...', 'Escaping...', 'Escorting...', 'Establishing...', 'Esteeming...', 'Estimating...', 'Eulogizing...', 'Evaluating...',
|
|
354
|
+
'Evaporating...', 'Evolving...', 'Examining...', 'Exceeding...', 'Excelling...', 'Exchanging...', 'Exciting...', 'Excluding...', 'Executing...', 'Exercising...',
|
|
355
|
+
'Exhaling...', 'Exhibiting...', 'Exhilarating...', 'Existing...', 'Expanding...', 'Expecting...', 'Expediting...', 'Experiencing...', 'Experimenting...', 'Explaining...',
|
|
356
|
+
'Exploring...', 'Exporting...', 'Exposing...', 'Expressing...', 'Extending...', 'Externalizing...', 'Extracting...', 'Extrapolating...', 'Eyeing...',
|
|
357
|
+
// ---- F (100) ----
|
|
358
|
+
'Fabricating...', 'Facilitating...', 'Facing...', 'Factoring...', 'Fading...', 'Farming...', 'Fascinating...', 'Fashioning...', 'Fastening...', 'Fathoming...',
|
|
359
|
+
'Favoring...', 'Feathering...', 'Featuring...', 'Feeding...', 'Feeling...', 'Fencing...', 'Fermenting...', 'Ferrying...', 'Fertilizing...', 'Fetching...',
|
|
360
|
+
'Fiddling...', 'Fielding...', 'Figuring...', 'Filing...', 'Filling...', 'Filtering...', 'Financing...', 'Finding...', 'Fingerprinting...', 'Finishing...',
|
|
361
|
+
'Firing...', 'Firming...', 'Fishing...', 'Fitting...', 'Fixing...', 'Flagging...', 'Flanking...', 'Flashing...', 'Flattening...', 'Flattering...',
|
|
362
|
+
'Flavoring...', 'Fleshing...', 'Flexing...', 'Flicking...', 'Flinging...', 'Flipping...', 'Floating...', 'Flooding...', 'Flooring...', 'Flourishing...',
|
|
363
|
+
'Flowing...', 'Flushing...', 'Fluttering...', 'Flying...', 'Foaming...', 'Focusing...', 'Folding...', 'Following...', 'Forging...', 'Forgiving...',
|
|
364
|
+
'Formalizing...', 'Forming...', 'Formulating...', 'Fortifying...', 'Forwarding...', 'Fostering...', 'Founding...', 'Fragmenting...', 'Framing...', 'Freeing...',
|
|
365
|
+
'Freezing...', 'Freshening...', 'Frosting...', 'Frying...', 'Fueling...', 'Fulfilling...', 'Functioning...', 'Funding...', 'Furnishing...', 'Furthering...',
|
|
366
|
+
'Fusing...', 'Fussing...', 'Factorizing...', 'Fantasizing...', 'Faultfinding...', 'Federating...', 'Fending...', 'Ferreting...', 'Filming...', 'Finalizing...',
|
|
367
|
+
'Finessing...', 'Fireproofing...', 'Fizzing...', 'Flameproofing...', 'Fleecing...', 'Flickering...', 'Flitting...', 'Fluctuating...', 'Fluorescing...', 'Freewheeling...',
|
|
368
|
+
// ---- G (100) ----
|
|
369
|
+
'Gaining...', 'Galloping...', 'Galvanizing...', 'Gaming...', 'Gardening...', 'Garlanding...', 'Garnering...', 'Gathering...', 'Gauging...', 'Gazing...',
|
|
370
|
+
'Gearing...', 'Generalizing...', 'Generating...', 'Gesticulating...', 'Gesturing...', 'Getting...', 'Gifting...', 'Giggling...', 'Gilding...', 'Girding...',
|
|
371
|
+
'Giving...', 'Glancing...', 'Glazing...', 'Gleaming...', 'Gleaning...', 'Glimmering...', 'Glimpsing...', 'Gliding...', 'Glistening...', 'Glittering...',
|
|
372
|
+
'Glowing...', 'Glueing...', 'Goading...', 'Gobbling...', 'Golfing...', 'Governing...', 'Grabbing...', 'Graduating...', 'Grafting...', 'Granting...',
|
|
373
|
+
'Graphing...', 'Grasping...', 'Gratifying...', 'Grating...', 'Greasing...', 'Greeting...', 'Gridding...', 'Grilling...', 'Grinding...', 'Gripping...',
|
|
374
|
+
'Grooving...', 'Grooming...', 'Grounding...', 'Grouping...', 'Growing...', 'Guaranteeing...', 'Guarding...', 'Guessing...', 'Guiding...', 'Gushing...',
|
|
375
|
+
'Gusting...', 'Gutting...', 'Guzzling...', 'Gamboling...', 'Gating...', 'Gazetting...', 'Gelling...', 'Gentrifying...', 'Germinating...', 'Gestating...',
|
|
376
|
+
'Ghosting...', 'Glaciating...', 'Gladdening...', 'Globalizing...', 'Glorifying...', 'Glossing...', 'Gonging...', 'Gouging...', 'Grandstanding...', 'Granulating...',
|
|
377
|
+
'Gravitating...', 'Grazing...', 'Greening...', 'Grimacing...', 'Grubbing...', 'Gurgling...', 'Gyrating...', 'Gabbing...', 'Gaffing...', 'Gallivanting...',
|
|
378
|
+
'Galumphing...', 'Garbling...', 'Garrisoning...', 'Gasping...', 'Gentling...', 'Geotagging...', 'Gimballing...', 'Glinting...', 'Gorging...', 'Grinning...',
|
|
379
|
+
// ---- H (100) ----
|
|
380
|
+
'Habilitating...', 'Hacking...', 'Haggling...', 'Hailing...', 'Halting...', 'Hammering...', 'Handcrafting...', 'Handing...', 'Handling...', 'Hanging...',
|
|
381
|
+
'Happening...', 'Harboring...', 'Hardening...', 'Harmonizing...', 'Harnessing...', 'Harvesting...', 'Hashing...', 'Hastening...', 'Hatching...', 'Hauling...',
|
|
382
|
+
'Having...', 'Heading...', 'Healing...', 'Heaping...', 'Hearing...', 'Heartening...', 'Heating...', 'Heaving...', 'Hedging...', 'Heeding...',
|
|
383
|
+
'Helping...', 'Heralding...', 'Herding...', 'Hesitating...', 'Hibernating...', 'Hiding...', 'Highlighting...', 'Hiking...', 'Hinging...', 'Hinting...',
|
|
384
|
+
'Hiring...', 'Hitting...', 'Hoarding...', 'Hoisting...', 'Holding...', 'Hollowing...', 'Homing...', 'Honing...', 'Honoring...', 'Hooking...',
|
|
385
|
+
'Hopping...', 'Hoping...', 'Horsing...', 'Hosting...', 'Hotfixing...', 'Hovering...', 'Howling...', 'Huddling...', 'Hugging...', 'Hulling...',
|
|
386
|
+
'Humanizing...', 'Humbling...', 'Humming...', 'Hunting...', 'Hurling...', 'Hurrying...', 'Hushing...', 'Hustling...', 'Hybridizing...', 'Hydrating...',
|
|
387
|
+
'Hydroplaning...', 'Hymning...', 'Hyping...', 'Hypothesizing...', 'Habituating...', 'Hairdressing...', 'Hallowing...', 'Halving...', 'Handholding...', 'Handpicking...',
|
|
388
|
+
'Handwriting...', 'Hankering...', 'Hardcoding...', 'Harkening...', 'Hawking...', 'Haymaking...', 'Headhunting...', 'Headlining...', 'Heartwarming...', 'Heightening...',
|
|
389
|
+
'Heehawing...', 'Homesteading...', 'Homeschooling...', 'Honeying...', 'Hooping...', 'Hornswoggling...', 'Hosing...', 'Hounding...', 'Huckstering...', 'Humoring...',
|
|
390
|
+
// ---- I (100) ----
|
|
391
|
+
'Idealizing...', 'Identifying...', 'Igniting...', 'Ignoring...', 'Illuminating...', 'Illustrating...', 'Imagining...', 'Imitating...', 'Immersing...', 'Immortalizing...',
|
|
392
|
+
'Impacting...', 'Imparting...', 'Impersonating...', 'Implementing...', 'Implicating...', 'Importing...', 'Imposing...', 'Impressing...', 'Imprinting...', 'Improving...',
|
|
393
|
+
'Improvising...', 'Inaugurating...', 'Incentivizing...', 'Inching...', 'Including...', 'Incorporating...', 'Increasing...', 'Incrementing...', 'Incubating...', 'Incurring...',
|
|
394
|
+
'Indenting...', 'Indexing...', 'Indicating...', 'Inducting...', 'Indulging...', 'Industrializing...', 'Inferring...', 'Infiltrating...', 'Inflating...', 'Influencing...',
|
|
395
|
+
'Informing...', 'Infusing...', 'Ingesting...', 'Inhabiting...', 'Inhaling...', 'Inheriting...', 'Inhibiting...', 'Initializing...', 'Initiating...', 'Injecting...',
|
|
396
|
+
'Injuring...', 'Inking...', 'Innovating...', 'Inoculating...', 'Inquiring...', 'Inscribing...', 'Inserting...', 'Insisting...', 'Inspecting...', 'Inspiring...',
|
|
397
|
+
'Installing...', 'Instantiating...', 'Instigating...', 'Instilling...', 'Instituting...', 'Instructing...', 'Insulating...', 'Insuring...', 'Integrating...', 'Intending...',
|
|
398
|
+
'Intensifying...', 'Interacting...', 'Intercepting...', 'Interfacing...', 'Interleaving...', 'Interlinking...', 'Interlacing...', 'Interlocking...', 'Interning...', 'Interpolating...',
|
|
399
|
+
'Interpreting...', 'Interrogating...', 'Interrupting...', 'Intersecting...', 'Intervening...', 'Interviewing...', 'Interweaving...', 'Intoning...', 'Introducing...', 'Intuiting...',
|
|
400
|
+
'Inventing...', 'Inventorying...', 'Inverting...', 'Investing...', 'Investigating...', 'Invigorating...', 'Inviting...', 'Involving...', 'Ionizing...', 'Iterating...',
|
|
401
|
+
// ---- J ----
|
|
402
|
+
'Jabbing...', 'Jacketing...', 'Jailing...', 'Jamming...', 'Jangling...', 'Jarring...', 'Jaunting...', 'Jazzing...',
|
|
403
|
+
'Jeering...', 'Jelling...', 'Jeopardizing...', 'Jerking...', 'Jesting...', 'Jettisoning...', 'Jeweling...', 'Jibing...',
|
|
404
|
+
'Jiggling...', 'Jigsawing...', 'Jilting...', 'Jingling...', 'Jiving...', 'Jobbing...', 'Jockeying...', 'Jogging...',
|
|
405
|
+
'Joining...', 'Joking...', 'Jollying...', 'Jolting...', 'Jostling...', 'Jotting...', 'Journeying...', 'Jousting...',
|
|
406
|
+
'Joyriding...', 'Judging...', 'Juggling...', 'Julienning...', 'Jumping...', 'Junketing...', 'Justifying...', 'Jutting...',
|
|
407
|
+
'Juxtaposing...', 'Jabbering...', 'Jading...', 'Jalousing...', 'Japing...', 'Jargonizing...', 'Jauncing...', 'Jawboning...',
|
|
408
|
+
'Jawing...', 'Jeopardying...', 'Jellifying...', 'Jemmying...', 'Jettying...', 'Jewelling...', 'Jibbing...', 'Jigging...',
|
|
409
|
+
'Jitterbugging...', 'Jinxing...', 'Jittering...', 'Jobhunting...', 'Joggling...', 'Joisting...', 'Jokifying...', 'Jollifying...',
|
|
410
|
+
'Jonesing...', 'Joshing...', 'Jouking...', 'Journalling...', 'Jovializing...', 'Jowing...', 'Joying...', 'Joypopping...',
|
|
411
|
+
'Jubilating...', 'Juddering...', 'Judoing...', 'Juking...', 'Jumbling...', 'Jumpstarting...', 'Junking...', 'Jurifying...',
|
|
412
|
+
'Jurying...', 'Justling...', 'Juicing...', 'Jouncing...', 'Javelining...', 'Jocundizing...', 'Japering...', 'Japonicaing...',
|
|
413
|
+
'Jasperizing...', 'Jazzercising...', 'Jellyfishing...', 'Jerrybuilding...', 'Jerseying...', 'Jessying...', 'Jobseeking...', 'Jointing...',
|
|
414
|
+
'Junketeering...', 'Jawbreaking...', 'Jaywalking...',
|
|
415
|
+
// ---- K ----
|
|
416
|
+
'Keeling...', 'Keening...', 'Keeping...', 'Kenning...', 'Kerbing...', 'Kerneling...', 'Kettling...', 'Keyboarding...',
|
|
417
|
+
'Keying...', 'Keynoting...', 'Kibitzing...', 'Kicking...', 'Kidding...', 'Kidnapping...', 'Kilning...', 'Kindling...',
|
|
418
|
+
'Kinging...', 'Kinking...', 'Kissing...', 'Kitting...', 'Kneading...', 'Kneeling...', 'Knifing...', 'Knighting...',
|
|
419
|
+
'Knitting...', 'Knocking...', 'Knotting...', 'Knowing...', 'Knuckling...', 'Koshering...', 'Kowtowing...', 'Kvetching...',
|
|
420
|
+
'Karaoking...', 'Kayoing...', 'Kecking...', 'Kedging...', 'Keeking...', 'Kelping...', 'Kenneling...', 'Keratinizing...',
|
|
421
|
+
'Kerfing...', 'Kernelling...', 'Kibbutzing...', 'Kiboshing...', 'Kickboxing...', 'Kickstarting...', 'Kiddying...', 'Kiltering...',
|
|
422
|
+
'Kippering...', 'Kiting...', 'Kittening...', 'Klaxoning...', 'Knapping...', 'Kneecapping...', 'Knelling...', 'Knobbling...',
|
|
423
|
+
'Knolling...', 'Kodaking...', 'Konking...', 'Kooking...', 'Koreanizing...', 'Kremlining...', 'Krumping...', 'Kudosing...',
|
|
424
|
+
'Kurbling...', 'Kvelling...', 'Kingmaking...', 'Kyanising...', 'Kyboshing...', 'Kything...', 'Kaolinizing...', 'Kaputing...',
|
|
425
|
+
'Katalysing...', 'Kathoding...', 'Keshing...', 'Keypunching...', 'Khediving...', 'Kalsomining...', 'Kamiing...', 'Kantianing...',
|
|
426
|
+
'Karaokeing...', 'Karateing...', 'Karstifying...', 'Katabolizing...', 'Kathing...', 'Kazaching...', 'Kebabbing...', 'Kebobbing...',
|
|
427
|
+
'Keeving...', 'Keelhauling...', 'Keenlying...', 'Keepnetting...', 'Keltering...', 'Kerseying...', 'Ketling...', 'Keyframing...',
|
|
428
|
+
'Keyseating...', 'Kibbling...', 'Kickboarding...', 'Kickflipping...', 'Kidneystoning...', 'Kieving...', 'Kimchifying...', 'Kimmering...',
|
|
429
|
+
'Kindergartening...', 'Kinescoping...', 'Kingfishing...', 'Kirkifying...', 'Kirshening...', 'Kitbagging...', 'Kitchening...', 'Kitesurfing...',
|
|
430
|
+
'Klondiking...', 'Kludging...', 'Knackering...', 'Kneippering...', 'Knobkerrying...', 'Knowledging...', 'Kohlrabiing...', 'Koorifying...',
|
|
431
|
+
'Kotowing...', 'Kronaing...', 'Krytroning...', 'Kugelhopfing...', 'Kumquating...', 'Kurrajonging...', 'Kyanizing...',
|
|
432
|
+
// ---- L (100) ----
|
|
433
|
+
'Labeling...', 'Laboring...', 'Lacing...', 'Laddering...', 'Lading...', 'Lagging...', 'Lambasting...', 'Laminating...', 'Landing...', 'Languishing...',
|
|
434
|
+
'Lapping...', 'Lapsing...', 'Larding...', 'Lashing...', 'Lasting...', 'Latching...', 'Lathering...', 'Lauding...', 'Laughing...', 'Launching...',
|
|
435
|
+
'Laundering...', 'Lavishing...', 'Laying...', 'Lazing...', 'Leaching...', 'Leading...', 'Leafing...', 'Leaking...', 'Leaning...', 'Leaping...',
|
|
436
|
+
'Learning...', 'Leasing...', 'Leathering...', 'Leaving...', 'Lecturing...', 'Ledgering...', 'Leeching...', 'Leering...', 'Legitimizing...', 'Lending...',
|
|
437
|
+
'Lengthening...', 'Lessening...', 'Letting...', 'Leveling...', 'Leveraging...', 'Levying...', 'Liberating...', 'Licensing...', 'Licking...', 'Lifting...',
|
|
438
|
+
'Lightening...', 'Lighting...', 'Liking...', 'Limbering...', 'Limiting...', 'Lining...', 'Linking...', 'Lionizing...', 'Liquidating...', 'Lisping...',
|
|
439
|
+
'Listening...', 'Litigating...', 'Littering...', 'Living...', 'Loading...', 'Loafing...', 'Loaning...', 'Lobbying...', 'Lobing...', 'Localizing...',
|
|
440
|
+
'Locating...', 'Locking...', 'Lodging...', 'Lofting...', 'Logging...', 'Longing...', 'Looking...', 'Looping...', 'Loosening...', 'Looting...',
|
|
441
|
+
'Loping...', 'Losing...', 'Lounging...', 'Loving...', 'Lowering...', 'Lubricating...', 'Lugging...', 'Lulling...', 'Lumbering...', 'Lumping...',
|
|
442
|
+
'Lunching...', 'Lunging...', 'Luring...', 'Lurking...', 'Lustering...', 'Lustrating...', 'Luxuriating...', 'Lying...', 'Lynching...', 'Lyricizing...',
|
|
443
|
+
// ---- M (100) ----
|
|
444
|
+
'Machining...', 'Magnetizing...', 'Magnifying...', 'Maintaining...', 'Mainstreaming...', 'Making...', 'Managing...', 'Mandating...',
|
|
445
|
+
'Maneuvering...', 'Mangling...', 'Manifesting...', 'Manipulating...', 'Manning...', 'Manufacturing...', 'Mapping...', 'Marching...',
|
|
446
|
+
'Marketing...', 'Marking...', 'Marrying...', 'Marshaling...', 'Marveling...', 'Mashing...', 'Masking...', 'Massaging...',
|
|
447
|
+
'Massing...', 'Mastering...', 'Matching...', 'Materializing...', 'Matriculating...', 'Mattering...', 'Maximizing...', 'Meaning...',
|
|
448
|
+
'Measuring...', 'Mediating...', 'Meditating...', 'Meeting...', 'Melding...', 'Mellowing...', 'Melting...', 'Memorizing...',
|
|
449
|
+
'Mending...', 'Mentioning...', 'Mentoring...', 'Merging...', 'Meriting...', 'Meshing...', 'Mesmerizing...', 'Messaging...',
|
|
450
|
+
'Metabolizing...', 'Metalworking...', 'Metamorphosing...', 'Metering...', 'Microblogging...', 'Microchipping...', 'Microfilming...', 'Micromanaging...',
|
|
451
|
+
'Migrating...', 'Milking...', 'Milling...', 'Mimicking...', 'Mincing...', 'Minding...', 'Mingling...', 'Miniaturizing...',
|
|
452
|
+
'Minimizing...', 'Mining...', 'Ministering...', 'Minting...', 'Mirroring...', 'Misaligning...', 'Miscalculating...', 'Misdiagnosing...',
|
|
453
|
+
'Misfiring...', 'Misjudging...', 'Mislaying...', 'Mismatching...', 'Misplacing...', 'Misquoting...', 'Misreading...', 'Missing...',
|
|
454
|
+
'Misspelling...', 'Mistaking...', 'Misting...', 'Misunderstanding...', 'Mitigating...', 'Mixing...', 'Moaning...', 'Mobilizing...',
|
|
455
|
+
'Mocking...', 'Modeling...', 'Moderating...', 'Modernizing...', 'Modifying...', 'Modularizing...', 'Modulating...', 'Moistening...',
|
|
456
|
+
'Moisturizing...', 'Molding...', 'Mollifying...', 'Monitoring...', 'Monetizing...', 'Monogramming...', 'Monologuing...', 'Monopolizing...',
|
|
457
|
+
'Mooring...', 'Moralizing...', 'Morphing...', 'Mortgaging...', 'Moseying...', 'Motivating...', 'Motoring...', 'Mottling...',
|
|
458
|
+
'Moulding...', 'Mounting...', 'Mountaineering...', 'Mourning...', 'Mousetrapping...', 'Mouthing...', 'Moving...', 'Mowing...',
|
|
459
|
+
'Muckraking...', 'Muddling...', 'Mudslinging...', 'Muffling...', 'Mulching...', 'Multiplying...', 'Multiplexing...', 'Multitasking...',
|
|
460
|
+
'Mumbling...', 'Mummifying...', 'Munching...', 'Murmuring...', 'Muscling...', 'Musicalizing...', 'Musing...', 'Musseling...',
|
|
461
|
+
'Mustering...', 'Mutating...', 'Muting...', 'Mutinying...', 'Muttering...', 'Mutualizing...', 'Muzzling...', 'Mystifying...',
|
|
462
|
+
'Mailing...', 'Mamboing...', 'Manacling...', 'Manhandling...', 'Manicuring...', 'Mansplaining...', 'Mantling...', 'Marinating...',
|
|
463
|
+
'Marooning...', 'Marring...', 'Masquerading...', 'Masterminding...', 'Masticating...', 'Mauling...', 'Meandering...', 'Mechanizing...',
|
|
464
|
+
'Meddling...', 'Meliorating...', 'Melodizing...', 'Memorializing...', 'Menacing...', 'Mercerizing...', 'Merchandising...', 'Metallizing...',
|
|
465
|
+
'Mildewing...', 'Militating...', 'Mimeographing...', 'Mindmapping...', 'Minesweeping...', 'Misappropriating...', 'Misconstruing...', 'Misrepresenting...',
|
|
466
|
+
// ---- N (100) ----
|
|
467
|
+
'Nabbing...', 'Nailing...', 'Naming...', 'Nanosizing...', 'Napping...', 'Narrating...', 'Narrowing...', 'Nattering...',
|
|
468
|
+
'Naturalizing...', 'Navigating...', 'Neatening...', 'Nebulizing...', 'Necessitating...', 'Necking...', 'Necropsying...', 'Needling...',
|
|
469
|
+
'Negating...', 'Negotiating...', 'Nerving...', 'Nesting...', 'Nestling...', 'Netting...', 'Nettling...', 'Networking...',
|
|
470
|
+
'Neutralizing...', 'Newscasting...', 'Nibbling...', 'Nicking...', 'Nicknaming...', 'Nictitating...', 'Niggling...', 'Nipping...',
|
|
471
|
+
'Nitpicking...', 'Nobbling...', 'Nocking...', 'Nodding...', 'Noising...', 'Nominating...', 'Nonplussing...', 'Noodling...',
|
|
472
|
+
'Normalizing...', 'Nosediving...', 'Nosing...', 'Notarizing...', 'Notating...', 'Notching...', 'Noticing...', 'Notifying...',
|
|
473
|
+
'Noting...', 'Nourishing...', 'Novelizing...', 'Nucleating...', 'Nudging...', 'Nuggetting...', 'Nuking...', 'Nullifying...',
|
|
474
|
+
'Numbering...', 'Numbing...', 'Nursemaiding...', 'Nursing...', 'Nurturing...', 'Nutmegging...', 'Nuzzling...', 'Narrowcasting...',
|
|
475
|
+
// ---- O (100) ----
|
|
476
|
+
'Obeying...', 'Objecting...', 'Objectivizing...', 'Obligating...', 'Obliging...', 'Obliterating...', 'Obnubilating...', 'Obscuring...',
|
|
477
|
+
'Observing...', 'Obsessing...', 'Obsoleting...', 'Obstructing...', 'Obtaining...', 'Obtruding...', 'Obviating...', 'Occasioning...',
|
|
478
|
+
'Occupying...', 'Occurring...', 'Offering...', 'Officiating...', 'Offloading...', 'Offsetting...', 'Ogling...', 'Oiling...',
|
|
479
|
+
'Okaying...', 'Ominating...', 'Omitting...', 'Onboarding...', 'Oozing...', 'Opacifying...', 'Opalescing...', 'Opening...',
|
|
480
|
+
'Operating...', 'Opining...', 'Opposing...', 'Optimizing...', 'Optioning...', 'Orating...', 'Orbiting...', 'Orchestrating...',
|
|
481
|
+
'Ordaining...', 'Ordering...', 'Organizing...', 'Orienting...', 'Originating...', 'Ornamenting...', 'Orphaning...', 'Oscillating...',
|
|
482
|
+
'Ossifying...', 'Ostracizing...', 'Outbidding...', 'Outclassing...', 'Outdoing...', 'Outfitting...', 'Outgrowing...', 'Outlasting...',
|
|
483
|
+
'Outlining...', 'Outmaneuvering...', 'Outpacing...', 'Outperforming...', 'Outreaching...', 'Outsmarting...', 'Outsourcing...', 'Outstripping...',
|
|
484
|
+
'Outwitting...', 'Overachieving...', 'Overarching...', 'Overbalancing...', 'Overbooking...', 'Overbuilding...', 'Overcoming...', 'Overcompensating...',
|
|
485
|
+
'Overdelivering...', 'Overdoing...', 'Overeating...', 'Overemphasizing...', 'Overestimating...', 'Overflowing...', 'Overhauling...', 'Overhearing...',
|
|
486
|
+
'Overheating...', 'Overlaying...', 'Overloading...', 'Overlooking...', 'Overpowering...', 'Overprinting...', 'Overproducing...', 'Overreaching...',
|
|
487
|
+
'Overriding...', 'Overruling...', 'Overseeing...', 'Overshadowing...', 'Overshooting...', 'Oversimplifying...', 'Oversleeping...', 'Overstating...',
|
|
488
|
+
'Overstepping...', 'Overstocking...', 'Overstretching...', 'Overtaking...', 'Overthinking...', 'Overthrowing...', 'Overturning...', 'Overvaluing...',
|
|
489
|
+
'Overwatching...', 'Overwhelming...', 'Owing...', 'Owning...', 'Oxidizing...', 'Oxygenating...', 'Ozonizing...', 'Obolizing...',
|
|
490
|
+
// ---- P ----
|
|
491
|
+
'Pacing...', 'Packaging...', 'Packing...', 'Padding...', 'Paddling...', 'Paging...', 'Painting...', 'Pairing...',
|
|
492
|
+
'Palatalizing...', 'Palling...', 'Palming...', 'Palpating...', 'Pampering...', 'Panelizing...', 'Panicking...', 'Pantomiming...',
|
|
493
|
+
'Papering...', 'Parachuting...', 'Parading...', 'Paragliding...', 'Paralysing...', 'Paraphrasing...', 'Parceling...', 'Pardoning...',
|
|
494
|
+
'Parenting...', 'Pariking...', 'Parking...', 'Parleying...', 'Parodying...', 'Paroling...', 'Parrying...', 'Parsing...',
|
|
495
|
+
'Partaking...', 'Partitioning...', 'Partnering...', 'Partying...', 'Passing...', 'Passivizing...', 'Pasting...', 'Pastoring...',
|
|
496
|
+
'Patching...', 'Patenting...', 'Patrolling...', 'Patronizing...', 'Patterning...', 'Pausing...', 'Paving...', 'Pawing...',
|
|
497
|
+
'Paying...', 'Peaking...', 'Pealing...', 'Pecking...', 'Pedaling...', 'Peeking...', 'Peeling...', 'Peering...',
|
|
498
|
+
'Pegging...', 'Pelting...', 'Penalizing...', 'Penciling...', 'Pending...', 'Penetrating...', 'Pensioning...', 'Peppering...',
|
|
499
|
+
'Perceiving...', 'Perching...', 'Perfecting...', 'Perforating...', 'Performing...', 'Perfuming...', 'Perishing...', 'Permeating...',
|
|
500
|
+
'Permitting...', 'Permuting...', 'Perpetuating...', 'Perplexing...', 'Persevering...', 'Persisting...', 'Personalizing...', 'Personifying...',
|
|
501
|
+
'Persuading...', 'Perving...', 'Pestering...', 'Petitioning...', 'Petrifying...', 'Pettifogging...', 'Phaseing...', 'Philandering...',
|
|
502
|
+
'Philosophizing...', 'Phoneing...', 'Photocopying...', 'Photographing...', 'Photosynthesizing...', 'Phrasing...', 'Picking...', 'Picnicking...',
|
|
503
|
+
'Picturing...', 'Piecing...', 'Piercing...', 'Piggybacking...', 'Pilfering...', 'Piloting...', 'Pinching...', 'Pinging...',
|
|
504
|
+
'Pinning...', 'Pioneering...', 'Piping...', 'Pitching...', 'Pitying...', 'Pivoting...', 'Placating...', 'Placing...',
|
|
505
|
+
'Plagiarizing...', 'Plaiting...', 'Planning...', 'Planting...', 'Plastering...', 'Plating...', 'Playing...', 'Pleading...',
|
|
506
|
+
'Pleasuring...', 'Pledging...', 'Plodding...', 'Plotting...', 'Ploughing...', 'Plowing...', 'Plucking...', 'Plugging...',
|
|
507
|
+
'Plumbing...', 'Plummeting...', 'Plumping...', 'Plundering...', 'Plunging...', 'Pluralizing...', 'Plying...', 'Poaching...',
|
|
508
|
+
'Pocketing...', 'Podcasting...', 'Poeticizing...', 'Pointing...', 'Poising...', 'Poisoning...', 'Poking...', 'Polarizing...',
|
|
509
|
+
'Policing...', 'Polishing...', 'Politicizing...', 'Pollinating...', 'Polling...', 'Pondering...', 'Pooling...', 'Popping...',
|
|
510
|
+
'Popularizing...', 'Populating...', 'Poring...', 'Porting...', 'Portioning...', 'Portraying...', 'Posing...', 'Positioning...',
|
|
511
|
+
'Positing...', 'Possessing...', 'Posting...', 'Postponing...', 'Postulating...', 'Posturing...', 'Potting...', 'Pouncing...',
|
|
512
|
+
'Pounding...', 'Pouring...', 'Pouting...', 'Powdering...', 'Powering...', 'Practicing...', 'Praising...', 'Prancing...',
|
|
513
|
+
'Prattling...', 'Praying...', 'Preaching...', 'Preambleing...', 'Preceding...', 'Precipitating...', 'Precluding...', 'Precooking...',
|
|
514
|
+
'Predicting...', 'Predisposing...', 'Prefacing...', 'Preferring...', 'Prefiguring...', 'Prefixing...', 'Preheating...', 'Prejudging...',
|
|
515
|
+
'Preluding...', 'Premising...', 'Preoccupying...', 'Preordaining...', 'Preparing...', 'Prepaving...', 'Prepaying...', 'Preponderating...',
|
|
516
|
+
'Prerecording...', 'Prescribing...', 'Presenting...', 'Preserving...', 'Presetting...', 'Presiding...', 'Pressuring...', 'Pressurizing...',
|
|
517
|
+
'Prestressing...', 'Presuming...', 'Pretending...', 'Prettifying...', 'Prevailing...', 'Preventing...', 'Previewing...', 'Preying...',
|
|
518
|
+
'Pricing...', 'Pricking...', 'Pridiing...', 'Priming...', 'Printing...', 'Prioritizing...', 'Privatizing...', 'Privileging...',
|
|
519
|
+
'Prizing...', 'Probing...', 'Proceeding...', 'Processing...', 'Proclaiming...', 'Procrastinating...', 'Procuring...', 'Prodding...',
|
|
520
|
+
'Producing...', 'Profaning...', 'Professing...', 'Profiling...', 'Profiting...', 'Programming...', 'Progressing...', 'Prohibiting...',
|
|
521
|
+
'Projecting...', 'Proliferating...', 'Prolonging...', 'Promenading...', 'Promising...', 'Promoting...', 'Prompting...', 'Promulgating...',
|
|
522
|
+
'Pronouncing...', 'Proofing...', 'Proofreading...', 'Propagating...', 'Propelling...', 'Prophesying...', 'Proposing...', 'Propping...',
|
|
523
|
+
'Proroguing...', 'Prosecuting...', 'Proselytizing...', 'Prospecting...', 'Prospering...', 'Protecting...', 'Protesting...', 'Protruding...',
|
|
524
|
+
'Proving...', 'Providing...', 'Provisioning...', 'Provoking...', 'Prowling...', 'Pruning...', 'Psyching...', 'Publicizing...',
|
|
525
|
+
'Publishing...', 'Puckering...', 'Puffing...', 'Pulling...', 'Pulping...', 'Pulsating...', 'Pulverizing...', 'Pummeling...',
|
|
526
|
+
'Pumping...', 'Punching...', 'Puncturing...', 'Punishing...', 'Punning...', 'Purging...', 'Purifying...', 'Purloining...',
|
|
527
|
+
'Purporting...', 'Purring...', 'Pursuing...', 'Pushing...', 'Putting...', 'Puzzling...', 'Pyramiding...',
|
|
528
|
+
// ---- Q ----
|
|
529
|
+
'Quacking...', 'Quadrupling...', 'Quaffing...', 'Quailing...', 'Qualifying...', 'Quantifying...', 'Quarreling...', 'Quarrying...',
|
|
530
|
+
'Quartering...', 'Quashing...', 'Quavering...', 'Queening...', 'Quelling...', 'Quenching...', 'Querying...', 'Questing...',
|
|
531
|
+
'Questioning...', 'Queueing...', 'Quickening...', 'Quieting...', 'Quilting...', 'Quipping...', 'Quivering...', 'Quizzing...',
|
|
532
|
+
'Quoting...',
|
|
533
|
+
// ---- R ----
|
|
534
|
+
'Racing...', 'Racking...', 'Radiating...', 'Raffling...', 'Rafting...', 'Raging...', 'Raining...', 'Raising...',
|
|
535
|
+
'Rallying...', 'Rambling...', 'Ramifying...', 'Ramping...', 'Ransacking...', 'Ransoming...', 'Ranting...', 'Rapping...',
|
|
536
|
+
'Rapproching...', 'Rapturing...', 'Raring...', 'Rasping...', 'Ratifying...', 'Rating...', 'Rationalizing...',
|
|
537
|
+
'Rationing...', 'Rattling...', 'Ravaging...', 'Raving...', 'Reaching...', 'Reacting...', 'Readjusting...', 'Readying...',
|
|
538
|
+
'Realigning...', 'Realizing...', 'Reallocating...', 'Reanimating...', 'Reaping...', 'Reappearing...', 'Reapplying...', 'Rearranging...',
|
|
539
|
+
'Reasoning...', 'Reassembling...', 'Reasserting...', 'Reassessing...', 'Reassigning...', 'Reassuring...', 'Rebalancing...', 'Rebating...',
|
|
540
|
+
'Rebelling...', 'Rebooting...', 'Rebounding...', 'Rebranding...', 'Rebuilding...', 'Rebuking...', 'Recalculating...', 'Recalibrating...',
|
|
541
|
+
'Recalling...', 'Recanting...', 'Recapping...', 'Recapturing...', 'Receding...', 'Receiving...', 'Recentering...', 'Recharging...',
|
|
542
|
+
'Rechecking...', 'Rechristening...', 'Recirculating...', 'Reciting...', 'Reclaiming...', 'Reclassifying...', 'Reclining...', 'Recognizing...',
|
|
543
|
+
'Recoiling...', 'Recollecting...', 'Recommending...', 'Recommissioning...', 'Recompiling...', 'Reconciling...', 'Reconfiguring...', 'Reconnecting...',
|
|
544
|
+
'Reconsidering...', 'Reconstituting...', 'Reconstructing...', 'Recording...', 'Recounting...', 'Recouping...', 'Recovering...', 'Recreating...',
|
|
545
|
+
'Recruiting...', 'Rectifying...', 'Recuperating...', 'Recurring...', 'Recycling...', 'Redacting...', 'Redefining...', 'Redeploying...',
|
|
546
|
+
'Redesigning...', 'Redeveloping...', 'Redialing...', 'Redirecting...', 'Rediscovering...', 'Redistributing...', 'Redoing...', 'Redoubling...',
|
|
547
|
+
'Redrafting...', 'Reducing...', 'Reeling...', 'Refactoring...', 'Referencing...', 'Referring...', 'Refilling...', 'Refinancing...',
|
|
548
|
+
'Refining...', 'Refitting...', 'Reflecting...', 'Reformatting...', 'Reforming...', 'Refracting...', 'Refraining...', 'Refreshing...',
|
|
549
|
+
'Refueling...', 'Refunding...', 'Refurbishing...', 'Refusing...', 'Refuting...', 'Regaining...', 'Regaling...', 'Regarding...',
|
|
550
|
+
'Regenerating...', 'Registering...', 'Regressing...', 'Regretting...', 'Regrouping...', 'Regulating...', 'Rehabilitating...', 'Rehearsing...',
|
|
551
|
+
'Reigning...', 'Reimbursing...', 'Reinforcing...', 'Reining...', 'Reinstalling...', 'Reinstating...', 'Reintegrating...', 'Reinterpreting...',
|
|
552
|
+
'Reintroducing...', 'Reinventing...', 'Reinvesting...', 'Reissuing...', 'Reiterating...', 'Rejecting...', 'Rejoicing...', 'Rejoining...',
|
|
553
|
+
'Rejuvenating...', 'Rekindling...', 'Relating...', 'Relaxing...', 'Relaying...', 'Releasing...', 'Relegating...', 'Relenting...',
|
|
554
|
+
'Relieving...', 'Relighting...', 'Relishing...', 'Reliving...', 'Reloading...', 'Relocating...', 'Relying...', 'Remaining...',
|
|
555
|
+
'Remanding...', 'Remarking...', 'Remarrying...', 'Remastering...', 'Remedying...', 'Remembering...', 'Reminding...', 'Reminiscing...',
|
|
556
|
+
'Remitting...', 'Remodeling...', 'Remolding...', 'Remonstrating...', 'Removing...', 'Rendering...', 'Rendezvousing...', 'Reneging...',
|
|
557
|
+
'Renegotiating...', 'Renewing...', 'Renouncing...', 'Renovating...', 'Renting...', 'Reopening...', 'Reorganizing...', 'Repacking...',
|
|
558
|
+
'Repairing...', 'Repaying...', 'Repealing...', 'Repeating...', 'Repelling...', 'Repenting...', 'Repercussing...', 'Replacing...',
|
|
559
|
+
'Replaying...', 'Replenishing...', 'Replicating...', 'Replying...', 'Repointing...', 'Reporting...', 'Reposing...', 'Repositioning...',
|
|
560
|
+
'Representing...', 'Repressing...', 'Reprimanding...', 'Reprinting...', 'Reproaching...', 'Reprocessing...', 'Reproducing...', 'Reprogramming...',
|
|
561
|
+
'Reproving...', 'Repulsing...', 'Repurposing...', 'Requesting...', 'Requiring...', 'Requisitioning...', 'Requiting...', 'Rescinding...',
|
|
562
|
+
'Rescuing...', 'Researching...', 'Reselling...', 'Resembling...', 'Resenting...', 'Reserving...', 'Resetting...', 'Resettling...',
|
|
563
|
+
'Reshaping...', 'Residing...', 'Resigning...', 'Resisting...', 'Reskilling...', 'Resoling...', 'Resolving...', 'Resonating...',
|
|
564
|
+
'Resorting...', 'Resounding...', 'Respecting...', 'Respiring...', 'Responding...', 'Restarting...', 'Restating...', 'Resting...',
|
|
565
|
+
'Restocking...', 'Restoring...', 'Restraining...', 'Restricting...', 'Restructuring...', 'Resulting...', 'Resuming...', 'Resurfacing...',
|
|
566
|
+
'Resurging...', 'Resurrecting...', 'Retailing...', 'Retaining...', 'Retaliating...', 'Retelling...', 'Rethinking...', 'Retiring...',
|
|
567
|
+
'Retorting...', 'Retouching...', 'Retracing...', 'Retracting...', 'Retraining...', 'Retranslating...', 'Retreating...', 'Retrieving...',
|
|
568
|
+
'Retrofitting...', 'Retrying...', 'Returning...', 'Reunifying...', 'Reuniting...', 'Reusing...', 'Revealing...', 'Reveling...',
|
|
569
|
+
'Revenging...', 'Reverberating...', 'Revering...', 'Reversing...', 'Reverting...', 'Reviewing...', 'Reviling...', 'Revising...',
|
|
570
|
+
'Revisiting...', 'Revitalizing...', 'Reviving...', 'Revoking...', 'Revolting...', 'Revolutionizing...', 'Revolving...', 'Rewarding...',
|
|
571
|
+
'Rewiring...', 'Rewording...', 'Reworking...', 'Rewriting...', 'Rhapsodizing...', 'Rhyming...', 'Ribbing...', 'Ridding...',
|
|
572
|
+
'Riding...', 'Riffling...', 'Rifling...', 'Rigging...', 'Righting...', 'Rimming...', 'Ringing...', 'Rinsing...',
|
|
573
|
+
'Rioting...', 'Ripping...', 'Rippling...', 'Rising...', 'Risking...', 'Rivaling...', 'Riveting...', 'Roaming...',
|
|
574
|
+
'Roaring...', 'Roasting...', 'Robbing...', 'Robing...', 'Rocking...', 'Rolling...', 'Romancing...', 'Romanticizing...',
|
|
575
|
+
'Roofing...', 'Rooming...', 'Rooting...', 'Roping...', 'Rorting...', 'Rostering...', 'Rotating...', 'Roting...',
|
|
576
|
+
'Roughening...', 'Rounding...', 'Rousing...', 'Rousting...', 'Roving...', 'Rowing...', 'Rubbing...', 'Ruining...',
|
|
577
|
+
'Ruling...', 'Rumbling...', 'Ruminating...', 'Rummaging...', 'Running...', 'Ruptioning...', 'Rushing...', 'Rusticating...',
|
|
578
|
+
'Rustling...', 'Reticulating...',
|
|
295
579
|
];
|
|
296
580
|
|
|
297
581
|
// ---- ANSI helpers ----
|
|
@@ -301,6 +585,39 @@ function stripAnsi(s) {
|
|
|
301
585
|
.replace(/\x1b\[[0-9;?]* [a-zA-Z]/g, '')
|
|
302
586
|
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
|
|
303
587
|
}
|
|
588
|
+
|
|
589
|
+
// Make UNTRUSTED text safe to paint: file contents from Read, command output from
|
|
590
|
+
// Bash, diff bodies, tool arguments.
|
|
591
|
+
//
|
|
592
|
+
// The goal is to keep the content as ORIGINAL as possible while stopping the
|
|
593
|
+
// terminal from OBEYING it. Only the ESC byte is rewritten — every following byte
|
|
594
|
+
// (the `[31m`, the `[10;20H` parameters) is shown verbatim, so nothing is lost:
|
|
595
|
+
//
|
|
596
|
+
// file contains: ESC [ 3 1 m E R R O R ESC [ 0 m
|
|
597
|
+
// screen shows : ^[ [ 3 1 m E R R O R ^[ [ 0 m
|
|
598
|
+
//
|
|
599
|
+
// A terminal cannot act on a sequence whose ESC has been split into `^[`, so:
|
|
600
|
+
// * `ESC[31m` no longer recolours half the transcript
|
|
601
|
+
// * `ESC[0m` no longer resets the row's styling mid-line
|
|
602
|
+
// * `ESC[10;20H` / `ESC[2J` no longer move the cursor or clear the screen
|
|
603
|
+
//
|
|
604
|
+
// `^[` is the same convention `cat -v`, `less` and `bat` use for ESC, so it reads
|
|
605
|
+
// as "there is an escape here" rather than as content. Tab is kept (the renderer
|
|
606
|
+
// expands it) and newlines are the caller's business; only the dangerous C0
|
|
607
|
+
// controls are defanged, and they are shown as their caret form so they remain
|
|
608
|
+
// visible instead of silently vanishing.
|
|
609
|
+
export function sanitizeText(s) {
|
|
610
|
+
return String(s == null ? '' : s)
|
|
611
|
+
// ESC -> "^[" . The rest of the sequence is untouched.
|
|
612
|
+
.replace(/\x1b/g, '^[')
|
|
613
|
+
// A bare BEL/BS/VT/FF would still be interpreted by some terminals.
|
|
614
|
+
.replace(/\x07/g, '^G')
|
|
615
|
+
.replace(/\x08/g, '^H')
|
|
616
|
+
.replace(/[\x0b\x0c]/g, ' ')
|
|
617
|
+
// Drop NUL and the remaining C0 controls (they have no visible form).
|
|
618
|
+
.replace(/[\x00-\x06\x0e-\x1f\x7f]/g, '');
|
|
619
|
+
}
|
|
620
|
+
|
|
304
621
|
function visualCol(s) { return visualWidth(stripAnsi(String(s))); }
|
|
305
622
|
function col(text, c) { return c + String(text) + C.reset; }
|
|
306
623
|
|
|
@@ -787,7 +1104,12 @@ function keyArgument(name, args, workspace) {
|
|
|
787
1104
|
for (const k of keys) {
|
|
788
1105
|
const v = args[k];
|
|
789
1106
|
if (typeof v !== 'string' || !v.length) continue;
|
|
790
|
-
|
|
1107
|
+
// Neutralise escapes BEFORE measuring: sanitizeText turns ESC into the two
|
|
1108
|
+
// visible characters `^[`, so truncating first and sanitizing after made a
|
|
1109
|
+
// long command (a `node -e "…"` one-liner is the usual case) come out `+1`
|
|
1110
|
+
// column wider than MAX_ARG — and a trailing ESC cut at the boundary left the
|
|
1111
|
+
// row ending in a bare `^` instead of a proper `…`.
|
|
1112
|
+
let text = sanitizeText(v.split('\n')[0]);
|
|
791
1113
|
// Make absolute paths under the workspace relative (shorter + familiar).
|
|
792
1114
|
if ((k === 'path' || k === 'file_path') && path.isAbsolute(text) && workspace) {
|
|
793
1115
|
const rel = path.relative(workspace, text);
|
|
@@ -807,7 +1129,7 @@ function keyArgument(name, args, workspace) {
|
|
|
807
1129
|
// "● Using Read (src/tui.js)" while running, "● Used Read (src/tui.js)" when done.
|
|
808
1130
|
// The verb is plain (white) / green once finished; the tool NAME is always the
|
|
809
1131
|
// theme (cyan) colour; the key argument is dim/gray.
|
|
810
|
-
export function formatToolLine(msg, workspace, spin) {
|
|
1132
|
+
export function formatToolLine(msg, workspace, spin, pulseStart) {
|
|
811
1133
|
const name = msg.toolName || '';
|
|
812
1134
|
const arg = keyArgument(name, msg.toolArgs, workspace);
|
|
813
1135
|
const verb = msg.pending ? 'Using' : 'Used';
|
|
@@ -822,15 +1144,27 @@ export function formatToolLine(msg, workspace, spin) {
|
|
|
822
1144
|
const SWEEP = 6; // 0.5s
|
|
823
1145
|
const HALF = SWEEP + SWEEP; // 12 ticks per colour pair (sweep + sweep back)
|
|
824
1146
|
const CYCLE = HALF * 2; // 24 ticks for the full loop (~2s)
|
|
825
|
-
|
|
1147
|
+
// Phase is measured from the tick the pulse BEGAN (state.pulseStart), not
|
|
1148
|
+
// from 0: the spinner has been running since turn start, so a raw
|
|
1149
|
+
// `spin % CYCLE` dropped the pulse into a mid-cycle colour (often the red
|
|
1150
|
+
// half) the instant the start animation handed over.
|
|
1151
|
+
const inCycle = ((spin - (pulseStart || spin)) % CYCLE + CYCLE) % CYCLE;
|
|
826
1152
|
const inHalf = inCycle % HALF; // 0..23 within this colour pair's half
|
|
827
1153
|
const phase = inCycle < HALF ? 0 : 1; // 0 = l↔c, 1 = c↔b
|
|
828
1154
|
|
|
829
|
-
// Colours as plain RGB triples (mirroring Working's orange→yellow/red pattern)
|
|
830
|
-
//
|
|
1155
|
+
// Colours as plain RGB triples (mirroring Working's orange→yellow/red pattern).
|
|
1156
|
+
// The orange pulse swings HARD — yellow is +100G/+120B off the base and red is
|
|
1157
|
+
// -140G — but this sweep used to move only +64R / -35G, i.e. about a QUARTER of
|
|
1158
|
+
// that amplitude, so "pale cyan" and "cyan-blue" were both nearly
|
|
1159
|
+
// indistinguishable from plain cyan. Match the orange pulse's swing:
|
|
1160
|
+
// pale cyan = base + a strong red lift (toward white)
|
|
1161
|
+
// cyan-blue = base with the green pulled DOWN but not away: green 90 read as
|
|
1162
|
+
// plain blue and clashed with the cyan base, so it is kept at 150
|
|
1163
|
+
// — still clearly darker/cooler than the base, still cyan-family
|
|
1164
|
+
// so each phase is a DIFFERENT colour, not a shade of the same one.
|
|
831
1165
|
const c = [0, 215, 255]; // pure cyan (like ORANGE base)
|
|
832
|
-
const l = [
|
|
833
|
-
const b = [0,
|
|
1166
|
+
const l = [150, 255, 255]; // pale cyan (like YELLOW: base + strong R/G lift)
|
|
1167
|
+
const b = [0, 150, 255]; // cyan-blue (like RED: base with green pulled down)
|
|
834
1168
|
const base = c; // always cyan, like Working's constant ORANGE
|
|
835
1169
|
const target = phase === 0 ? l : b; // pale cyan, then cyan-blue
|
|
836
1170
|
|
|
@@ -883,11 +1217,13 @@ export function formatToolLine(msg, workspace, spin) {
|
|
|
883
1217
|
: bytes >= 1024 ? ` ${(bytes / 1024).toFixed(1)}KB`
|
|
884
1218
|
: ` ${bytes}B`;
|
|
885
1219
|
}
|
|
886
|
-
// Show +xx -xx diff counts for Edit tool when done
|
|
1220
|
+
// Show +xx -xx diff counts for Edit tool when done. The diff itself is rendered
|
|
1221
|
+
// under the `↳` receipt, so prefer the counts the tool_result handler copied over.
|
|
887
1222
|
let diffStr = '';
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
const
|
|
1223
|
+
const edDiff = (Array.isArray(msg.diff) && msg.diff.length) ? msg.diff : msg._diffCounts;
|
|
1224
|
+
if (name === 'Edit' && !msg.pending && Array.isArray(edDiff) && edDiff.length) {
|
|
1225
|
+
const adds = edDiff.filter((d) => d.type === 'add').length;
|
|
1226
|
+
const deletes = edDiff.filter((d) => d.type === 'del').length;
|
|
891
1227
|
if (adds || deletes) {
|
|
892
1228
|
diffStr = ' ' + (adds ? col(`+${adds}`, C.green) : '') + ' ' + (deletes ? col(`-${deletes}`, C.red) : '');
|
|
893
1229
|
}
|
|
@@ -1066,11 +1402,11 @@ export function todoPanelHeight(state) {
|
|
|
1066
1402
|
|
|
1067
1403
|
// Render one chat message into display lines.
|
|
1068
1404
|
// Returns { lines: [{text, ind, color}] } (ind = indent string for continuation).
|
|
1069
|
-
function messageLines(msg, width, workspace, expanded, spin) {
|
|
1405
|
+
function messageLines(msg, width, workspace, expanded, spin, pulseStart) {
|
|
1070
1406
|
// Tool calls render as a single "● Using/Used Name (arg)" line (kimi style).
|
|
1071
1407
|
// The body already carries its own ANSI colours, so mark it pre-colored.
|
|
1072
1408
|
if (msg.role === 'tool') {
|
|
1073
|
-
const body = formatToolLine(msg, workspace, spin);
|
|
1409
|
+
const body = formatToolLine(msg, workspace, spin, pulseStart);
|
|
1074
1410
|
// Red bullet for a failed call (a Bash non-zero exit / [error: …]).
|
|
1075
1411
|
const failed = msg.failed === true || (msg.role === 'tool' && msg.failed === true);
|
|
1076
1412
|
const pre = col('● ', msg.pending ? C.orange : (failed ? C.red : C.green));
|
|
@@ -1085,12 +1421,27 @@ function messageLines(msg, width, workspace, expanded, spin) {
|
|
|
1085
1421
|
const indent = ' ';
|
|
1086
1422
|
const avail = Math.max(1, width - visualCol(indent));
|
|
1087
1423
|
|
|
1424
|
+
// AgentSwarm renders kimi's live progress BLOCK instead of a `↳` output dump:
|
|
1425
|
+
// a header, one cell per subagent, and a status pip bar. The members live on
|
|
1426
|
+
// the message (see the tool_use handler, which registers them as soon as the
|
|
1427
|
+
// args arrive) and update in place while the swarm runs.
|
|
1428
|
+
if (name === 'AgentSwarm' && Array.isArray(msg.swarmMembers) && msg.swarmMembers.length) {
|
|
1429
|
+
const block = renderSwarmProgress({
|
|
1430
|
+
description: msg.swarmDescription || '',
|
|
1431
|
+
model: msg.swarmModel || '',
|
|
1432
|
+
members: msg.swarmMembers,
|
|
1433
|
+
failed: msg.swarmFailed === true,
|
|
1434
|
+
}, width, { indent: ' ', availableGridHeight: 8 });
|
|
1435
|
+
for (const line of block) out.push({ text: line, ind: '', raw: true });
|
|
1436
|
+
return out;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1088
1439
|
// Live output of a RUNNING command (Bash): rendered under the "Using …"
|
|
1089
1440
|
// row exactly like the finished "Used …" output, just not done yet. Only
|
|
1090
1441
|
// the tail is shown (capped) so a chatty command cannot flood the frame.
|
|
1091
1442
|
if (msg.pending && typeof msg.liveOutput === 'string' && msg.liveOutput.length) {
|
|
1092
1443
|
const bodyW = Math.max(1, width - visualCol(' '));
|
|
1093
|
-
const rawLines = msg.liveOutput.replace(/\r\n/g, '\n').split('\n');
|
|
1444
|
+
const rawLines = sanitizeText(msg.liveOutput).replace(/\r\n/g, '\n').split('\n');
|
|
1094
1445
|
const MAX_RUN_LINES = expanded ? Infinity : 8;
|
|
1095
1446
|
const shown = rawLines.slice(-MAX_RUN_LINES); // running: show the newest output
|
|
1096
1447
|
if (rawLines.length > MAX_RUN_LINES) {
|
|
@@ -1103,35 +1454,75 @@ function messageLines(msg, width, workspace, expanded, spin) {
|
|
|
1103
1454
|
}
|
|
1104
1455
|
}
|
|
1105
1456
|
|
|
1457
|
+
// Ctrl+B detach hint (mirrors kimi-code). A foreground Bash/Agent call that
|
|
1458
|
+
// has been running a while advertises the shortcut that moves it to the
|
|
1459
|
+
// background; the hint disappears the moment the result lands. Bash waits
|
|
1460
|
+
// DETACH_HINT_DELAY_MS so short commands never flash it; Agent-like tools
|
|
1461
|
+
// announce it immediately since they are long-running by nature.
|
|
1462
|
+
if (msg.pending && (name === 'Bash' || name === 'Agent' || name === 'AgentSwarm') && msg.startAt) {
|
|
1463
|
+
const waited = Date.now() - msg.startAt;
|
|
1464
|
+
const delay = name === 'Bash' ? DETACH_HINT_DELAY_MS : 0;
|
|
1465
|
+
if (waited >= delay) {
|
|
1466
|
+
out.push({ text: col(DETACH_HINT_TEXT, C.gray), ind: indent, raw: true });
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1106
1470
|
// Write: stream the file content while the model is still emitting it.
|
|
1107
1471
|
if (name === 'Write' && typeof msg.streamContent === 'string' && msg.streamContent.length) {
|
|
1108
|
-
const lines = msg.streamContent.replace(/\r\n/g, '\n').split('\n');
|
|
1472
|
+
const lines = sanitizeText(msg.streamContent).replace(/\r\n/g, '\n').split('\n');
|
|
1109
1473
|
const MAX = 20;
|
|
1110
1474
|
const wNum = String(lines.length).length;
|
|
1111
1475
|
lines.slice(0, MAX).forEach((ln, i) => {
|
|
1112
|
-
out.push({ text: col(`${String(i + 1).padStart(wNum)} ${ln}`, C.gray), ind: indent, raw: true });
|
|
1476
|
+
out.push({ text: col(`${String(i + 1).padStart(wNum)} ${ln}`, C.gray), ind: i === 0 ? '↳ ' : indent, raw: true });
|
|
1113
1477
|
});
|
|
1114
1478
|
if (lines.length > MAX) out.push({ text: col(`… ${lines.length - MAX} more lines`, C.gray), ind: indent, raw: true });
|
|
1115
1479
|
}
|
|
1116
1480
|
|
|
1117
|
-
// Edit:
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1481
|
+
// The Edit diff is NOT drawn here: it rides on the tool_result message and is
|
|
1482
|
+
// rendered BELOW the `↳` receipt (see the tool_result branch). Drawing it here
|
|
1483
|
+
// put it between the `● Used Edit` status bullet and the receipt.
|
|
1484
|
+
return out;
|
|
1485
|
+
}
|
|
1486
|
+
// A PLAN block (Plan mode): the model's <plan>…</plan> body, rendered as a
|
|
1487
|
+
// single-column TABLE — the same ╭─┬─╮ / ├─┼─┤ / ╰─┴─╯ vocabulary the Markdown
|
|
1488
|
+
// table renderer uses, so a plan reads as structured data rather than as prose
|
|
1489
|
+
// or as a second composer box.
|
|
1490
|
+
if (msg.role === 'plan') {
|
|
1491
|
+
// `width` is the transcript's inner width (the caller already removed the
|
|
1492
|
+
// one-column margin); the wrapped user boxes land at `width - 2`.
|
|
1493
|
+
const tableW = Math.max(12, width - 2);
|
|
1494
|
+
const inner = Math.max(1, tableW - 2); // between the two │
|
|
1495
|
+
// NOTE: rows must be {text, ind, raw} OBJECTS — messageLines' contract.
|
|
1496
|
+
// Pushing plain strings here made rowToLine read `undefined` and the whole
|
|
1497
|
+
// block rendered as blank lines.
|
|
1498
|
+
const out = [];
|
|
1499
|
+
const bar = (ch) => col(ch, C.border);
|
|
1500
|
+
const full = (l, r) => bar(l + '─'.repeat(inner) + r);
|
|
1501
|
+
// Header: the table's single column is captioned "Plan".
|
|
1502
|
+
out.push({ text: full('╭', '╮'), ind: '', raw: true });
|
|
1503
|
+
out.push({ text: bar('│') + fitAnsi(col(' Plan', C.cyan + C.bold), inner) + bar('│'), ind: '', raw: true });
|
|
1504
|
+
out.push({ text: bar('├' + '─'.repeat(inner) + '┤'), ind: '', raw: true });
|
|
1505
|
+
// Body rows.
|
|
1506
|
+
const lines = String(msg.text || '').replace(/\r\n/g, '\n').split('\n');
|
|
1507
|
+
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
|
1508
|
+
const textW = Math.max(1, inner - 2);
|
|
1509
|
+
for (const raw of lines) {
|
|
1510
|
+
if (raw.trim() === '') {
|
|
1511
|
+
out.push({ text: bar('│') + ' '.repeat(inner) + bar('│'), ind: '', raw: true });
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
// Headings are emphasised; everything else renders as Markdown body text so
|
|
1515
|
+
// **bold**, `code` and bullets work.
|
|
1516
|
+
const isHead = /^\s*#{1,6}\s/.test(raw);
|
|
1517
|
+
const shown = isHead ? raw.replace(/^\s*#{1,6}\s*/, '') : raw;
|
|
1518
|
+
for (const r of renderMdText(shown, textW, isHead ? (C.cyan + C.bold) : C.white, C.cyan)) {
|
|
1519
|
+
out.push({ text: bar('│') + ' ' + fitAnsi(r, textW) + ' ' + bar('│'), ind: '', raw: true });
|
|
1129
1520
|
}
|
|
1130
|
-
if (changed.length > MAX) out.push({ text: col(`… ${changed.length - MAX} more changed lines`, C.gray), ind: indent, raw: true });
|
|
1131
|
-
if (changed.length === 0) out.push({ text: col('(no changes)', C.gray), ind: indent, raw: true });
|
|
1132
1521
|
}
|
|
1522
|
+
out.push({ text: full('╰', '╯'), ind: '', raw: true });
|
|
1133
1523
|
return out;
|
|
1134
1524
|
}
|
|
1525
|
+
|
|
1135
1526
|
// Tool output: a single "↳ " marker on the first line, then the output
|
|
1136
1527
|
// indented by 2 columns. Long output is capped (kimi caps result previews
|
|
1137
1528
|
// too) so one command cannot flood the whole transcript.
|
|
@@ -1143,7 +1534,11 @@ function messageLines(msg, width, workspace, expanded, spin) {
|
|
|
1143
1534
|
const pre = '↳ ';
|
|
1144
1535
|
const cont = ' ';
|
|
1145
1536
|
const bodyW = Math.max(1, width - visualCol(cont));
|
|
1146
|
-
|
|
1537
|
+
// Strip escapes BEFORE splitting: a file or a command can emit raw SGR /
|
|
1538
|
+
// cursor codes, and letting them through made the terminal OBEY them —
|
|
1539
|
+
// recolouring the transcript, resetting styles mid-row, moving the cursor or
|
|
1540
|
+
// clearing the screen over the composer.
|
|
1541
|
+
const rawLines = sanitizeText(msg.text).replace(/\r\n/g, '\n').split('\n');
|
|
1147
1542
|
// Trim blank lines at BOTH ends: commands typically emit a trailing
|
|
1148
1543
|
// newline, and some tools emit a leading one. A stray blank line would
|
|
1149
1544
|
// render as a lone "↳ " with no content.
|
|
@@ -1157,18 +1552,71 @@ function messageLines(msg, width, workspace, expanded, spin) {
|
|
|
1157
1552
|
const visible = rawLines.filter((ln) => !/^\[exit code:/.test(ln.trim()));
|
|
1158
1553
|
const failed = msg.failed === true;
|
|
1159
1554
|
const shown = visible.slice(0, MAX_RESULT_LINES);
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1555
|
+
|
|
1556
|
+
// ---- DISPLAY ORDER -----------------------------------------------------
|
|
1557
|
+
// The row reads as a block under the tool line, with ONE `↳` marker:
|
|
1558
|
+
// ● Used Edit (a.js) +1 -1 <- status bullet
|
|
1559
|
+
// ↳ 3 - old line <- `↳` rides the FIRST body row
|
|
1560
|
+
// 3 + new line
|
|
1561
|
+
// Edited a.js: replaced 1 occurrence(s). <- receipt LAST
|
|
1562
|
+
// For an Edit the DIFF is the body (and carries the marker); the receipt text
|
|
1563
|
+
// moves to the bottom. For every other tool the tool output IS the body, so
|
|
1564
|
+
// the marker sits on its first line, exactly as before.
|
|
1565
|
+
const diffRows = [];
|
|
1566
|
+
const receiptRows = [];
|
|
1567
|
+
const hasDiff = Array.isArray(msg.diff); // empty array => receipt-only (Write)
|
|
1568
|
+
|
|
1569
|
+
if (hasDiff) {
|
|
1570
|
+
// Same collapse rule as the tool output and thinking blocks above: a short
|
|
1571
|
+
// preview by default, everything once Ctrl+O expands. 16 rows (not 12)
|
|
1572
|
+
// because a change renders as a PAIR: `- old` then `+ new`.
|
|
1573
|
+
const MAX = expanded ? Infinity : 16;
|
|
1574
|
+
const changed = msg.diff.filter((d) => d.type !== 'ctx');
|
|
1575
|
+
const wNo = Math.max(1, ...changed.map((d) => String(d.no || 0).length));
|
|
1576
|
+
for (const d of changed.slice(0, MAX)) {
|
|
1577
|
+
const no = String(d.no || 0).padStart(wNo);
|
|
1578
|
+
const mark = d.type === 'add' ? '+' : '-';
|
|
1579
|
+
const color = d.type === 'add' ? C.green : C.red;
|
|
1580
|
+
// Diff lines are FILE content and can carry escapes; show them as `^[`.
|
|
1581
|
+
diffRows.push({ text: col(`${no} ${mark} ${sanitizeText(d.text)}`, color), ind: cont, raw: true });
|
|
1582
|
+
}
|
|
1583
|
+
if (changed.length > MAX) {
|
|
1584
|
+
diffRows.push({
|
|
1585
|
+
text: col(`… (${changed.length - MAX} more changed lines, ctrl+o to expand)`, C.gray),
|
|
1586
|
+
ind: cont, raw: true,
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
if (msg.diff.length > 0 && changed.length === 0) diffRows.push({ text: col('(no changes)', C.gray), ind: cont, raw: true });
|
|
1590
|
+
// The receipt: one line per source line of the tool's own message.
|
|
1591
|
+
for (const ln of shown) {
|
|
1592
|
+
const color = failed ? C.red : C.gray;
|
|
1593
|
+
for (const t of (ln === '' ? [''] : wrapWords(ln, bodyW))) {
|
|
1594
|
+
receiptRows.push({ text: t, ind: cont, color });
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
} else {
|
|
1598
|
+
// No diff: the tool's own output is the body.
|
|
1599
|
+
shown.forEach((ln, idx) => {
|
|
1600
|
+
const isErr = /^\[error:/.test(ln.trim());
|
|
1601
|
+
// A failed run is red throughout so it cannot be skimmed past.
|
|
1602
|
+
const color = isErr ? C.red : (failed ? C.red : C.gray);
|
|
1603
|
+
const wrapped = ln === '' ? [''] : wrapWords(ln, bodyW);
|
|
1604
|
+
wrapped.forEach((t, i) => {
|
|
1605
|
+
diffRows.push({ text: t, ind: idx === 0 && i === 0 ? pre : cont, color });
|
|
1606
|
+
});
|
|
1168
1607
|
});
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1608
|
+
if (visible.length > MAX_RESULT_LINES) {
|
|
1609
|
+
diffRows.push({ text: `… ${visible.length - MAX_RESULT_LINES} more lines`, ind: cont, color: failed ? C.red : C.gray });
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
const out = [];
|
|
1614
|
+
for (const r of diffRows) out.push(r);
|
|
1615
|
+
for (const r of receiptRows) out.push(r);
|
|
1616
|
+
// Put the `↳` on the FIRST rendered row (diff or output) — never on the
|
|
1617
|
+
// receipt, which now sits last.
|
|
1618
|
+
if (out.length && out[0].ind === cont && Array.isArray(msg.diff) && msg.diff.length > 0) {
|
|
1619
|
+
out[0] = { ...out[0], ind: pre };
|
|
1172
1620
|
}
|
|
1173
1621
|
// Nothing to show (empty output) -> render no row at all rather than a
|
|
1174
1622
|
// dangling "↳ ".
|
|
@@ -1189,15 +1637,23 @@ function messageLines(msg, width, workspace, expanded, spin) {
|
|
|
1189
1637
|
for (const w of wrapWords(ln, bodyW)) contentLines.push(w);
|
|
1190
1638
|
}
|
|
1191
1639
|
const PREVIEW = expanded ? Infinity : 2;
|
|
1640
|
+
// The reasoning BODY is italic; the "thinking…" label and the "N more lines"
|
|
1641
|
+
// hint are NOT. Closed with endItalic rather than reset, so the row keeps the
|
|
1642
|
+
// dim gray it is drawn in.
|
|
1643
|
+
const it = (s) => (s === '' ? s : C.italic + s + C.endItalic);
|
|
1192
1644
|
const out = [];
|
|
1193
1645
|
if (msg.pending) {
|
|
1194
|
-
|
|
1195
|
-
|
|
1646
|
+
// The spinner frame is NOT baked in here: a glyph that changes every 80ms
|
|
1647
|
+
// tick would invalidate this row's cache and re-wrap the whole reasoning
|
|
1648
|
+
// block each time (measured ~44ms/frame vs ~6ms warm), which is what made
|
|
1649
|
+
// long thinking look laggy while ordinary streamed text stayed smooth.
|
|
1650
|
+
// renderChatLines substitutes the live frame outside the cache instead.
|
|
1651
|
+
out.push({ text: SPIN_PLACEHOLDER + ' thinking…', ind: '', color: C.gray });
|
|
1196
1652
|
const vis = contentLines.length > PREVIEW ? contentLines.slice(-PREVIEW) : contentLines;
|
|
1197
|
-
for (const l of vis) out.push({ text: l, ind: INDENT, color: C.gray });
|
|
1653
|
+
for (const l of vis) out.push({ text: it(l), ind: INDENT, color: C.gray });
|
|
1198
1654
|
} else {
|
|
1199
1655
|
const shown = contentLines.slice(0, PREVIEW);
|
|
1200
|
-
shown.forEach((l, i) => out.push({ text: l, ind: i === 0 ? '● ' : INDENT, color: C.gray }));
|
|
1656
|
+
shown.forEach((l, i) => out.push({ text: it(l), ind: i === 0 ? '● ' : INDENT, color: C.gray }));
|
|
1201
1657
|
if (contentLines.length > PREVIEW) {
|
|
1202
1658
|
out.push({ text: `… (${contentLines.length - PREVIEW} more lines, ctrl+o to expand)`, ind: INDENT, color: C.gray });
|
|
1203
1659
|
}
|
|
@@ -1206,6 +1662,7 @@ function messageLines(msg, width, workspace, expanded, spin) {
|
|
|
1206
1662
|
return out;
|
|
1207
1663
|
}
|
|
1208
1664
|
|
|
1665
|
+
|
|
1209
1666
|
let icon = '';
|
|
1210
1667
|
switch (msg.role) {
|
|
1211
1668
|
case 'system': icon = ''; break;
|
|
@@ -1555,24 +2012,35 @@ function renderChatLines(state, w) {
|
|
|
1555
2012
|
// The row WIDTH is keyed (not `w`) because the rows are laid out to it:
|
|
1556
2013
|
// `boxText` for a bordered message (inside the box padding), `innerW` otherwise.
|
|
1557
2014
|
const rowW = bordered ? boxText : innerW;
|
|
1558
|
-
|
|
1559
|
-
|
|
2015
|
+
// The cache key MUST include everything messageLines() reads. `spin` is
|
|
2016
|
+
// keyed ONLY for a pending TOOL row: formatToolLine sweeps the tool name's
|
|
2017
|
+
// colour with it. The pending THINKING row deliberately does not key it —
|
|
2018
|
+
// its spinner is substituted outside the cache (see SPIN_PLACEHOLDER), so
|
|
2019
|
+
// the 80ms tick no longer re-wraps the reasoning preview every frame.
|
|
2020
|
+
const spinKey = (msg.role === 'tool' && msg.pending) ? (state.spin || 0) : 0;
|
|
2021
|
+
const key = `${rowW}\u0000${expanded ? 1 : 0}\u0000${spinKey}\u0000${msg.text || ''}\u0000${msg.streamContent || ''}`
|
|
2022
|
+
+ `\u0000${msg.liveOutput ? msg.liveOutput.length : 0}\u0000${msg.failed ? 1 : 0}\u0000${JSON.stringify(msg.toolArgs || null)}`
|
|
2023
|
+
+ `\u0000${msg.diff ? msg.diff.length : 0}\u0000${msg._diffCounts ? msg._diffCounts.length : 0}`;
|
|
1560
2024
|
let cached = msg._cache;
|
|
1561
2025
|
if (!cached || cached.key !== key) {
|
|
1562
|
-
const rows = messageLines(msg, rowW, state.cwd, expanded, state.spin);
|
|
2026
|
+
const rows = messageLines(msg, rowW, state.cwd, expanded, state.spin, state.pulseStart);
|
|
1563
2027
|
cached = { key, rows: rows.map(rowToLine) };
|
|
1564
2028
|
msg._cache = cached;
|
|
1565
2029
|
}
|
|
2030
|
+
// Swap the live spinner frame into any row that carries the placeholder.
|
|
2031
|
+
// Done AFTER the cache, so the glyph animates without invalidating it.
|
|
2032
|
+
const spinFrame = SPINNER[(state.spin || 0) % SPINNER.length];
|
|
2033
|
+
const live = (r) => (r.indexOf(SPIN_PLACEHOLDER) >= 0 ? r.split(SPIN_PLACEHOLDER).join(spinFrame) : r);
|
|
1566
2034
|
// The margin is added here (outside the cache) so it is not baked into the
|
|
1567
2035
|
// cached rows. A bordered message also gets its side walls, laid out to the
|
|
1568
2036
|
// box's inner width so it lines up with the composer exactly.
|
|
1569
2037
|
if (bordered) {
|
|
1570
2038
|
const pad = ' '.repeat(boxPad);
|
|
1571
2039
|
for (const r of cached.rows) {
|
|
1572
|
-
out.push(PAD + col('│', C.border) + pad + fitAnsi(r, boxText) + pad + col('│', C.border));
|
|
2040
|
+
out.push(PAD + col('│', C.border) + pad + fitAnsi(live(r), boxText) + pad + col('│', C.border));
|
|
1573
2041
|
}
|
|
1574
2042
|
} else {
|
|
1575
|
-
for (const r of cached.rows) out.push(PAD + r);
|
|
2043
|
+
for (const r of cached.rows) out.push(PAD + live(r));
|
|
1576
2044
|
}
|
|
1577
2045
|
if (bordered && out[out.length - 1] !== botRule) out.push(botRule);
|
|
1578
2046
|
}
|
|
@@ -1593,8 +2061,10 @@ function lineToString(line) {
|
|
|
1593
2061
|
}
|
|
1594
2062
|
|
|
1595
2063
|
function modeLabel(mode) {
|
|
1596
|
-
|
|
1597
|
-
|
|
2064
|
+
// Short, uniform names — the long forms ("Always Ask", "Ask When Needed",
|
|
2065
|
+
// "Never Ask") made the status group wrap awkwardly next to Plan/Focus.
|
|
2066
|
+
if (mode === 'auto') return 'Auto';
|
|
2067
|
+
if (mode === 'yolo') return 'Yolo';
|
|
1598
2068
|
return 'Ask';
|
|
1599
2069
|
}
|
|
1600
2070
|
function trimDecimal(v) {
|
|
@@ -1626,7 +2096,10 @@ function usagePercent(used, max) {
|
|
|
1626
2096
|
}
|
|
1627
2097
|
|
|
1628
2098
|
// Human-readable elapsed duration, omitting zero leading units:
|
|
1629
|
-
//
|
|
2099
|
+
// 1y 1mon 1d 1h 1min 1s / 2y 2mon 2d 2h 2min 2s
|
|
2100
|
+
// Units are NEVER pluralised with a trailing "s": the old code rendered 5
|
|
2101
|
+
// minutes as "5ms", which reads as 5 MILLISECONDS (and 2 hours as "2hs").
|
|
2102
|
+
// Minutes are "m", so months use "mon" to stay unambiguous.
|
|
1630
2103
|
function fmtDuration(ms) {
|
|
1631
2104
|
ms = Math.max(0, Math.floor(ms));
|
|
1632
2105
|
const sec = Math.floor(ms / 1000) % 60;
|
|
@@ -1636,14 +2109,14 @@ function fmtDuration(ms) {
|
|
|
1636
2109
|
const mon = Math.floor(ms / 2592000000) % 12;
|
|
1637
2110
|
const year = Math.floor(ms / 31536000000);
|
|
1638
2111
|
const parts = [];
|
|
1639
|
-
if (year) parts.push(year + 'y'
|
|
1640
|
-
if (mon) parts.push(mon + '
|
|
1641
|
-
if (day) parts.push(day + 'd'
|
|
1642
|
-
if (hour) parts.push(hour + 'h'
|
|
1643
|
-
if (min) parts.push(min + 'm'
|
|
2112
|
+
if (year) parts.push(year + 'y');
|
|
2113
|
+
if (mon) parts.push(mon + 'mon');
|
|
2114
|
+
if (day) parts.push(day + 'd');
|
|
2115
|
+
if (hour) parts.push(hour + 'h');
|
|
2116
|
+
if (min) parts.push(min + 'm');
|
|
1644
2117
|
// Seconds are always shown (at least "0s").
|
|
1645
2118
|
parts.push(sec + 's');
|
|
1646
|
-
return parts.join('');
|
|
2119
|
+
return parts.join(' ');
|
|
1647
2120
|
}
|
|
1648
2121
|
|
|
1649
2122
|
// Left/right justify: left text, then padding, then right text flush to `w`.
|
|
@@ -1669,21 +2142,82 @@ export function composerHeight(state, cols) {
|
|
|
1669
2142
|
return composerLayout(state, insideW - 3).rows.length + 2;
|
|
1670
2143
|
}
|
|
1671
2144
|
|
|
2145
|
+
// UNCLAMPED height of the AskUserQuestion box. `composeFrame` caps the real
|
|
2146
|
+
// height to the space available (see `questionH`), and the renderer drops the
|
|
2147
|
+
// hint row / scrolls the option list to fit that cap. This is therefore the
|
|
2148
|
+
// height wanted when there IS room.
|
|
2149
|
+
// Rows: top border + header + wrapped question + one per row + hint/entry + bottom
|
|
2150
|
+
export function questionBoxHeight(state, insideW) {
|
|
2151
|
+
const qs = state.question;
|
|
2152
|
+
if (!qs) return 0;
|
|
2153
|
+
const cur = qs.items[qs.index] || { question: '', options: [] };
|
|
2154
|
+
const innerW = Math.max(1, insideW - 2);
|
|
2155
|
+
const isLast = qs.index === qs.items.length - 1;
|
|
2156
|
+
const optRows = (cur.options || []).length + 1 + (isLast ? 1 : 0); // + Other (+ supplement)
|
|
2157
|
+
const qRows = Math.max(1, wrapWords(cur.question, Math.max(1, innerW - 2)).length);
|
|
2158
|
+
return 2 + 1 + qRows + optRows + 1;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// The height the box ACTUALLY emits, given the terminal height `h` and the rows
|
|
2162
|
+
// already taken by other chrome. Mirrors composeFrame's cap + the renderer's
|
|
2163
|
+
// fit logic, so geometry and painting cannot disagree.
|
|
2164
|
+
export function questionBoxClampedHeight(state, insideW, h, chromeH) {
|
|
2165
|
+
const want = questionBoxHeight(state, insideW);
|
|
2166
|
+
if (!want) return 0;
|
|
2167
|
+
const cap = Math.max(1, h - chromeH - 1);
|
|
2168
|
+
const q = Math.min(want, cap);
|
|
2169
|
+
const qs = state.question;
|
|
2170
|
+
const cur = qs.items[qs.index] || { question: '', options: [] };
|
|
2171
|
+
const innerW = Math.max(1, insideW - 2);
|
|
2172
|
+
const isLast = qs.index === qs.items.length - 1;
|
|
2173
|
+
const optRows = (cur.options || []).length + 1 + (isLast ? 1 : 0);
|
|
2174
|
+
const qRows = Math.max(1, wrapWords(cur.question, Math.max(1, innerW - 2)).length);
|
|
2175
|
+
const boxBudget = q - 2 - qRows; // room for options (+hint)
|
|
2176
|
+
const showHint = boxBudget >= optRows + 1;
|
|
2177
|
+
const optShown = Math.min(optRows, Math.max(0, boxBudget - (showHint ? 1 : 0)));
|
|
2178
|
+
return 2 + qRows + optShown + (showHint ? 1 : 0);
|
|
2179
|
+
}
|
|
2180
|
+
|
|
1672
2181
|
// ---- scrollbar (Codewhale's TranscriptScrollbar) ---------------------------
|
|
1673
2182
|
// Geometry for a vertical scrollbar over the transcript body. `scroll` is the
|
|
1674
2183
|
// number of lines scrolled UP from the bottom (0 = pinned to newest). Returns
|
|
1675
2184
|
// the thumb's row range within the body (0-based) so the painter and the mouse
|
|
1676
2185
|
// hit-test share one source of truth.
|
|
2186
|
+
//
|
|
2187
|
+
// NOTE on precision: the terminal's mouse protocol reports INTEGER rows, so a
|
|
2188
|
+
// drag can only address `bodyH` distinct positions. On a long transcript that is
|
|
2189
|
+
// inherently coarse (100k lines over 24 rows = ~4.3k lines per row) and no
|
|
2190
|
+
// formula fixes it — fine control comes from the wheel / keyboard instead (see
|
|
2191
|
+
// the `sbFine` handling in handleKey). What this function DOES guarantee is that
|
|
2192
|
+
// the thumb's travel is monotonic, uses every row, and lands exactly on both
|
|
2193
|
+
// ends; `thumbLenF` is kept unrounded purely so the grab offset inside the thumb
|
|
2194
|
+
// is not quantised to a whole row when the thumb is 10+ rows tall.
|
|
1677
2195
|
export function scrollbarGeometry({ total, bodyH, scroll }) {
|
|
1678
2196
|
const maxScroll = Math.max(0, total - bodyH);
|
|
1679
2197
|
const pos = Math.min(maxScroll, Math.max(0, scroll || 0));
|
|
1680
2198
|
// Thumb length proportional to the visible fraction, at least 1 row.
|
|
1681
|
-
const
|
|
2199
|
+
const thumbLenF = Math.max(1, bodyH * (bodyH / Math.max(1, total)));
|
|
2200
|
+
const thumb = Math.max(1, Math.round(thumbLenF));
|
|
1682
2201
|
// 0 = bottom (pinned), maxScroll = top. Row 0 is the TOP of the body.
|
|
1683
2202
|
const fromTop = maxScroll === 0 ? 0 : (maxScroll - pos) / maxScroll;
|
|
1684
|
-
const
|
|
1685
|
-
|
|
1686
|
-
|
|
2203
|
+
const maxThumbStartF = Math.max(0, bodyH - thumbLenF);
|
|
2204
|
+
// Round once, at the end. Using `bodyH - thumb` (rounded) here made the last
|
|
2205
|
+
// row unreachable when the thumb was 1 row and bodyH was even.
|
|
2206
|
+
const thumbStart = Math.round(fromTop * maxThumbStartF);
|
|
2207
|
+
return { thumbStart, thumbLen: thumb, thumbLenF, maxThumbStartF, bodyH, total, maxScroll };
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
// Map a position on the track to a transcript scroll offset. `rowF` is the mouse
|
|
2211
|
+
// row (fractional allowed) and `grabOffset` is where inside the thumb the drag
|
|
2212
|
+
// started, in rows, so the thumb does not jump under the pointer on press.
|
|
2213
|
+
// Exported so the drag handler and its tests share ONE definition of the mapping.
|
|
2214
|
+
export function scrollFromThumbPos(g, rowF, grabOffset = 0) {
|
|
2215
|
+
if (!g) return 0;
|
|
2216
|
+
const track = Math.max(1e-6, g.maxThumbStartF != null
|
|
2217
|
+
? g.maxThumbStartF
|
|
2218
|
+
: Math.max(1, g.bodyH - (g.thumbLenF != null ? g.thumbLenF : g.thumbLen)));
|
|
2219
|
+
const frac = Math.max(0, Math.min(1, (rowF - grabOffset) / track));
|
|
2220
|
+
return Math.round((1 - frac) * g.maxScroll);
|
|
1687
2221
|
}
|
|
1688
2222
|
|
|
1689
2223
|
// Paint the scrollbar glyph into the LAST column of a body row. The row already
|
|
@@ -1834,9 +2368,28 @@ function sliceAnsi(s, width) {
|
|
|
1834
2368
|
export function composeFrame(state, cols, rows) {
|
|
1835
2369
|
const w = Math.max(20, cols | 0);
|
|
1836
2370
|
const h = Math.max(12, rows | 0);
|
|
2371
|
+
// Full-screen takeovers (kimi's screen-takeover): the /tasks browser and its
|
|
2372
|
+
// output viewer own the ENTIRE screen, so they short-circuit the normal
|
|
2373
|
+
// layout ? no chat, no composer, no status bar.
|
|
2374
|
+
if (state.tasksViewer) {
|
|
2375
|
+
const lines = renderTaskOutputViewer(state.tasksViewer, w, h);
|
|
2376
|
+
return { ansi: '', lines, cursor: { row: 0, col: 0 }, cursorVisible: false,
|
|
2377
|
+
width: w, height: h, cursorRow: 0, cursorCol: 0, cursorShape: hideCursor(), hitboxes: [], composerMeta: [] };
|
|
2378
|
+
}
|
|
2379
|
+
if (state.tasksPanel) {
|
|
2380
|
+
const lines = renderTasksBrowser(state.tasksPanel, w, h);
|
|
2381
|
+
return { ansi: '', lines, cursor: { row: 0, col: 0 }, cursorVisible: false,
|
|
2382
|
+
width: w, height: h, cursorRow: 0, cursorCol: 0, cursorShape: hideCursor(), hitboxes: [], composerMeta: [] };
|
|
2383
|
+
}
|
|
1837
2384
|
const insideW = Math.max(0, w - 2);
|
|
1838
2385
|
|
|
1839
|
-
|
|
2386
|
+
// A full-screen overlay: it takes the whole body and the composer is hidden.
|
|
2387
|
+
// `state.editor` MUST be in here — it renders into `lines` first, but without
|
|
2388
|
+
// this the `if (!dialog)` composer block further down painted the input box
|
|
2389
|
+
// straight over it, so /personal and /set-system-prompt showed only a caret.
|
|
2390
|
+
// The question prompt is deliberately NOT a dialog: it is a box above the
|
|
2391
|
+
// composer, so the transcript stays visible (see the `state.question` block).
|
|
2392
|
+
const dialog = state.editor || state.picker || state.form || state.panel || null;
|
|
1840
2393
|
|
|
1841
2394
|
// ---- geometry ----
|
|
1842
2395
|
const composer = composerLayout(state, insideW - 3); // bodyW = (insideW-3)-2 = 53, matches fitAnsi(textW-2) = cInner-4 = 53
|
|
@@ -1844,12 +2397,29 @@ export function composeFrame(state, cols, rows) {
|
|
|
1844
2397
|
const menuItems = (state.menuOpen && state.menuList.length && !dialog)
|
|
1845
2398
|
? Math.min(state.menuList.length, MAX_MENU) + 1
|
|
1846
2399
|
: 0;
|
|
2400
|
+
// The notice no longer reserves a row: it is drawn ON the context row (see the
|
|
2401
|
+
// bottom of this function), so `noticeH` only selects the colour there.
|
|
1847
2402
|
const noticeH = state.notice ? 1 : 0;
|
|
1848
2403
|
const confirmH = state.confirmExit ? 1 : 0;
|
|
1849
2404
|
const workingH = (!dialog && state.running) ? 1 : 0;
|
|
1850
2405
|
const todoH = dialog ? 0 : todoPanelHeight(state);
|
|
1851
2406
|
const queueH = dialog ? 0 : queuePanelHeight(state);
|
|
1852
|
-
|
|
2407
|
+
// The question box is chrome above the composer, so its rows must come OUT of
|
|
2408
|
+
// the transcript area — otherwise the box would overlap the chat.
|
|
2409
|
+
//
|
|
2410
|
+
// On a short terminal the box can be taller than the whole space available. It
|
|
2411
|
+
// must NOT simply overflow: the overflow is trimmed from the TOP (see the
|
|
2412
|
+
// topTrim below), which eats the box's own top border and header and leaves a
|
|
2413
|
+
// dangling `│`. Cap it so the box plus the composer always fit, and let the
|
|
2414
|
+
// transcript shrink to zero instead.
|
|
2415
|
+
const chromeH = todoH + queueH + workingH + composerBoxH + menuItems + STATUS_H + confirmH + CTX_H;
|
|
2416
|
+
// NOTE the extra `- 1`: `bodyH` below is clamped to a MINIMUM of 1 row, so that
|
|
2417
|
+
// row has to be reserved here too. Without it the total came out one over and
|
|
2418
|
+
// the top trim ate the box's `╭` border.
|
|
2419
|
+
const questionH = (!dialog && state.question)
|
|
2420
|
+
? Math.min(questionBoxHeight(state, insideW), Math.max(1, h - chromeH - 1))
|
|
2421
|
+
: 0;
|
|
2422
|
+
const bottomH = chromeH + questionH;
|
|
1853
2423
|
const bodyH = Math.max(1, h - bottomH);
|
|
1854
2424
|
|
|
1855
2425
|
const lines = [];
|
|
@@ -1868,8 +2438,15 @@ export function composeFrame(state, cols, rows) {
|
|
|
1868
2438
|
const hint = ed.hint || 'Ctrl+S save · Esc cancel · Enter newline';
|
|
1869
2439
|
lines.push(col(hint, C.gray));
|
|
1870
2440
|
lines.push('');
|
|
1871
|
-
// Viewport height for the editor body
|
|
1872
|
-
|
|
2441
|
+
// Viewport height for the editor body. The editor's own rows are:
|
|
2442
|
+
// title + rule + hint + blank (4, pushed above)
|
|
2443
|
+
// viewH body rows (the text)
|
|
2444
|
+
// line-info (+ optional notice) + rule (2 or 3, pushed below)
|
|
2445
|
+
// so `viewH` must leave room for 6 (or 7 with a notice), NOT 5 — one row too
|
|
2446
|
+
// many made the frame one line over `h` and the top trim cut the editor's own
|
|
2447
|
+
// title and text, leaving just a bare caret on /personal & /set-system-prompt.
|
|
2448
|
+
const edFixed = ed.notice ? 7 : 6;
|
|
2449
|
+
const viewH = Math.max(1, bodyH - edFixed);
|
|
1873
2450
|
// Keep the caret row in view.
|
|
1874
2451
|
let top = Math.max(0, ed.top || 0);
|
|
1875
2452
|
if (ed.caretRow < top) top = ed.caretRow;
|
|
@@ -1885,9 +2462,13 @@ export function composeFrame(state, cols, rows) {
|
|
|
1885
2462
|
lines.push(col(fitAnsi(` line ${ed.caretRow + 1}/${textLines.length} · ${textLines.length} lines`, w), C.gray));
|
|
1886
2463
|
if (ed.notice) lines.push(col(fitAnsi(' ' + ed.notice, w), ed.noticeKind === 'error' ? C.red : C.green));
|
|
1887
2464
|
lines.push(rule);
|
|
1888
|
-
// Caret position = caret column within the visible window.
|
|
2465
|
+
// Caret position = caret column within the visible window. The `+ 4` is the
|
|
2466
|
+
// four rows pushed above the body (title, rule, hint, blank) — it was `3`
|
|
2467
|
+
// (i.e. the title / rule / hint / blank rows miscounted as three), so the
|
|
2468
|
+
// block caret was drawn ONE ROW ABOVE the line it was editing on /personal
|
|
2469
|
+
// and /set-system-prompt.
|
|
1889
2470
|
dialogCaret = {
|
|
1890
|
-
row:
|
|
2471
|
+
row: 4 + Math.max(0, Math.min(viewH - 1, ed.caretRow - top)),
|
|
1891
2472
|
col: Math.min(w - 1, visualCol(expandTabs(textLines[ed.caretRow] || '').slice(0, ed.caretCol))),
|
|
1892
2473
|
};
|
|
1893
2474
|
while (lines.length < bodyH) lines.push(' '.repeat(w));
|
|
@@ -1959,7 +2540,99 @@ export function composeFrame(state, cols, rows) {
|
|
|
1959
2540
|
for (const b of body) lines.push(b);
|
|
1960
2541
|
while (lines.length < bodyH) lines.push(' '.repeat(w));
|
|
1961
2542
|
if (dialogCaret) dialogCaret.row += padTop;
|
|
1962
|
-
}
|
|
2543
|
+
}
|
|
2544
|
+
// AskUserQuestion prompt: rendered as a BOX sitting directly above the
|
|
2545
|
+
// composer — the same treatment as the approval prompt — instead of a
|
|
2546
|
+
// full-screen dialog. It is a prompt about the turn, not a separate screen, and
|
|
2547
|
+
// taking over the whole viewport hid the conversation the question refers to.
|
|
2548
|
+
if (state.question && !dialog) {
|
|
2549
|
+
const qs = state.question;
|
|
2550
|
+
const cur = qs.items[qs.index] || { question: '', options: [] };
|
|
2551
|
+
const promptW = insideW;
|
|
2552
|
+
const innerW = Math.max(1, promptW - 2);
|
|
2553
|
+
const bar = (ch) => col(ch, C.border);
|
|
2554
|
+
const boxRow = (content) => bar('│') + ' ' + fitAnsi(content, innerW) + ' ' + bar('│');
|
|
2555
|
+
const counter = qs.items.length > 1 ? ` ${qs.index + 1}/${qs.items.length}` : '';
|
|
2556
|
+
lines.push(bar('╭' + '─'.repeat(promptW) + '╮'));
|
|
2557
|
+
// Body rows are built into `box` FIRST so the whole thing can be clamped to
|
|
2558
|
+
// `questionH` before any of it reaches `lines`. Pushing the border/header
|
|
2559
|
+
// straight into `lines` (as this used to) meant the budget below did not
|
|
2560
|
+
// count them, so the box overflowed by one row and the top trim ate its `╭`.
|
|
2561
|
+
const box = [];
|
|
2562
|
+
box.push(boxRow(
|
|
2563
|
+
col('Question', C.cyan + C.bold)
|
|
2564
|
+
+ (cur.header ? col(' ' + cur.header, C.yellow) : '')
|
|
2565
|
+
+ col(counter, C.gray),
|
|
2566
|
+
));
|
|
2567
|
+
// The question text itself, wrapped by DISPLAY width so CJK cannot push the
|
|
2568
|
+
// right border out.
|
|
2569
|
+
for (const seg of wrapWords(cur.question, innerW - 2)) {
|
|
2570
|
+
box.push(boxRow(col(' ' + seg, C.white)));
|
|
2571
|
+
}
|
|
2572
|
+
// Options, then the per-question free-text row, then (last question only) the
|
|
2573
|
+
// global supplement row. All three are appended by US, never by the model.
|
|
2574
|
+
const isLast = qs.index === qs.items.length - 1;
|
|
2575
|
+
const opts = cur.options.map((o) => ({ label: o.label, description: o.description, kind: 'option' }));
|
|
2576
|
+
opts.push({ label: OTHER_LABEL, description: 'Type your own answer', kind: 'other' });
|
|
2577
|
+
if (isLast) opts.push({ label: SUPPLEMENT_LABEL, description: 'Optional notes for the whole request', kind: 'supplement' });
|
|
2578
|
+
// The hint/entry row is the LAST body row; when space runs out it is the first
|
|
2579
|
+
// thing to drop (the keys still work, the reminder is just not shown).
|
|
2580
|
+
// `questionH` counts the WHOLE box, so the two border rows and the question
|
|
2581
|
+
// rows already in `box` come OUT of the budget before anything is added.
|
|
2582
|
+
const boxBudget = (questionH || Infinity) - 2 - box.length;
|
|
2583
|
+
const showHint = boxBudget >= opts.length + 1;
|
|
2584
|
+
const bodyBudget = Math.max(0, boxBudget - (showHint ? 1 : 0));
|
|
2585
|
+
// Scroll the option window so the selected row stays visible when the list is
|
|
2586
|
+
// taller than the budget (a 4-option question on a 12-row terminal).
|
|
2587
|
+
let firstOpt = 0;
|
|
2588
|
+
if (opts.length > bodyBudget && bodyBudget > 0) {
|
|
2589
|
+
firstOpt = Math.min(Math.max(0, qs.sel - bodyBudget + 1), opts.length - bodyBudget);
|
|
2590
|
+
}
|
|
2591
|
+
const shownOpts = opts.slice(firstOpt, firstOpt + (bodyBudget || opts.length));
|
|
2592
|
+
const itemRows = [];
|
|
2593
|
+
shownOpts.forEach((o, k) => {
|
|
2594
|
+
const j = firstOpt + k;
|
|
2595
|
+
const isSel = j === qs.sel;
|
|
2596
|
+
const ptr = isSel ? col('❯ ', C.cyan) : ' ';
|
|
2597
|
+
const chosen = qs.picked.has(j);
|
|
2598
|
+
// Only real options get checkboxes: Other/supplement are free text.
|
|
2599
|
+
const box2 = (cur.multiSelect && o.kind === 'option')
|
|
2600
|
+
? (chosen ? col('[x] ', C.green) : col('[ ] ', C.gray)) : '';
|
|
2601
|
+
// Echo what was already typed, so leaving the editor is not a dead end.
|
|
2602
|
+
const typed = (o.kind === 'supplement' && qs.supplement) ? `: ${qs.supplement}`
|
|
2603
|
+
: (o.kind === 'other' && qs.otherText) ? `: ${qs.otherText}` : '';
|
|
2604
|
+
const label = col(String(o.label) + typed, isSel ? (C.cyan + C.bold) : C.white);
|
|
2605
|
+
const desc = o.description ? col(' ' + o.description, C.gray) : '';
|
|
2606
|
+
// The marker column is 2 wide, so the rest gets innerW - 2.
|
|
2607
|
+
itemRows.push({ boxIdx: box.length, index: j });
|
|
2608
|
+
box.push(boxRow(ptr + box2 + fitAnsi(label + desc, Math.max(1, innerW - 4))));
|
|
2609
|
+
});
|
|
2610
|
+
let caretInBox = -1;
|
|
2611
|
+
if (qs.editing && showHint) {
|
|
2612
|
+
// Free-text entry; the caret is drawn on this row.
|
|
2613
|
+
const which = qs.editing === 'supplement' ? SUPPLEMENT_LABEL : OTHER_LABEL;
|
|
2614
|
+
const label = which + ': ';
|
|
2615
|
+
caretInBox = box.length;
|
|
2616
|
+
box.push(boxRow(col(label, C.gray) + col(qs.editingText || '', C.white)));
|
|
2617
|
+
} else if (showHint) {
|
|
2618
|
+
const hint = cur.multiSelect
|
|
2619
|
+
? '↑↓ move · Space toggle · Enter confirm · Esc dismiss'
|
|
2620
|
+
: '↑↓ move · Enter select · Esc dismiss';
|
|
2621
|
+
box.push(boxRow(col(hint, C.gray)));
|
|
2622
|
+
}
|
|
2623
|
+
// Emit the box. `boxTop` is where row 0 of `box` lands in `lines`.
|
|
2624
|
+
const boxTop = lines.length;
|
|
2625
|
+
for (const b of box) lines.push(b);
|
|
2626
|
+
lines.push(bar('╰' + '─'.repeat(promptW) + '╯'));
|
|
2627
|
+
// Mouse hitboxes: the box rows are pushed into `lines`, and composeFrame's
|
|
2628
|
+
// hitbox pass shifts them to final screen coordinates.
|
|
2629
|
+
itemRows.forEach((r) => addHit(boxTop + r.boxIdx, 0, w - 1, { kind: 'questionItem', index: r.index }));
|
|
2630
|
+
if (caretInBox >= 0) {
|
|
2631
|
+
const which = qs.editing === 'supplement' ? SUPPLEMENT_LABEL : OTHER_LABEL;
|
|
2632
|
+
dialogCaret = { row: boxTop + caretInBox, col: visualCol(which + ': ') + visualCol(qs.editingText || '') };
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
if (state.picker) {
|
|
1963
2636
|
const pick = state.picker;
|
|
1964
2637
|
const query = state.pickerQuery || '';
|
|
1965
2638
|
const list = pickerFiltered(state);
|
|
@@ -2060,7 +2733,13 @@ export function composeFrame(state, cols, rows) {
|
|
|
2060
2733
|
for (const b of body) lines.push(b);
|
|
2061
2734
|
while (lines.length < bodyH) lines.push(' '.repeat(w));
|
|
2062
2735
|
if (dialogCaret) dialogCaret.row += padTop;
|
|
2063
|
-
}
|
|
2736
|
+
}
|
|
2737
|
+
// The chat body is the LAST fallback for an open overlay. It used to be the
|
|
2738
|
+
// bare `else` of the PICKER branch, so opening the editor (picker == null) ran
|
|
2739
|
+
// this too and appended a whole extra viewport of rows ON TOP of the editor.
|
|
2740
|
+
// The frame then overflowed, the top trim ate the editor's own title and text,
|
|
2741
|
+
// and /personal & /set-system-prompt showed nothing but a stray caret.
|
|
2742
|
+
if (!state.editor && !state.panel && !state.form && !state.picker) {
|
|
2064
2743
|
const chat = renderChatLines(state, w);
|
|
2065
2744
|
if (state.selection && state.selection.anchor && state.selection.head) {
|
|
2066
2745
|
// Apply selection highlight directly on ANSI strings
|
|
@@ -2120,7 +2799,17 @@ export function composeFrame(state, cols, rows) {
|
|
|
2120
2799
|
|
|
2121
2800
|
if (workingH) {
|
|
2122
2801
|
const frame = SPINNER[(state.spin || 0) % SPINNER.length];
|
|
2123
|
-
|
|
2802
|
+
// ---- shared palette for the whole Working row -------------------------
|
|
2803
|
+
// Everything on this row (spinner glyph, typed chars, elapsed tail, sweep)
|
|
2804
|
+
// must come from ONE set of RGB values, rendered through the SAME lerpColor
|
|
2805
|
+
// path. Mixing C.gray (ANSI 90 ≈ #808080) with the sweep's #969696, or
|
|
2806
|
+
// C.orange (256-colour 208) with the sweep's #ff8c00 (=214), made the row
|
|
2807
|
+
// visibly change shade at each phase boundary.
|
|
2808
|
+
const GREY_RGB = [150, 150, 150];
|
|
2809
|
+
const ORANGE_RGB = [255, 140, 0];
|
|
2810
|
+
const greyEsc = lerpColor(GREY_RGB[0], GREY_RGB[1], GREY_RGB[2], GREY_RGB[0], GREY_RGB[1], GREY_RGB[2], 0);
|
|
2811
|
+
const orangeEsc = lerpColor(ORANGE_RGB[0], ORANGE_RGB[1], ORANGE_RGB[2], ORANGE_RGB[0], ORANGE_RGB[1], ORANGE_RGB[2], 0);
|
|
2812
|
+
const elapsed = state.turnStart ? ` ${col('[' + fmtDuration(Date.now() - state.turnStart) + ']', greyEsc)}` : '';
|
|
2124
2813
|
// The wording was chosen once at turn start; the gradient below loops
|
|
2125
2814
|
// independently, so the phrase stays put while the colours sweep.
|
|
2126
2815
|
const workMsg = state.workMsg || WORKING_MESSAGES[0];
|
|
@@ -2145,17 +2834,17 @@ export function composeFrame(state, cols, rows) {
|
|
|
2145
2834
|
const out = [];
|
|
2146
2835
|
|
|
2147
2836
|
if (phase === 'type') {
|
|
2148
|
-
// Type the WHOLE row (phrase + tail) left → right, all grey.
|
|
2837
|
+
// Type the WHOLE row (phrase + tail) left → right, all in the grey.
|
|
2149
2838
|
const typedCount = Math.floor(phaseT * totalChars);
|
|
2150
2839
|
for (let i = 0; i < phrase.length; i++) {
|
|
2151
|
-
out.push(i < typedCount ? col(phrase[i],
|
|
2840
|
+
out.push(i < typedCount ? col(phrase[i], greyEsc) : ' ');
|
|
2152
2841
|
}
|
|
2153
2842
|
const tailTyped = Math.max(0, typedCount - phrase.length);
|
|
2154
2843
|
for (let i = 0; i < tailText.length; i++) {
|
|
2155
|
-
out.push(i < tailTyped ? col(tailText[i],
|
|
2844
|
+
out.push(i < tailTyped ? col(tailText[i], greyEsc) : ' ');
|
|
2156
2845
|
}
|
|
2157
2846
|
// The tail is already inside `out`, so do NOT append `elapsed` here.
|
|
2158
|
-
lines.push(col(frame,
|
|
2847
|
+
lines.push(col(frame, orangeEsc) + ' ' + out.join(''));
|
|
2159
2848
|
} else {
|
|
2160
2849
|
// Colour sweep: only the PHRASE goes grey → orange. The tail stays grey
|
|
2161
2850
|
// (it is appended as the colored `elapsed` chunk after `out`).
|
|
@@ -2164,21 +2853,23 @@ export function composeFrame(state, cols, rows) {
|
|
|
2164
2853
|
// head sweeps from just left of the phrase (0) to just right of it (1),
|
|
2165
2854
|
// extended by BAND on both ends so every char crosses the band smoothly.
|
|
2166
2855
|
const head = -BAND + phaseT * (1 + 2 * BAND);
|
|
2167
|
-
const GREY = [150, 150, 150];
|
|
2168
|
-
const ORANGE = [255, 140, 0];
|
|
2169
2856
|
for (let i = 0; i < phrase.length; i++) {
|
|
2170
2857
|
const charAt = i / phrase.length;
|
|
2171
2858
|
let tChar = (head - charAt) / BAND;
|
|
2172
2859
|
tChar = Math.max(0, Math.min(1, tChar));
|
|
2173
|
-
const r = Math.round(
|
|
2174
|
-
const g = Math.round(
|
|
2175
|
-
const b = Math.round(
|
|
2860
|
+
const r = Math.round(GREY_RGB[0] + (ORANGE_RGB[0] - GREY_RGB[0]) * tChar);
|
|
2861
|
+
const g = Math.round(GREY_RGB[1] + (ORANGE_RGB[1] - GREY_RGB[1]) * tChar);
|
|
2862
|
+
const b = Math.round(GREY_RGB[2] + (ORANGE_RGB[2] - GREY_RGB[2]) * tChar);
|
|
2176
2863
|
out.push(col(phrase[i], lerpColor(r, g, b, r, g, b, 0)));
|
|
2177
2864
|
}
|
|
2178
|
-
lines.push(col(frame,
|
|
2865
|
+
lines.push(col(frame, orangeEsc) + ' ' + out.join('') + elapsed);
|
|
2179
2866
|
}
|
|
2180
2867
|
if ((Date.now() - state.startAnim.start) >= 1000) {
|
|
2181
2868
|
state.startAnim = null;
|
|
2869
|
+
// The colour phase has just finished painting the phrase orange. Anchor
|
|
2870
|
+
// the pulse to THIS tick so its first frame is the orange start of the
|
|
2871
|
+
// orange -> yellow sweep, not a mid-cycle colour.
|
|
2872
|
+
state.pulseStart = state.spin || 0;
|
|
2182
2873
|
}
|
|
2183
2874
|
}
|
|
2184
2875
|
// ---- turn-finish animation ----
|
|
@@ -2206,14 +2897,13 @@ export function composeFrame(state, cols, rows) {
|
|
|
2206
2897
|
for (let i = 0; i < width; i++) {
|
|
2207
2898
|
word += (i < done) ? (toPadded[i] || '') : from[i];
|
|
2208
2899
|
}
|
|
2209
|
-
// Colour: orange → grey over the animation.
|
|
2210
|
-
|
|
2211
|
-
const
|
|
2212
|
-
const
|
|
2213
|
-
const
|
|
2214
|
-
const b = Math.round(ORANGE[2] + (GREY[2] - ORANGE[2]) * t);
|
|
2900
|
+
// Colour: orange → grey over the animation. Uses the SAME shared constants
|
|
2901
|
+
// so the row does not shift shade when the finish morph begins.
|
|
2902
|
+
const r = Math.round(ORANGE_RGB[0] + (GREY_RGB[0] - ORANGE_RGB[0]) * t);
|
|
2903
|
+
const g = Math.round(ORANGE_RGB[1] + (GREY_RGB[1] - ORANGE_RGB[1]) * t);
|
|
2904
|
+
const b = Math.round(ORANGE_RGB[2] + (GREY_RGB[2] - ORANGE_RGB[2]) * t);
|
|
2215
2905
|
const animColor = lerpColor(r, g, b, r, g, b, 0);
|
|
2216
|
-
lines.push(col(frame, animColor) + ' ' + col('[', animColor) + col(word, animColor) + col(tail,
|
|
2906
|
+
lines.push(col(frame, animColor) + ' ' + col('[', animColor) + col(word, animColor) + col(tail, greyEsc));
|
|
2217
2907
|
} else {
|
|
2218
2908
|
|
|
2219
2909
|
// Timing (spinner ticks every 80ms, so 0.5s ≈ 6 ticks):
|
|
@@ -2225,12 +2915,16 @@ export function composeFrame(state, cols, rows) {
|
|
|
2225
2915
|
const SWEEP = 6; // 0.5s
|
|
2226
2916
|
const HALF = SWEEP + SWEEP; // 12 ticks per colour (sweep + sweep back)
|
|
2227
2917
|
const CYCLE = HALF * 2; // 24 ticks for the full loop (~2s)
|
|
2228
|
-
|
|
2918
|
+
// Phase is measured from the tick the pulse BEGAN (state.pulseStart), not
|
|
2919
|
+
// from 0: the spinner has been running since turn start, so a raw
|
|
2920
|
+
// `spin % CYCLE` dropped the pulse into a mid-cycle colour (often the red
|
|
2921
|
+
// half) the instant the start animation handed over.
|
|
2922
|
+
const inCycle = ((spin - (state.pulseStart || spin)) % CYCLE + CYCLE) % CYCLE;
|
|
2229
2923
|
const inHalf = inCycle % HALF; // 0..23 within this colour's half
|
|
2230
2924
|
const phase = inCycle < HALF ? 0 : 1; // 0 = yellow, 1 = red
|
|
2231
2925
|
|
|
2232
|
-
// Colours as plain RGB triples (never ANSI strings).
|
|
2233
|
-
|
|
2926
|
+
// Colours as plain RGB triples (never ANSI strings). ORANGE is the shared
|
|
2927
|
+
// constant so the pulse starts exactly where the start animation ended.
|
|
2234
2928
|
const YELLOW = [255, 240, 120];
|
|
2235
2929
|
const RED = [255, 0, 0]; // pure red
|
|
2236
2930
|
const target = phase === 0 ? YELLOW : RED;
|
|
@@ -2249,12 +2943,12 @@ export function composeFrame(state, cols, rows) {
|
|
|
2249
2943
|
const mix = (t) => {
|
|
2250
2944
|
const k = Math.max(0, Math.min(1, t));
|
|
2251
2945
|
return lerpColor(
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2946
|
+
ORANGE_RGB[0] + (target[0] - ORANGE_RGB[0]) * k,
|
|
2947
|
+
ORANGE_RGB[1] + (target[1] - ORANGE_RGB[1]) * k,
|
|
2948
|
+
ORANGE_RGB[2] + (target[2] - ORANGE_RGB[2]) * k,
|
|
2949
|
+
ORANGE_RGB[0] + (target[0] - ORANGE_RGB[0]) * k,
|
|
2950
|
+
ORANGE_RGB[1] + (target[1] - ORANGE_RGB[1]) * k,
|
|
2951
|
+
ORANGE_RGB[2] + (target[2] - ORANGE_RGB[2]) * k,
|
|
2258
2952
|
0,
|
|
2259
2953
|
);
|
|
2260
2954
|
};
|
|
@@ -2276,7 +2970,7 @@ export function composeFrame(state, cols, rows) {
|
|
|
2276
2970
|
}
|
|
2277
2971
|
out.push(col(chars[i], mix(t)));
|
|
2278
2972
|
}
|
|
2279
|
-
lines.push(col(frame,
|
|
2973
|
+
lines.push(col(frame, orangeEsc) + ' ' + out.join('') + elapsed);
|
|
2280
2974
|
}
|
|
2281
2975
|
}
|
|
2282
2976
|
|
|
@@ -2331,6 +3025,22 @@ export function composeFrame(state, cols, rows) {
|
|
|
2331
3025
|
lines.push(boxRow(col('Enter to approve | Esc to reject', C.gray)));
|
|
2332
3026
|
lines.push(bar('╰' + '─'.repeat(promptW) + '╯'));
|
|
2333
3027
|
}
|
|
3028
|
+
|
|
3029
|
+
// Plan approval: shown once the model hands over a <plan> in Plan mode. Same
|
|
3030
|
+
// box shape as the tool approval above so the two read as the same kind of
|
|
3031
|
+
// prompt. Enter approves (turn Plan off and execute); Esc keeps planning.
|
|
3032
|
+
if (state.planPending && !dialog) {
|
|
3033
|
+
const pp = state.planPending;
|
|
3034
|
+
const promptW = insideW;
|
|
3035
|
+
const innerW = Math.max(1, promptW - 2);
|
|
3036
|
+
const bar = (ch) => col(ch, C.border);
|
|
3037
|
+
const boxRow = (content) => bar('│') + ' ' + fitAnsi(content, innerW) + ' ' + bar('│');
|
|
3038
|
+
lines.push(bar('╭' + '─'.repeat(promptW) + '╮'));
|
|
3039
|
+
lines.push(boxRow(col('Approve', C.white + C.bold) + ' ' + col('this plan?', C.cyan + C.bold)));
|
|
3040
|
+
// The plan is ALREADY shown in its table above - do not repeat it here.
|
|
3041
|
+
lines.push(boxRow(col('Enter to approve & execute | Esc to keep planning', C.gray)));
|
|
3042
|
+
lines.push(bar('╰' + '─'.repeat(promptW) + '╯'));
|
|
3043
|
+
}
|
|
2334
3044
|
|
|
2335
3045
|
let composerFirstRow = -1;
|
|
2336
3046
|
if (!dialog) {
|
|
@@ -2404,40 +3114,73 @@ export function composeFrame(state, cols, rows) {
|
|
|
2404
3114
|
? (state.effort && state.effort !== 'on' ? ` thinking: ${state.effort}` : ' thinking')
|
|
2405
3115
|
: state.seenThinking ? ' thinking' : '';
|
|
2406
3116
|
const label = state.modelLabel || state.model || '';
|
|
3117
|
+
// Plan / Focus / Swarm are mutually exclusive modes, so at most one badge is
|
|
3118
|
+
// shown. Swarm uses a spring-green (cyan shifted toward green) to stay in the
|
|
3119
|
+
// theme family while reading as its own thing next to Plan's cyan.
|
|
2407
3120
|
const modeBadge = state.plan ? col('Plan', C.cyan + C.bold)
|
|
2408
|
-
: state.focus ? col('Focus', C.
|
|
3121
|
+
: state.focus ? col('Focus', C.blue + C.bold)
|
|
3122
|
+
: state.swarm ? col('Swarm', C.spring + C.bold)
|
|
2409
3123
|
: '';
|
|
2410
3124
|
const parts = [];
|
|
2411
|
-
|
|
2412
|
-
|
|
3125
|
+
// The permission mode and the Plan/Focus badge are both "what mode am I in",
|
|
3126
|
+
// so they read as ONE group: separated by a single space. Everything after the
|
|
3127
|
+
// group gets the normal two-space divider, so a name that already contains a
|
|
3128
|
+
// space is not mistaken for the group boundary:
|
|
3129
|
+
// Auto Plan workbuddy/deepseek-v4.1-flash D:\hncode
|
|
3130
|
+
let modeGroup = state.slMode !== false ? col(modeLabel(state.mode), C.orange + C.bold) : '';
|
|
3131
|
+
if (modeBadge) modeGroup = modeGroup ? modeGroup + ' ' + modeBadge : modeBadge;
|
|
3132
|
+
if (modeGroup) parts.push(modeGroup);
|
|
2413
3133
|
if (state.slModel !== false && label) {
|
|
2414
3134
|
const think = state.slEffort !== false ? thinking : '';
|
|
2415
3135
|
parts.push(col(`${label}${think}`, C.white));
|
|
2416
3136
|
}
|
|
2417
3137
|
if (state.slTasks !== false) {
|
|
3138
|
+
// Two SEPARATE badges, mirroring kimi-code's footer: `bashTasks` counts
|
|
3139
|
+
// background processes and `agentTasks` counts background subagents. They are
|
|
3140
|
+
// semantically different (you wait on a command, you read an agent's report),
|
|
3141
|
+
// so kimi keeps them apart and so do we. Only RUNNING tasks are counted — a
|
|
3142
|
+
// finished task leaves the badge, which is what makes it a live indicator
|
|
3143
|
+
// rather than a running total. `bg N→M` used to conflate both kinds and also
|
|
3144
|
+
// showed a stale count after everything finished.
|
|
2418
3145
|
const ts = Object.values(state.tasks || {});
|
|
2419
|
-
const running = ts.filter((t) => t.status === 'running')
|
|
2420
|
-
const
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
}
|
|
3146
|
+
const running = ts.filter((t) => t.status === 'running');
|
|
3147
|
+
const bashTasks = running.filter((t) => t.kind !== 'agent').length;
|
|
3148
|
+
const agentTasks = running.filter((t) => t.kind === 'agent').length;
|
|
3149
|
+
if (bashTasks > 0) parts.push(col(`[${bashTasks} task${bashTasks === 1 ? '' : 's'} running]`, C.cyan));
|
|
3150
|
+
if (agentTasks > 0) parts.push(col(`[${agentTasks} agent${agentTasks === 1 ? '' : 's'} running]`, C.spring));
|
|
2424
3151
|
}
|
|
2425
3152
|
if (state.slCwd !== false && cwd) parts.push(col(cwd, C.gray));
|
|
2426
3153
|
const statusLeft = parts.join(' ');
|
|
2427
3154
|
const statusRight = (state.slTips !== false && state.tip) ? col(state.tip, C.gray) : '';
|
|
2428
3155
|
lines.push(justify(statusLeft, statusRight, w));
|
|
2429
3156
|
|
|
2430
|
-
if (noticeH) {
|
|
2431
|
-
const nc = state.noticeKind === 'error' ? C.red : C.gray;
|
|
2432
|
-
lines.push(col(fitAnsi(' ' + state.notice, w), nc));
|
|
2433
|
-
}
|
|
2434
|
-
|
|
2435
3157
|
if (confirmH) lines.push(col(fitAnsi('Press Ctrl+C again to exit the hncode', w), C.yellow));
|
|
2436
3158
|
|
|
2437
3159
|
const ctxRight = `${state.rounds} turn${state.rounds === 1 ? '' : 's'} | ${state.steps} step${state.steps === 1 ? '' : 's'} | ${Math.round(state.tokRate)} tok/s | context: ${state.ctxPercent}% (${fmtTokens(state.ctxTokens || 0)}/${fmtTokens(state.ctxMax || 1)})`;
|
|
3160
|
+
// The short-lived notice ("Expanded tool output", "Copied 1.2k chars", …)
|
|
3161
|
+
// shares the context row, LEFT-aligned, instead of reserving a row of its own
|
|
3162
|
+
// — a whole blank line appeared and disappeared above the context bar on every
|
|
3163
|
+
// Ctrl+O. The gauge keeps its full width (it must never be the part that gets
|
|
3164
|
+
// cut), so the notice is truncated into whatever space is left over. NOTE:
|
|
3165
|
+
// measure the RAW string — fitAnsi() pads to the full width, so using its
|
|
3166
|
+
// result here made the available room always 0 and the notice invisible.
|
|
2438
3167
|
const ctxW = visualCol(ctxRight);
|
|
2439
|
-
|
|
2440
|
-
|
|
3168
|
+
if (noticeH) {
|
|
3169
|
+
const nc = state.noticeKind === 'error' ? C.red : C.gray;
|
|
3170
|
+
// The notice yields first: it gets whatever is left after the gauge keeps
|
|
3171
|
+
// its natural width plus a 1-column gap, and is truncated into it.
|
|
3172
|
+
const room = Math.max(0, w - Math.min(ctxW, w) - 1);
|
|
3173
|
+
const left = room > 0 ? col(fitAnsi(state.notice, room), nc) : '';
|
|
3174
|
+
const lw = visualCol(left);
|
|
3175
|
+
const gap = lw > 0 ? 1 : 0;
|
|
3176
|
+
// The gauge takes the remaining columns exactly, so the row is always
|
|
3177
|
+
// EXACTLY `w` wide (never 1 over, which wrapped the last column onto the
|
|
3178
|
+
// next screen row) and stays visible even on a narrow terminal.
|
|
3179
|
+
const gauge = col(fitAnsi(ctxRight, Math.max(0, w - lw - gap)), C.white);
|
|
3180
|
+
lines.push(left + ' '.repeat(gap) + gauge);
|
|
3181
|
+
} else {
|
|
3182
|
+
lines.push(' '.repeat(Math.max(0, w - ctxW)) + col(fitAnsi(ctxRight, Math.min(ctxW, w)), C.white));
|
|
3183
|
+
}
|
|
2441
3184
|
|
|
2442
3185
|
let topPad = 0;
|
|
2443
3186
|
if (lines.length < h) { topPad = h - lines.length; }
|
|
@@ -2458,7 +3201,14 @@ export function composeFrame(state, cols, rows) {
|
|
|
2458
3201
|
let cursor;
|
|
2459
3202
|
let cursorVisible;
|
|
2460
3203
|
if (dialogCaret) {
|
|
2461
|
-
|
|
3204
|
+
// A dialog is drawn at the TOP of the frame: the filler rows go BELOW it
|
|
3205
|
+
// (topPad) and nothing is trimmed off the top. The caret row is relative to
|
|
3206
|
+
// the dialog body, so it MUST be shifted by that same pad — without it the
|
|
3207
|
+
// block caret landed `topPad` rows too high (the editor's title / rule / hint
|
|
3208
|
+
// sit above the body, which is exactly the one row it was off by on
|
|
3209
|
+
// /personal and /set-system-prompt). The hitboxes below already apply the pad;
|
|
3210
|
+
// the caret did not.
|
|
3211
|
+
cursor = { row: Math.min(dialogCaret.row + topPad, h - 1), col: Math.min(dialogCaret.col, w - 1) };
|
|
2462
3212
|
cursorVisible = true;
|
|
2463
3213
|
} else if (dialog) {
|
|
2464
3214
|
cursor = { row: 0, col: 0 };
|
|
@@ -2466,7 +3216,7 @@ export function composeFrame(state, cols, rows) {
|
|
|
2466
3216
|
} else {
|
|
2467
3217
|
const menuH = (state.menuOpen && state.menuList.length && !dialog)
|
|
2468
3218
|
? Math.min(state.menuList.length, MAX_MENU) + 1 : 0;
|
|
2469
|
-
const bottomChrome = STATUS_H + (state.
|
|
3219
|
+
const bottomChrome = STATUS_H + (state.confirmExit ? 1 : 0) + CTX_H + menuH;
|
|
2470
3220
|
const caretScreenRow = h - bottomChrome - 2 - (composer.rows.length - 1 - composer.caretRow);
|
|
2471
3221
|
cursor = {
|
|
2472
3222
|
row: Math.max(0, Math.min(caretScreenRow, h - 1)),
|
|
@@ -2523,26 +3273,23 @@ export function diffFrame(prev, next) {
|
|
|
2523
3273
|
let out = '\x1b[?2026h';
|
|
2524
3274
|
// Only [first, last] changed; rewriting to the bottom of the screen made a
|
|
2525
3275
|
// single appended line repaint the whole viewport every frame.
|
|
3276
|
+
//
|
|
3277
|
+
// Every row is addressed ABSOLUTELY (`ESC[<n>;1H`). This used to write the
|
|
3278
|
+
// first row absolutely and then advance with `\r\n`, which is a RELATIVE move
|
|
3279
|
+
// that SCROLLS the screen once the line feed happens on the last terminal row
|
|
3280
|
+
// (or with the cursor past the bottom margin). The Working/pulse row sits
|
|
3281
|
+
// directly above the composer's `╭` border, so a repaint span reaching it
|
|
3282
|
+
// scrolled everything up by a row and the next `ESC[2K` erased the composer's
|
|
3283
|
+
// top border — "Working… gets written on the next line and overwrites the
|
|
3284
|
+
// input box". Absolute addressing cannot scroll, so the span is now safe.
|
|
2526
3285
|
const reach = Math.min(next.lines.length, last + 1);
|
|
2527
|
-
let pos2 = first;
|
|
2528
3286
|
for (let i = first; i < reach; i++) {
|
|
2529
|
-
|
|
2530
|
-
else out += '\r\n';
|
|
2531
|
-
out += '\x1b[2K' + next.lines[i];
|
|
2532
|
-
pos2 = i;
|
|
2533
|
-
}
|
|
2534
|
-
if (next.lines.length < prev.lines.length) {
|
|
2535
|
-
const extraStart = next.lines.length;
|
|
2536
|
-
const extraEnd = Math.max(extraStart, prev.lines.length);
|
|
2537
|
-
if (pos2 < extraStart - 1) out += '\r\n'.repeat(extraStart - 1 - pos2);
|
|
2538
|
-
else if (pos2 > extraStart - 1) out += `\x1b[${pos2 - (extraStart - 1)}A`;
|
|
2539
|
-
out += '\r\n';
|
|
2540
|
-
for (let i = extraStart; i < extraEnd; i++) {
|
|
2541
|
-
out += '\x1b[2K';
|
|
2542
|
-
if (i < extraEnd - 1) out += '\r\n';
|
|
2543
|
-
}
|
|
2544
|
-
out += `\x1b[${extraEnd - extraStart}A`;
|
|
3287
|
+
out += `\x1b[${i + 1};1H\x1b[2K` + next.lines[i];
|
|
2545
3288
|
}
|
|
3289
|
+
// No "clear the trailing rows" pass is needed: composeFrame always returns
|
|
3290
|
+
// EXACTLY `rows` lines (both frames here share the same height, otherwise the
|
|
3291
|
+
// sizeChanged path above would have run), so a shrinking viewport is already
|
|
3292
|
+
// handled by `last` moving up and the freed rows being rewritten as blanks.
|
|
2546
3293
|
out += '\x1b[?2026l';
|
|
2547
3294
|
out += pos + shape;
|
|
2548
3295
|
return out;
|
|
@@ -2625,6 +3372,10 @@ export function makeState({ cfg, session, opts }) {
|
|
|
2625
3372
|
running: false,
|
|
2626
3373
|
spin: 0,
|
|
2627
3374
|
workMsg: WORKING_MESSAGES[0], // chosen once per turn
|
|
3375
|
+
// (swarm state is set below from the session — see the `swarm:` line after
|
|
3376
|
+
// plan/focus, so it is read from ONE place.)
|
|
3377
|
+
// Turn-finish animation: while non-null, the Working row renders the morph
|
|
3378
|
+
// from `from` to `to` (see composeFrame). Cleared ~0.5s after turn end.
|
|
2628
3379
|
// Turn-finish animation: while non-null, the Working row renders the morph
|
|
2629
3380
|
// from `from` to `to` (see composeFrame). Cleared ~0.5s after turn end.
|
|
2630
3381
|
finishAnim: null, // { start, from, to }
|
|
@@ -2638,6 +3389,10 @@ export function makeState({ cfg, session, opts }) {
|
|
|
2638
3389
|
plan: !!(session && session.plan),
|
|
2639
3390
|
planPath: (session && session.planPath) || null,
|
|
2640
3391
|
focus: !!(session && session.focus),
|
|
3392
|
+
// Swarm mode is per-SESSION (like plan/focus), not just per-config: it is
|
|
3393
|
+
// restored here and written by persistState(). Reading only config.toml left
|
|
3394
|
+
// it off after /sessions resume, /fork and a plain restart.
|
|
3395
|
+
swarm: !!(session && session.swarm),
|
|
2641
3396
|
effort: (session && session.effort) || cfg.effort || (cfg.reasoning ? 'on' : 'off'),
|
|
2642
3397
|
// Theme removed - forced dark only
|
|
2643
3398
|
addDirs: [],
|
|
@@ -2648,6 +3403,20 @@ export function makeState({ cfg, session, opts }) {
|
|
|
2648
3403
|
pickerCategory: null, // active filter category (null = "All")
|
|
2649
3404
|
pickerCategories: null,
|
|
2650
3405
|
form: null,
|
|
3406
|
+
// AskUserQuestion dialog: { items, index, sel, picked:Set, multiSelect-going
|
|
3407
|
+
// state, editing, text, resolve }. Non-null while the user is answering.
|
|
3408
|
+
question: null,
|
|
3409
|
+
// Plan approval: { plan, resolve } while the user reviews a <plan> in Plan
|
|
3410
|
+
// mode. Mirrors approvalPending, but for the whole plan rather than one tool.
|
|
3411
|
+
planPending: null,
|
|
3412
|
+
// /tasks full-screen browser: { tasks, filter, selectedIndex, listScroll,
|
|
3413
|
+
// pendingStop, flash, flashTimer } or null when closed.
|
|
3414
|
+
tasksPanel: null,
|
|
3415
|
+
// Output viewer state (makeViewerState result) or null.
|
|
3416
|
+
tasksViewer: null,
|
|
3417
|
+
// Swarm mode (/swarm): when on, the prompt tells the model to decompose work
|
|
3418
|
+
// across parallel subagents via AgentSwarm.
|
|
3419
|
+
swarm: false,
|
|
2651
3420
|
panel: null,
|
|
2652
3421
|
notice: '',
|
|
2653
3422
|
noticeKind: 'info',
|
|
@@ -2775,7 +3544,7 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
2775
3544
|
if (a === 'on') state.plan = true;
|
|
2776
3545
|
else if (a === 'off') state.plan = false;
|
|
2777
3546
|
else state.plan = !state.plan;
|
|
2778
|
-
if (state.plan) state.focus = false;
|
|
3547
|
+
if (state.plan) { state.focus = false; state.swarm = false; }
|
|
2779
3548
|
persist();
|
|
2780
3549
|
app(`Plan mode: ${state.plan ? 'ON (read-only planning)' : 'OFF'}`);
|
|
2781
3550
|
return;
|
|
@@ -2786,7 +3555,7 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
2786
3555
|
if (a === 'on') want = true;
|
|
2787
3556
|
else if (a === 'off') want = false;
|
|
2788
3557
|
state.focus = want;
|
|
2789
|
-
if (state.focus) state.plan = false;
|
|
3558
|
+
if (state.focus) { state.plan = false; state.swarm = false; }
|
|
2790
3559
|
persist();
|
|
2791
3560
|
app(`Focus mode: ${state.focus ? 'ON (Read/Write/Edit/Bash only)' : 'OFF (all tools)'}`);
|
|
2792
3561
|
return;
|
|
@@ -2802,6 +3571,7 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
2802
3571
|
{ label: 'provider', sub: 'manage AI providers' },
|
|
2803
3572
|
{ label: 'statusline', sub: 'configure status line items' },
|
|
2804
3573
|
{ label: 'add-dir', sub: 'add an additional workspace directory' },
|
|
3574
|
+
{ label: 'auto-update', sub: 'check npm for updates at startup + every 30 min' },
|
|
2805
3575
|
],
|
|
2806
3576
|
onPick: (it) => { dispatch(it.label, '', state, cfg, session, h, submit, stdout); return true; },
|
|
2807
3577
|
});
|
|
@@ -3187,16 +3957,92 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
3187
3957
|
return;
|
|
3188
3958
|
}
|
|
3189
3959
|
case 'tasks': {
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3960
|
+
// Open the full-screen task browser (kimi's TASK BROWSER). Through the host:
|
|
3961
|
+
// dispatch is module-level and the panel lives inside startTUI.
|
|
3962
|
+
h.openTasksPanel();
|
|
3963
|
+
return;
|
|
3964
|
+
}
|
|
3965
|
+
case 'swarm': {
|
|
3966
|
+
// Swarm mode: tell the model to decompose work across parallel subagents.
|
|
3967
|
+
const a = raw.toLowerCase();
|
|
3968
|
+
let want;
|
|
3969
|
+
if (a === 'on') want = true;
|
|
3970
|
+
else if (a === 'off') want = false;
|
|
3971
|
+
else want = !state.swarm;
|
|
3972
|
+
state.swarm = want;
|
|
3973
|
+
// Plan / Focus / Swarm are one mutually exclusive group: turning one on
|
|
3974
|
+
// turns the other two off.
|
|
3975
|
+
if (want) { state.plan = false; state.focus = false; }
|
|
3976
|
+
try { setConfigString('swarm_mode', want ? 'true' : 'false'); } catch {}
|
|
3977
|
+
cfg.raw.swarm_mode = want;
|
|
3978
|
+
app(want
|
|
3979
|
+
? 'Swarm mode ON - the model will decompose work across parallel subagents (AgentSwarm)'
|
|
3980
|
+
: 'Swarm mode OFF');
|
|
3981
|
+
return;
|
|
3982
|
+
return;
|
|
3983
|
+
}
|
|
3984
|
+
case 'swarm-sub-agent': {
|
|
3985
|
+
// Which model subagents run on. Empty/unset = follow this session's model
|
|
3986
|
+
// (the "default"), so a user who never touches this gets the behaviour they
|
|
3987
|
+
// already had. Stored in config.toml as `subagent_model`, and mirrored onto
|
|
3988
|
+
// `cfg.subagentModel` where the Agent/AgentSwarm tools read it.
|
|
3989
|
+
const models = (cfg.raw && cfg.raw.models) || {};
|
|
3990
|
+
const names = Object.keys(models);
|
|
3991
|
+
const desired = raw.trim();
|
|
3992
|
+
|
|
3993
|
+
const showCurrent = () => (cfg.subagentModel
|
|
3994
|
+
? `Subagent model: ${cfg.subagentModel}`
|
|
3995
|
+
: `Subagent model: default (this session's — ${cfg.model || 'unset'})`);
|
|
3996
|
+
|
|
3997
|
+
const setSubagentModel = (value) => {
|
|
3998
|
+
const v = value || '';
|
|
3999
|
+
try { setConfigString('subagent_model', v); }
|
|
4000
|
+
catch (e) { appErr('Could not write config.toml: ' + e.message); return; }
|
|
4001
|
+
cfg.subagentModel = v;
|
|
4002
|
+
cfg.raw.subagent_model = v;
|
|
4003
|
+
app(v ? `Subagent model → ${v}` : `Subagent model → default (this session's model)`);
|
|
4004
|
+
};
|
|
4005
|
+
|
|
4006
|
+
// `/swarm-sub-agent default` (or `none`/`reset`) clears the override.
|
|
4007
|
+
if (desired) {
|
|
4008
|
+
const d = desired.toLowerCase();
|
|
4009
|
+
if (d === 'default' || d === 'none' || d === 'reset' || d === 'clear') { setSubagentModel(''); return; }
|
|
4010
|
+
if (!models[desired]) { appErr(`Unknown model alias: ${desired}. Run /swarm-sub-agent to pick one.`); return; }
|
|
4011
|
+
setSubagentModel(desired);
|
|
4012
|
+
return;
|
|
4013
|
+
}
|
|
4014
|
+
if (!names.length) {
|
|
4015
|
+
appErr('No models configured. Add one first: /provider');
|
|
4016
|
+
return;
|
|
4017
|
+
}
|
|
4018
|
+
// The same model menu as /model, with a subagent-scoped title and a
|
|
4019
|
+
// "default" row that clears the override.
|
|
4020
|
+
const providers = Object.keys(cfg.raw.providers || {});
|
|
4021
|
+
openPicker({
|
|
4022
|
+
title: 'Select a subagent model',
|
|
4023
|
+
items: [
|
|
4024
|
+
{
|
|
4025
|
+
label: 'default',
|
|
4026
|
+
sub: `follow this session's model (${cfg.model || 'unset'})`,
|
|
4027
|
+
action: 'default',
|
|
4028
|
+
current: !cfg.subagentModel,
|
|
4029
|
+
},
|
|
4030
|
+
...names.map((n) => ({
|
|
4031
|
+
label: n,
|
|
4032
|
+
sub: models[n].provider || cfg.provider || '',
|
|
4033
|
+
current: n === cfg.subagentModel,
|
|
4034
|
+
category: models[n].provider || '',
|
|
4035
|
+
})),
|
|
4036
|
+
],
|
|
4037
|
+
categories: ['All', ...providers],
|
|
4038
|
+
category: null,
|
|
4039
|
+
hint: '↑↓ navigate · Tab switch category · Enter select · Esc cancel',
|
|
4040
|
+
onPick: (it) => {
|
|
4041
|
+
if (it.action === 'default') { setSubagentModel(''); return true; }
|
|
4042
|
+
setSubagentModel(it.label);
|
|
4043
|
+
return true;
|
|
4044
|
+
},
|
|
4045
|
+
});
|
|
3200
4046
|
return;
|
|
3201
4047
|
}
|
|
3202
4048
|
case 'fork': {
|
|
@@ -3294,33 +4140,162 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
3294
4140
|
return;
|
|
3295
4141
|
}
|
|
3296
4142
|
case 'set-system-prompt': {
|
|
3297
|
-
//
|
|
3298
|
-
//
|
|
3299
|
-
// override and restores the built-in.
|
|
4143
|
+
// Two steps: pick a source, then edit it. Showing the editor straight away
|
|
4144
|
+
// (the old behaviour) left no way to start from a preset.
|
|
3300
4145
|
const builtin = SYSTEM_PROMPT;
|
|
3301
4146
|
const custom = cfg.raw && cfg.raw.system_prompt ? String(cfg.raw.system_prompt) : '';
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
4147
|
+
|
|
4148
|
+
// Open the editor on `text`. Saving writes it to config.toml; an EMPTY value
|
|
4149
|
+
// clears the override and restores the built-in. `presetName` is only used
|
|
4150
|
+
// for the title, so the user can see which preset they started from.
|
|
4151
|
+
const edit = (text, presetName) => {
|
|
4152
|
+
openEditor({
|
|
4153
|
+
title: presetName
|
|
4154
|
+
? `System prompt — preset "${presetName}" (edit, then Ctrl+S to save)`
|
|
4155
|
+
: (custom
|
|
4156
|
+
? 'System prompt (custom — saving replaces it; clear it to restore the built-in)'
|
|
4157
|
+
: 'System prompt (built-in — saving overrides it)'),
|
|
4158
|
+
text,
|
|
4159
|
+
caretRow: 0,
|
|
4160
|
+
caretCol: 0,
|
|
4161
|
+
onSave: (value) => {
|
|
4162
|
+
const trimmed = String(value).trim();
|
|
4163
|
+
try {
|
|
4164
|
+
setConfigString('system_prompt', trimmed);
|
|
4165
|
+
} catch (e) { appErr('Could not write config.toml: ' + e.message); return; }
|
|
4166
|
+
cfg.systemPrompt = trimmed;
|
|
4167
|
+
cfg.raw.system_prompt = trimmed;
|
|
4168
|
+
if (trimmed) app(`System prompt saved (${trimmed.length} chars) → ${hncodeConfigFile()}`);
|
|
4169
|
+
else app('System prompt override cleared; the built-in prompt is in use again.');
|
|
4170
|
+
},
|
|
4171
|
+
});
|
|
4172
|
+
};
|
|
4173
|
+
|
|
4174
|
+
// A preset only lands in the editor — never written straight to config.toml.
|
|
4175
|
+
// No preset fits every workflow, and the user should be free to adjust it
|
|
4176
|
+
// before it takes effect.
|
|
4177
|
+
const items = [
|
|
4178
|
+
{
|
|
4179
|
+
label: 'Edit current prompt',
|
|
4180
|
+
sub: custom ? 'edit the custom prompt in use' : 'edit the built-in prompt',
|
|
4181
|
+
action: 'edit-current',
|
|
4182
|
+
},
|
|
4183
|
+
{
|
|
4184
|
+
label: 'Start from a preset…',
|
|
4185
|
+
sub: `${FAMILIES.length} model families × ${TASKS.length} tasks`,
|
|
4186
|
+
action: 'presets',
|
|
4187
|
+
},
|
|
4188
|
+
];
|
|
4189
|
+
if (custom) {
|
|
4190
|
+
items.push({
|
|
4191
|
+
label: 'Restore built-in',
|
|
4192
|
+
sub: 'clear the override and go back to the shipped prompt',
|
|
4193
|
+
action: 'restore',
|
|
4194
|
+
});
|
|
4195
|
+
}
|
|
4196
|
+
openPicker({
|
|
4197
|
+
title: 'System prompt',
|
|
4198
|
+
items,
|
|
4199
|
+
searchable: false,
|
|
4200
|
+
hint: '↑↓ navigate · Enter select · Esc cancel',
|
|
4201
|
+
onPick: (it) => {
|
|
4202
|
+
if (it.action === 'edit-current') {
|
|
4203
|
+
edit(custom || builtin);
|
|
4204
|
+
return true;
|
|
4205
|
+
}
|
|
4206
|
+
if (it.action === 'restore') {
|
|
4207
|
+
try { setConfigString('system_prompt', ''); }
|
|
4208
|
+
catch (e) { appErr('Could not write config.toml: ' + e.message); return true; }
|
|
4209
|
+
cfg.systemPrompt = '';
|
|
4210
|
+
cfg.raw.system_prompt = '';
|
|
4211
|
+
app('System prompt override cleared; the built-in prompt is in use again.');
|
|
4212
|
+
return true;
|
|
4213
|
+
}
|
|
4214
|
+
// Preset picker: the FAMILY is the list (↑↓), the TASK is a footer row
|
|
4215
|
+
// (Tab to focus, ←→ to change) — the same shape /model uses for its
|
|
4216
|
+
// Thinking row. `presetState` remembers the chosen task across the
|
|
4217
|
+
// list selection.
|
|
4218
|
+
const presetState = { task: 'general' };
|
|
4219
|
+
const taskLabel = () => {
|
|
4220
|
+
const t = TASKS.find((x) => x.id === presetState.task) || TASKS[0];
|
|
4221
|
+
return t.label;
|
|
4222
|
+
};
|
|
4223
|
+
openPicker({
|
|
4224
|
+
title: 'System prompt presets',
|
|
4225
|
+
items: FAMILIES.map((f) => ({
|
|
4226
|
+
label: f.label,
|
|
4227
|
+
sub: f.sub,
|
|
4228
|
+
presetId: f.id,
|
|
4229
|
+
})),
|
|
4230
|
+
hint: '↑↓ family · Tab focus task · Enter load into editor · Esc cancel',
|
|
4231
|
+
footer: {
|
|
4232
|
+
label: 'Task',
|
|
4233
|
+
options: TASKS.map((t) => t.label),
|
|
4234
|
+
value: taskLabel(),
|
|
4235
|
+
focused: false,
|
|
4236
|
+
},
|
|
4237
|
+
onPick: (it) => {
|
|
4238
|
+
// Tab/←→ moved the footer; Enter on a list row applies (family, task).
|
|
4239
|
+
const task = TASKS.find((t) => t.label === state.picker.footer.value) || TASKS[0];
|
|
4240
|
+
const text = buildPreset(it.presetId, task.id);
|
|
4241
|
+
// An empty result means the preset could not be built (unknown family,
|
|
4242
|
+
// or a build() that failed). Say so instead of closing the menu with
|
|
4243
|
+
// nothing happening, which reads as a broken Enter key.
|
|
4244
|
+
if (!text) { appErr(`Could not build the "${it.presetId}" preset.`); return false; }
|
|
4245
|
+
edit(text, presetLabel(it.presetId, task.id));
|
|
4246
|
+
return true;
|
|
4247
|
+
},
|
|
4248
|
+
});
|
|
4249
|
+
return true;
|
|
3319
4250
|
},
|
|
3320
4251
|
});
|
|
3321
4252
|
return;
|
|
3322
4253
|
}
|
|
3323
4254
|
|
|
4255
|
+
case 'personal': {
|
|
4256
|
+
// Personal preferences: free-form notes injected into the system prompt on
|
|
4257
|
+
// EVERY turn (see /personal's handler comment in config.js). Two scopes —
|
|
4258
|
+
// global (~/.hncode/PERSONAL.md, all workspaces) and project
|
|
4259
|
+
// (<workspace>/.hncode/PERSONAL.md, this workspace only). Bare /personal
|
|
4260
|
+
// asks which scope; saving an EMPTY body deletes that scope's file.
|
|
4261
|
+
const workspace = state.workspace || cfg.workspace || process.cwd();
|
|
4262
|
+
const openScope = (scope) => {
|
|
4263
|
+
const file = personalPromptFile(scope, workspace);
|
|
4264
|
+
openEditor({
|
|
4265
|
+
title: `Personal preferences (${scope}) — ${file}`,
|
|
4266
|
+
text: readPersonalPromptRaw(scope, workspace),
|
|
4267
|
+
caretRow: 0,
|
|
4268
|
+
caretCol: 0,
|
|
4269
|
+
onSave: (value) => {
|
|
4270
|
+
try { writePersonalPrompt(scope, workspace, value); }
|
|
4271
|
+
catch (e) { appErr('Could not write ' + file + ': ' + e.message); return; }
|
|
4272
|
+
if (String(value).trim()) app(`Personal preferences saved (${scope}) → ${file}`);
|
|
4273
|
+
else app(`Personal preferences cleared (${scope}); nothing will be injected.`);
|
|
4274
|
+
},
|
|
4275
|
+
});
|
|
4276
|
+
};
|
|
4277
|
+
const firstLine = (scope) => {
|
|
4278
|
+
const t = readPersonalPromptRaw(scope, workspace).trim();
|
|
4279
|
+
return t ? t.split('\n')[0].slice(0, 48) : '(empty)';
|
|
4280
|
+
};
|
|
4281
|
+
const a = raw.toLowerCase();
|
|
4282
|
+
if (a === 'global' || a === 'g' || a === 'user') { openScope('global'); return; }
|
|
4283
|
+
if (a === 'project' || a === 'p' || a === 'local') { openScope('project'); return; }
|
|
4284
|
+
if (a === '') {
|
|
4285
|
+
openPicker({
|
|
4286
|
+
title: 'Personal preferences — choose a scope',
|
|
4287
|
+
items: [
|
|
4288
|
+
{ label: 'project', sub: `this workspace only — ${firstLine('project')}` },
|
|
4289
|
+
{ label: 'global', sub: `all workspaces — ${firstLine('global')}` },
|
|
4290
|
+
],
|
|
4291
|
+
onPick: (it) => { openScope(it.label); return true; },
|
|
4292
|
+
});
|
|
4293
|
+
return;
|
|
4294
|
+
}
|
|
4295
|
+
appErr('Usage: /personal [global|project]');
|
|
4296
|
+
return;
|
|
4297
|
+
}
|
|
4298
|
+
|
|
3324
4299
|
case 'calm-mode': {
|
|
3325
4300
|
// Terse-output mode: while ON, an instruction is injected with each request
|
|
3326
4301
|
// telling the model not to narrate what it is about to do or why, unless
|
|
@@ -3341,8 +4316,29 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
3341
4316
|
|
|
3342
4317
|
case 'init': {
|
|
3343
4318
|
if (!cfg.model) { appErr('LLM not set. Configure a provider with /provider first.'); return; }
|
|
3344
|
-
|
|
3345
|
-
|
|
4319
|
+
// Optional free-text instructions, like /compact's optional ratio: whatever
|
|
4320
|
+
// the user types after `/init` steers what goes into the file (e.g.
|
|
4321
|
+
// `/init focus on the tRPC routers and our migration rules`). With no
|
|
4322
|
+
// argument the built-in checklist is used on its own.
|
|
4323
|
+
const instructions = raw.trim();
|
|
4324
|
+
// Already have one? Then this is likely an UPDATE, and overwriting silently
|
|
4325
|
+
// would throw away whatever the user hand-edited in there.
|
|
4326
|
+
const existing = agentsFilesFor(state.cwd || cfg.workspace, cfg.workspace || state.cwd || process.cwd())
|
|
4327
|
+
.filter((f) => /(^|[\\/])agents?\.md$/i.test(f.path) && !/override/i.test(f.path));
|
|
4328
|
+
const base = 'Analyze this codebase and write an AGENTS.md file at the workspace root. '
|
|
4329
|
+
+ 'Keep it concise instructions for future agents: project layout, build/test commands, '
|
|
4330
|
+
+ 'and conventions. Prefer concrete commands and paths over prose.';
|
|
4331
|
+
const update = existing.length
|
|
4332
|
+
? `\n\nThere is already an AGENTS.md at ${existing[0].path}. Read it first, then revise it `
|
|
4333
|
+
+ 'in place: keep what is still accurate, correct what is stale, and add what is missing. '
|
|
4334
|
+
+ 'Do not discard existing instructions unless they are wrong.'
|
|
4335
|
+
: '';
|
|
4336
|
+
const extra = instructions ? `\n\nThe user asked you to focus on this:\n${instructions}` : '';
|
|
4337
|
+
say(instructions
|
|
4338
|
+
? '/init — generating AGENTS.md, guided by your instructions…'
|
|
4339
|
+
: '/init — analyzing the workspace to generate AGENTS.md…');
|
|
4340
|
+
sendPrompt(base + update + extra);
|
|
4341
|
+
return;
|
|
3346
4342
|
return;
|
|
3347
4343
|
}
|
|
3348
4344
|
|
|
@@ -3419,6 +4415,39 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
3419
4415
|
].join('\n'));
|
|
3420
4416
|
return;
|
|
3421
4417
|
}
|
|
4418
|
+
case 'auto-update': {
|
|
4419
|
+
// Toggle /auto-update. Persists to config.toml so it survives restarts.
|
|
4420
|
+
const a = raw.toLowerCase();
|
|
4421
|
+
let want;
|
|
4422
|
+
if (a === 'on') want = true;
|
|
4423
|
+
else if (a === 'off') want = false;
|
|
4424
|
+
else want = !cfg.autoUpdate;
|
|
4425
|
+
try { setConfigString('auto_update', want ? 'true' : 'false'); }
|
|
4426
|
+
catch (e) { appErr('Could not write config.toml: ' + e.message); return; }
|
|
4427
|
+
cfg.autoUpdate = want;
|
|
4428
|
+
cfg.raw.auto_update = want;
|
|
4429
|
+
app(want
|
|
4430
|
+
? 'Auto-update ON — checks npm at startup and every 30 min, installs in the background'
|
|
4431
|
+
: 'Auto-update OFF');
|
|
4432
|
+
return;
|
|
4433
|
+
}
|
|
4434
|
+
case 'update': {
|
|
4435
|
+
// Manual update check. This is the ONLY place an update is ever reported;
|
|
4436
|
+
// the automatic background check stays completely silent.
|
|
4437
|
+
app('Checking for updates...');
|
|
4438
|
+
cfg.updateRunning = true;
|
|
4439
|
+
try {
|
|
4440
|
+
const r = await upd.checkAndUpdate();
|
|
4441
|
+
if (r.status === 'updated') app(r.message);
|
|
4442
|
+
else if (r.status === 'uptodate') app(r.message);
|
|
4443
|
+
else app('Update check: ' + (r.message || 'failed'));
|
|
4444
|
+
} catch (e) {
|
|
4445
|
+
appErr('Update check failed: ' + (e && e.message || e));
|
|
4446
|
+
} finally {
|
|
4447
|
+
cfg.updateRunning = false;
|
|
4448
|
+
}
|
|
4449
|
+
return;
|
|
4450
|
+
}
|
|
3422
4451
|
case 'version': app(`hncode v${VERSION}`); return;
|
|
3423
4452
|
|
|
3424
4453
|
case 'mcp': {
|
|
@@ -3619,6 +4648,7 @@ case 'statusline':
|
|
|
3619
4648
|
plan: session.plan,
|
|
3620
4649
|
planPath: session.planPath,
|
|
3621
4650
|
focus: session.focus,
|
|
4651
|
+
swarm: session.swarm,
|
|
3622
4652
|
effort: session.effort,
|
|
3623
4653
|
theme: session.theme,
|
|
3624
4654
|
todos: (session.todos || []).slice(),
|
|
@@ -3874,6 +4904,20 @@ function tokenize(str) {
|
|
|
3874
4904
|
out.push({ ch: String.fromCodePoint(cp) });
|
|
3875
4905
|
i += ku[0].length; continue;
|
|
3876
4906
|
}
|
|
4907
|
+
// Shift+Arrow / Shift+Home / Shift+End: the caret MOVES while extending a
|
|
4908
|
+
// selection. Emitted as their own tokens so the composer can tell them
|
|
4909
|
+
// apart from the plain arrows (which scroll the transcript).
|
|
4910
|
+
if (shift && !ctrl && !alt && cp >= 0xE000 && cp < 0xE100) {
|
|
4911
|
+
const arrows = { 0xE000: 'up', 0xE001: 'down', 0xE003: 'left', 0xE002: 'right' };
|
|
4912
|
+
const a = arrows[cp];
|
|
4913
|
+
if (a) { out.push({ key: 'shift-' + a }); i += ku[0].length; continue; }
|
|
4914
|
+
}
|
|
4915
|
+
if (shift && !ctrl && !alt && (cp === 0xE014 || cp === 0xE015)) {
|
|
4916
|
+
out.push({ key: cp === 0xE014 ? 'shift-home' : 'shift-end' });
|
|
4917
|
+
i += ku[0].length; continue;
|
|
4918
|
+
}
|
|
4919
|
+
// Fall back for terminals that send the legacy CSI form with a `2` modifier
|
|
4920
|
+
// (handled in csiName) — nothing to do here.
|
|
3877
4921
|
if (ctrl && shift && !alt) {
|
|
3878
4922
|
// Ctrl+Shift+<letter> needs its own token. Collapsing it into 'c-<x>'
|
|
3879
4923
|
// made Ctrl+Shift+C indistinguishable from Ctrl+C, so "copy" ran the
|
|
@@ -3916,8 +4960,21 @@ function tokenize(str) {
|
|
|
3916
4960
|
if (str[j] === '?') { j++; while (j < str.length && str[j] >= '0' && str[j] <= '9') j++; }
|
|
3917
4961
|
const fin = str[j];
|
|
3918
4962
|
if (fin === undefined || !/[A-Za-z~]/.test(fin)) break;
|
|
3919
|
-
const
|
|
3920
|
-
|
|
4963
|
+
const rawParams = str.slice(i + 2, j);
|
|
4964
|
+
const params = (rawParams.match(/^\d+/) || [''])[0];
|
|
4965
|
+
// Modifier: the 2nd `;`-separated parameter, 1-based (`;2` = Shift, `;5`
|
|
4966
|
+
// = Ctrl, `;6` = Ctrl+Shift). Legacy terminals send Shift+Arrow this way
|
|
4967
|
+
// rather than as a CSI-u sequence.
|
|
4968
|
+
const modStr = (rawParams.match(/;(\d+)/) || [])[1];
|
|
4969
|
+
const mod = modStr ? parseInt(modStr, 10) - 1 : 0;
|
|
4970
|
+
const shift = (mod & 1) !== 0, ctrl = (mod & 4) !== 0;
|
|
4971
|
+
const base = csiName(params, fin);
|
|
4972
|
+
if (shift && !ctrl && ['up', 'down', 'left', 'right', 'home', 'end'].includes(base)) {
|
|
4973
|
+
out.push({ key: 'shift-' + base });
|
|
4974
|
+
i = j + 1;
|
|
4975
|
+
continue;
|
|
4976
|
+
}
|
|
4977
|
+
out.push({ key: base });
|
|
3921
4978
|
i = j + 1;
|
|
3922
4979
|
} else if (str[i + 1] === 'O') {
|
|
3923
4980
|
const f = str[i + 2];
|
|
@@ -3945,6 +5002,9 @@ function tokenize(str) {
|
|
|
3945
5002
|
// CSI-u sequence unless the Kitty protocol maps it), so it needs an explicit
|
|
3946
5003
|
// entry here — otherwise it fell through to { ch: '\x16' } and was dropped.
|
|
3947
5004
|
else if (c === '\x16') { out.push({ key: 'c-v' }); i++; }
|
|
5005
|
+
// Ctrl+X is the same story: raw 0x18. Ctrl+Shift+X comes through the CSI-u
|
|
5006
|
+
// path above as `c-s-x` on terminals that report modified keys.
|
|
5007
|
+
else if (c === '\x18') { out.push({ key: 'c-x' }); i++; }
|
|
3948
5008
|
else if (c === '\x7f' || c === '\x08') { out.push({ key: 'backspace' }); i++; }
|
|
3949
5009
|
else { out.push({ ch: c }); i++; }
|
|
3950
5010
|
}
|
|
@@ -4014,7 +5074,27 @@ export async function startTUI(opts) {
|
|
|
4014
5074
|
// Only auto-follow when the view is ALREADY pinned to the bottom. Forcing
|
|
4015
5075
|
// scroll = 0 unconditionally yanked the user back down whenever new output
|
|
4016
5076
|
// arrived — so reading history mid-turn was impossible.
|
|
4017
|
-
|
|
5077
|
+
//
|
|
5078
|
+
// This used to be `if ((state.scroll||0) === 0) state.scroll = 0;`, i.e. a
|
|
5079
|
+
// NO-OP. The comment promised "auto-follow" but nothing re-anchored the view,
|
|
5080
|
+
// so appending a tall message (a long Read/Bash result) pushed the whole
|
|
5081
|
+
// transcript up by its height in a single frame instead of scrolling by one
|
|
5082
|
+
// row — the "everything jumps" report. When the user IS scrolled up we leave
|
|
5083
|
+
// the offset alone, which is the other half of the original intent.
|
|
5084
|
+
if ((state.scroll || 0) === 0) {
|
|
5085
|
+
state.scroll = 0; // pinned: keep following the newest message
|
|
5086
|
+
} else {
|
|
5087
|
+
// Scrolled up: grow the offset by exactly the number of rows this message
|
|
5088
|
+
// added, so the content the user is reading does not move.
|
|
5089
|
+
try {
|
|
5090
|
+
const rows = renderChatLines(state, dims().cols).length;
|
|
5091
|
+
if (state._addChatRows != null) {
|
|
5092
|
+
const grew = Math.max(0, rows - state._addChatRows);
|
|
5093
|
+
if (grew) state.scroll = (state.scroll || 0) + grew;
|
|
5094
|
+
}
|
|
5095
|
+
state._addChatRows = rows;
|
|
5096
|
+
} catch { /* rendering is best-effort here; never break a message append */ }
|
|
5097
|
+
}
|
|
4018
5098
|
renderFrame();
|
|
4019
5099
|
};
|
|
4020
5100
|
let noticeTimer = null;
|
|
@@ -4140,6 +5220,145 @@ export async function startTUI(opts) {
|
|
|
4140
5220
|
renderFrame();
|
|
4141
5221
|
}
|
|
4142
5222
|
|
|
5223
|
+
// ---- /tasks panel (kimi's TASK BROWSER) ----------------------------------
|
|
5224
|
+
function refreshTasksPanel() {
|
|
5225
|
+
const p = state.tasksPanel;
|
|
5226
|
+
if (!p) return;
|
|
5227
|
+
p.tasks = sortedTasks(state.agent ? state.agent.ctx : { tasks: state.tasks });
|
|
5228
|
+
const vis = visibleTasks(p.tasks, p.filter);
|
|
5229
|
+
if (p.selectedIndex >= vis.length) p.selectedIndex = Math.max(0, vis.length - 1);
|
|
5230
|
+
}
|
|
5231
|
+
|
|
5232
|
+
function openTasksPanel() {
|
|
5233
|
+
const tasks = sortedTasks(state.tasks ? { tasks: state.tasks } : {});
|
|
5234
|
+
state.tasksPanel = {
|
|
5235
|
+
tasks,
|
|
5236
|
+
filter: 'all',
|
|
5237
|
+
selectedIndex: 0,
|
|
5238
|
+
listScroll: 0,
|
|
5239
|
+
pendingStop: null,
|
|
5240
|
+
flash: '',
|
|
5241
|
+
flashTimer: null,
|
|
5242
|
+
};
|
|
5243
|
+
const vis = visibleTasks(tasks, 'all');
|
|
5244
|
+
const runningIdx = vis.findIndex((x) => x.status === 'running');
|
|
5245
|
+
state.tasksPanel.selectedIndex = runningIdx >= 0 ? runningIdx : 0;
|
|
5246
|
+
state.menuOpen = false; state.menuList = []; state.menuSel = 0;
|
|
5247
|
+
state.picker = null; state.form = null; state.panel = null;
|
|
5248
|
+
renderFrame();
|
|
5249
|
+
}
|
|
5250
|
+
|
|
5251
|
+
function closeTasksPanel() {
|
|
5252
|
+
const p = state.tasksPanel;
|
|
5253
|
+
if (!p) return;
|
|
5254
|
+
if (p.flashTimer) clearTimeout(p.flashTimer);
|
|
5255
|
+
if (p.pollTimer) clearInterval(p.pollTimer);
|
|
5256
|
+
state.tasksPanel = null;
|
|
5257
|
+
renderFrame();
|
|
5258
|
+
}
|
|
5259
|
+
|
|
5260
|
+
function tasksFlash(msg) {
|
|
5261
|
+
const p = state.tasksPanel;
|
|
5262
|
+
if (!p) return;
|
|
5263
|
+
p.flash = msg;
|
|
5264
|
+
if (p.flashTimer) clearTimeout(p.flashTimer);
|
|
5265
|
+
p.flashTimer = setTimeout(() => { p.flash = ''; renderFrame(); }, 2500);
|
|
5266
|
+
}
|
|
5267
|
+
|
|
5268
|
+
function openTaskOutputViewer(taskId) {
|
|
5269
|
+
const ctxLike = state.agent && state.agent.ctx ? state.agent.ctx : { tasks: state.tasks };
|
|
5270
|
+
const task = getTask(ctxLike, taskId) || (state.tasks || {})[taskId];
|
|
5271
|
+
if (!task) { tasksFlash('No such task: ' + taskId); return; }
|
|
5272
|
+
state.tasksViewer = makeViewerState(task);
|
|
5273
|
+
renderFrame();
|
|
5274
|
+
}
|
|
5275
|
+
|
|
5276
|
+
function closeTaskOutputViewer() {
|
|
5277
|
+
state.tasksViewer = null;
|
|
5278
|
+
renderFrame();
|
|
5279
|
+
}
|
|
5280
|
+
|
|
5281
|
+
// Returns true when the key was consumed by the panel / viewer.
|
|
5282
|
+
function handleTasksPanelKey(t) {
|
|
5283
|
+
const p = state.tasksPanel;
|
|
5284
|
+
if (!p) return false;
|
|
5285
|
+
const ctxLike = state.agent && state.agent.ctx ? state.agent.ctx : { tasks: state.tasks };
|
|
5286
|
+
// Snapshot the STOP target BEFORE the key handler clears pendingStop on 'y'.
|
|
5287
|
+
const stopTarget = p.pendingStop;
|
|
5288
|
+
const action = handleTasksBrowserKey(p, t);
|
|
5289
|
+
p.tasks = sortedTasks(ctxLike);
|
|
5290
|
+
const vis = visibleTasks(p.tasks, p.filter);
|
|
5291
|
+
const sel = vis[p.selectedIndex];
|
|
5292
|
+
|
|
5293
|
+
switch (action) {
|
|
5294
|
+
case 'close': closeTasksPanel(); return true;
|
|
5295
|
+
case 'toggleFilter':
|
|
5296
|
+
p.filter = p.filter === 'all' ? 'active' : 'all';
|
|
5297
|
+
p.selectedIndex = 0;
|
|
5298
|
+
break;
|
|
5299
|
+
case 'refresh':
|
|
5300
|
+
p.tasks = sortedTasks(ctxLike);
|
|
5301
|
+
tasksFlash('Refreshed');
|
|
5302
|
+
break;
|
|
5303
|
+
case 'stopIgnored':
|
|
5304
|
+
tasksFlash((sel ? sel.taskId : '') + ' is already terminal - nothing to stop.');
|
|
5305
|
+
break;
|
|
5306
|
+
case 'requestStop':
|
|
5307
|
+
// Nothing to do yet: the footer now asks for Y to confirm.
|
|
5308
|
+
break;
|
|
5309
|
+
case 'cancelStop':
|
|
5310
|
+
tasksFlash('Stop cancelled');
|
|
5311
|
+
break;
|
|
5312
|
+
case 'confirmStop': {
|
|
5313
|
+
// 'y' was pressed: stop the task that was pending.
|
|
5314
|
+
stopTask(ctxLike, stopTarget);
|
|
5315
|
+
p.tasks = sortedTasks(ctxLike);
|
|
5316
|
+
break;
|
|
5317
|
+
}
|
|
5318
|
+
case 'openOutput':
|
|
5319
|
+
if (sel) openTaskOutputViewer(sel.taskId);
|
|
5320
|
+
return true;
|
|
5321
|
+
default:
|
|
5322
|
+
break;
|
|
5323
|
+
}
|
|
5324
|
+
renderFrame();
|
|
5325
|
+
return true;
|
|
5326
|
+
}
|
|
5327
|
+
|
|
5328
|
+
// Kill / abort one task by id. An agent task aborts through its controller; a
|
|
5329
|
+
// process is killed as a tree (Windows) or by signal.
|
|
5330
|
+
function stopTask(ctxLike, id) {
|
|
5331
|
+
if (!id) return;
|
|
5332
|
+
const tk = getTask(ctxLike, id) || (state.tasks || {})[id];
|
|
5333
|
+
if (!tk || tk.status !== 'running') return;
|
|
5334
|
+
try {
|
|
5335
|
+
if (tk.kind === 'agent' && typeof tk._abort === 'function') {
|
|
5336
|
+
tk._abort();
|
|
5337
|
+
} else if (tk.pid) {
|
|
5338
|
+
if (process.platform === 'win32') {
|
|
5339
|
+
try { cp.spawnSync('taskkill', ['/pid', String(tk.pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
|
|
5340
|
+
} else {
|
|
5341
|
+
try { process.kill(tk.pid, 'SIGTERM'); } catch {}
|
|
5342
|
+
}
|
|
5343
|
+
}
|
|
5344
|
+
} catch {}
|
|
5345
|
+
settleTask(tk, 'killed', { stopReason: 'User initiated stop' });
|
|
5346
|
+
tasksFlash('Stopped ' + id);
|
|
5347
|
+
}
|
|
5348
|
+
|
|
5349
|
+
function handleTasksViewerKey(t) {
|
|
5350
|
+
const v = state.tasksViewer;
|
|
5351
|
+
if (!v) return false;
|
|
5352
|
+
// Refresh from the live store so a running task's tail keeps moving.
|
|
5353
|
+
const ctxLike = state.agent && state.agent.ctx ? state.agent.ctx : { tasks: state.tasks };
|
|
5354
|
+
const fresh = getTask(ctxLike, v.task.taskId);
|
|
5355
|
+
if (fresh) v.task = fresh;
|
|
5356
|
+
const action = handleViewerKey(v, t, dims().rows);
|
|
5357
|
+
if (action === 'close') closeTaskOutputViewer();
|
|
5358
|
+
else renderFrame();
|
|
5359
|
+
return true;
|
|
5360
|
+
}
|
|
5361
|
+
|
|
4143
5362
|
if (!stdin.isTTY) {
|
|
4144
5363
|
return 1;
|
|
4145
5364
|
}
|
|
@@ -4184,6 +5403,26 @@ export async function startTUI(opts) {
|
|
|
4184
5403
|
if (rate !== state.tokRate) { state.tokRate = rate; renderFrame(); }
|
|
4185
5404
|
}, 50);
|
|
4186
5405
|
|
|
5406
|
+
// ---- auto-update (background + SILENT, never blocks the TUI) --------------
|
|
5407
|
+
// If autoUpdate is on, check npm at startup and then every 30 minutes. The
|
|
5408
|
+
// check and the install both run detached, and NOTHING is ever printed: the
|
|
5409
|
+
// whole point is that the user never notices. Version notices appear only for
|
|
5410
|
+
// the explicit /update command (see its dispatch case).
|
|
5411
|
+
let autoCheckTimer = null;
|
|
5412
|
+
let autoCheckInflight = false;
|
|
5413
|
+
const runAutoCheck = async () => {
|
|
5414
|
+
if (!cfg.autoUpdate || autoCheckInflight) return;
|
|
5415
|
+
autoCheckInflight = true;
|
|
5416
|
+
try { await upd.checkAndUpdate(); } catch { /* silent */ }
|
|
5417
|
+
finally { autoCheckInflight = false; }
|
|
5418
|
+
};
|
|
5419
|
+
if (cfg.autoUpdate) {
|
|
5420
|
+
setTimeout(() => { void runAutoCheck(); }, 800);
|
|
5421
|
+
autoCheckTimer = setInterval(() => { void runAutoCheck(); }, upd.AUTO_UPDATE_INTERVAL_MS);
|
|
5422
|
+
if (autoCheckTimer.unref) autoCheckTimer.unref();
|
|
5423
|
+
}
|
|
5424
|
+
|
|
5425
|
+
|
|
4187
5426
|
let keyBuf = '';
|
|
4188
5427
|
let escTimer = null;
|
|
4189
5428
|
let escRetries = 0;
|
|
@@ -4248,12 +5487,13 @@ export async function startTUI(opts) {
|
|
|
4248
5487
|
const { cols } = dims();
|
|
4249
5488
|
return (t.col || 1) === cols;
|
|
4250
5489
|
}
|
|
4251
|
-
|
|
5490
|
+
// Drag the thumb. `rowF` may be fractional (the pointer reports sub-cell
|
|
5491
|
+
// resolution), and `grabOffset` remembers where inside the thumb the press
|
|
5492
|
+
// landed so the thumb does not jump under the cursor on the first move.
|
|
5493
|
+
function scrollFromThumb(rowF) {
|
|
4252
5494
|
const sb = state._sb;
|
|
4253
5495
|
if (!sb) return;
|
|
4254
|
-
|
|
4255
|
-
const frac = Math.max(0, Math.min(1, (rowInBody - (state.sbDragOffset || 0)) / maxThumbStart));
|
|
4256
|
-
state.scroll = Math.round((1 - frac) * sb.maxScroll);
|
|
5496
|
+
state.scroll = scrollFromThumbPos(sb, rowF, state.sbDragOffset || 0);
|
|
4257
5497
|
renderFrame();
|
|
4258
5498
|
}
|
|
4259
5499
|
function hitAt(t) {
|
|
@@ -4361,8 +5601,12 @@ export async function startTUI(opts) {
|
|
|
4361
5601
|
const sb = state._sb;
|
|
4362
5602
|
const { rowInBody } = mouseToCell(t);
|
|
4363
5603
|
state.sbDrag = true;
|
|
5604
|
+
// Grab INSIDE the thumb (so it does not jump under the pointer); off the
|
|
5605
|
+
// thumb, centre it on the pointer. Uses the FRACTIONAL length so the grab
|
|
5606
|
+
// point is not quantised to a whole row.
|
|
5607
|
+
const len = sb.thumbLenF != null ? sb.thumbLenF : sb.thumbLen;
|
|
4364
5608
|
state.sbDragOffset = (rowInBody >= sb.thumbStart && rowInBody < sb.thumbStart + sb.thumbLen)
|
|
4365
|
-
? rowInBody - sb.thumbStart :
|
|
5609
|
+
? rowInBody - sb.thumbStart : len / 2;
|
|
4366
5610
|
scrollFromThumb(rowInBody);
|
|
4367
5611
|
return;
|
|
4368
5612
|
}
|
|
@@ -4668,6 +5912,7 @@ export async function startTUI(opts) {
|
|
|
4668
5912
|
const items = [];
|
|
4669
5913
|
if (hasSel) items.push({ label: 'Copy', sub: 'copy the selected text', action: 'copy', primary: true });
|
|
4670
5914
|
if (hasComposerSel) items.push({ label: 'Copy', sub: 'copy the selected composer text', action: 'copy-composer', primary: true });
|
|
5915
|
+
if (hasComposerSel) items.push({ label: 'Cut', sub: 'cut the selected composer text to the clipboard', action: 'cut-composer' });
|
|
4671
5916
|
items.push({ label: 'Copy last answer', sub: 'copy the last assistant message', action: 'copy-last' });
|
|
4672
5917
|
items.push({ label: 'Clear selection', sub: 'drop the current selection', action: 'clear' });
|
|
4673
5918
|
if (hasComposerSel) items.push({ label: 'Clear composer selection', sub: 'drop the composer selection', action: 'clear-composer' });
|
|
@@ -4686,6 +5931,17 @@ export async function startTUI(opts) {
|
|
|
4686
5931
|
const h = Math.max(sel.anchor, sel.head);
|
|
4687
5932
|
copyToClipboard((state.input || '').slice(a, h));
|
|
4688
5933
|
}
|
|
5934
|
+
else if (it.action === 'cut-composer') {
|
|
5935
|
+
// Same operation as Ctrl+X: to the clipboard, then out of the composer.
|
|
5936
|
+
const sel = state.composerSel;
|
|
5937
|
+
const a = Math.min(sel.anchor, sel.head);
|
|
5938
|
+
const h = Math.max(sel.anchor, sel.head);
|
|
5939
|
+
copyToClipboard((state.input || '').slice(a, h));
|
|
5940
|
+
state.input = (state.input || '').slice(0, a) + (state.input || '').slice(h);
|
|
5941
|
+
state.caret = a;
|
|
5942
|
+
state.composerSel = null;
|
|
5943
|
+
refreshMenu(state);
|
|
5944
|
+
}
|
|
4689
5945
|
else if (it.action === 'copy-last') {
|
|
4690
5946
|
const last = [...state.chat].reverse().find((m) => m.role === 'assistant' && (m.text || '').trim());
|
|
4691
5947
|
if (last) copyToClipboard(last.text); else notice('No assistant message to copy', 'error');
|
|
@@ -4705,6 +5961,9 @@ export async function startTUI(opts) {
|
|
|
4705
5961
|
}
|
|
4706
5962
|
|
|
4707
5963
|
function handleKey(t) {
|
|
5964
|
+
// Full-screen takeovers own EVERY key while open.
|
|
5965
|
+
if (state.tasksViewer) { handleTasksViewerKey(t); return; }
|
|
5966
|
+
if (state.tasksPanel) { handleTasksPanelKey(t); return; }
|
|
4708
5967
|
if (t.key === 'mousedown' || t.key === 'mousemove' || t.key === 'mouseup' || t.key === 'rightclick' || t.key === 'mousehover') {
|
|
4709
5968
|
handleMouse(t);
|
|
4710
5969
|
return;
|
|
@@ -4730,13 +5989,40 @@ export async function startTUI(opts) {
|
|
|
4730
5989
|
// keep drafting while deciding.
|
|
4731
5990
|
if (t.key === 'c-c') return;
|
|
4732
5991
|
}
|
|
5992
|
+
// Plan approval: the model produced a <plan> and Plan mode is waiting.
|
|
5993
|
+
// Enter = approve (Plan turns off, the plan is sent back to be executed).
|
|
5994
|
+
// Esc = keep planning, do NOT execute.
|
|
5995
|
+
if (state.planPending) {
|
|
5996
|
+
if (t.key === 'enter' || t.key === ' ') {
|
|
5997
|
+
const resolve = state.planPending.resolve;
|
|
5998
|
+
state.planPending = null;
|
|
5999
|
+
resolve(true);
|
|
6000
|
+
renderFrame();
|
|
6001
|
+
return;
|
|
6002
|
+
}
|
|
6003
|
+
if (t.key === 'escape') {
|
|
6004
|
+
const resolve = state.planPending.resolve;
|
|
6005
|
+
state.planPending = null;
|
|
6006
|
+
resolve(false);
|
|
6007
|
+
renderFrame();
|
|
6008
|
+
return;
|
|
6009
|
+
}
|
|
6010
|
+
if (t.key === 'c-c') return; // do not exit while a plan is under review
|
|
6011
|
+
}
|
|
4733
6012
|
if (t.key === 'c-c') {
|
|
4734
6013
|
// An open overlay consumes Ctrl+C: close it instead of interrupting the
|
|
4735
|
-
// turn that is still running behind it.
|
|
4736
|
-
|
|
6014
|
+
// turn that is still running behind it. The modal editor belongs here too:
|
|
6015
|
+
// without it Ctrl+C fell through to the exit path below, armed the
|
|
6016
|
+
// "Press Ctrl+C again to exit" prompt behind the editor (which stays on
|
|
6017
|
+
// screen), and a second Ctrl+C — often the very key the user pressed again
|
|
6018
|
+
// to dismiss the prompt — quit the app and discarded the draft.
|
|
6019
|
+
if (state.menuOpen || state.picker || state.form || state.panel || state.question || state.editor) {
|
|
4737
6020
|
if (state.picker) { const cb = state.picker.onCancel; state.picker = null; if (cb) cb(); }
|
|
4738
6021
|
if (state.form) { const cb = state.form.onCancel; state.form = null; if (cb) cb(); }
|
|
6022
|
+
// Ctrl+C on the question dialog = dismiss, the same as Esc.
|
|
6023
|
+
if (state.question) { const r = state.question.resolve; state.question = null; r({}); }
|
|
4739
6024
|
state.panel = null;
|
|
6025
|
+
state.editor = null; // Ctrl+C cancels the editor, like Esc
|
|
4740
6026
|
state.menuOpen = false; state.menuList = []; state.menuSel = 0; state.menuOffset = 0;
|
|
4741
6027
|
renderFrame();
|
|
4742
6028
|
return;
|
|
@@ -4751,8 +6037,131 @@ export async function startTUI(opts) {
|
|
|
4751
6037
|
confirmTimer = setTimeout(() => { state.confirmExit = false; renderFrame(); }, 3000);
|
|
4752
6038
|
return;
|
|
4753
6039
|
}
|
|
6040
|
+
if (state.question) {
|
|
6041
|
+
// AskUserQuestion dialog. Owns ALL input while open — unlike the approval
|
|
6042
|
+
// prompt, the user is here to answer, and the free-text "Other" row needs
|
|
6043
|
+
// the character keys for itself.
|
|
6044
|
+
const qs = state.question;
|
|
6045
|
+
const cur = qs.items[qs.index] || { options: [] };
|
|
6046
|
+
const isLast = qs.index === qs.items.length - 1;
|
|
6047
|
+
// The list is: options, then "Other", then (last question only) the global
|
|
6048
|
+
// supplement row.
|
|
6049
|
+
const otherIdx = (cur.options || []).length;
|
|
6050
|
+
const suppIdx = isLast ? otherIdx + 1 : -1;
|
|
6051
|
+
const optCount = otherIdx + 1 + (isLast ? 1 : 0);
|
|
6052
|
+
// Resolve the call. `dismissed` distinguishes "Esc out of everything" from
|
|
6053
|
+
// "answered, and maybe left a note" so the tool can emit kimi's dismissal
|
|
6054
|
+
// contract instead of a half-filled result.
|
|
6055
|
+
const finish = (dismissed) => {
|
|
6056
|
+
const r = qs.resolve; state.question = null;
|
|
6057
|
+
r(dismissed ? {} : { answers: qs.answers, additional: (qs.supplement || '').trim() });
|
|
6058
|
+
renderFrame();
|
|
6059
|
+
};
|
|
6060
|
+
|
|
6061
|
+
// Move on after an answer was recorded.
|
|
6062
|
+
// * a further question -> ask it, cursor at the top
|
|
6063
|
+
// * the LAST question -> drop the cursor on the supplement row so the
|
|
6064
|
+
// very next Enter opens the note editor (or Esc/Enter twice skips it).
|
|
6065
|
+
// Jumping straight to `finish` made the row unreachable: `sel` stayed on
|
|
6066
|
+
// the option just picked, so a second Enter re-picked that option.
|
|
6067
|
+
const advance = () => {
|
|
6068
|
+
if (qs.index < qs.items.length - 1) {
|
|
6069
|
+
qs.index++;
|
|
6070
|
+
qs.sel = 0;
|
|
6071
|
+
qs.picked = new Set();
|
|
6072
|
+
renderFrame();
|
|
6073
|
+
return;
|
|
6074
|
+
}
|
|
6075
|
+
qs.sel = (cur.options || []).length + 1; // the supplement row
|
|
6076
|
+
renderFrame();
|
|
6077
|
+
};
|
|
6078
|
+
|
|
6079
|
+
if (qs.editing) {
|
|
6080
|
+
// Free-text entry, shared by the per-question "Other" and the global
|
|
6081
|
+
// supplement (qs.editing says which). Esc backs out WITHOUT losing what
|
|
6082
|
+
// was typed, so a half-written note survives a look at the options.
|
|
6083
|
+
if (t.key === 'escape') { qs.editing = null; renderFrame(); return; }
|
|
6084
|
+
if (t.key === 'backspace') { qs.editingText = (qs.editingText || '').slice(0, -1); renderFrame(); return; }
|
|
6085
|
+
if (t.paste !== undefined) { qs.editingText += String(t.paste).replace(/[\r\n]+/g, ' '); renderFrame(); return; }
|
|
6086
|
+
if (t.key === 'enter') {
|
|
6087
|
+
const answer = (qs.editingText || '').trim();
|
|
6088
|
+
if (qs.editing === 'supplement') {
|
|
6089
|
+
// The supplement is optional: Enter commits it (empty is fine) and
|
|
6090
|
+
// ends the call — it is the last row, so nothing follows it.
|
|
6091
|
+
qs.supplement = answer;
|
|
6092
|
+
qs.editing = null;
|
|
6093
|
+
finish(false);
|
|
6094
|
+
return;
|
|
6095
|
+
}
|
|
6096
|
+
if (!answer) { renderFrame(); return; } // nothing typed: stay
|
|
6097
|
+
qs.answers[cur.question] = answer;
|
|
6098
|
+
qs.otherText = '';
|
|
6099
|
+
qs.editing = null;
|
|
6100
|
+
advance();
|
|
6101
|
+
return;
|
|
6102
|
+
}
|
|
6103
|
+
if (t.ch) { qs.editingText = (qs.editingText || '') + t.ch; renderFrame(); return; }
|
|
6104
|
+
return;
|
|
6105
|
+
}
|
|
6106
|
+
|
|
6107
|
+
if (t.key === 'escape') { finish(true); return; } // dismiss: empty answers
|
|
6108
|
+
if (t.key === 'up') { qs.sel = (qs.sel - 1 + optCount) % optCount; renderFrame(); return; }
|
|
6109
|
+
if (t.key === 'down') { qs.sel = (qs.sel + 1) % optCount; renderFrame(); return; }
|
|
6110
|
+
|
|
6111
|
+
// Open a free-text editor, seeding it with whatever was typed before.
|
|
6112
|
+
const openEditor = (which) => {
|
|
6113
|
+
qs.editing = which;
|
|
6114
|
+
qs.editingText = which === 'supplement' ? (qs.supplement || '') : (qs.otherText || '');
|
|
6115
|
+
renderFrame();
|
|
6116
|
+
};
|
|
6117
|
+
|
|
6118
|
+
if (cur.multiSelect && t.key === ' ') {
|
|
6119
|
+
if (qs.sel >= otherIdx) { openEditor(qs.sel === suppIdx ? 'supplement' : 'other'); return; }
|
|
6120
|
+
if (qs.picked.has(qs.sel)) qs.picked.delete(qs.sel); else qs.picked.add(qs.sel);
|
|
6121
|
+
renderFrame(); return;
|
|
6122
|
+
}
|
|
6123
|
+
if (t.key === 'enter') {
|
|
6124
|
+
if (qs.sel >= otherIdx) { openEditor(qs.sel === suppIdx ? 'supplement' : 'other'); return; }
|
|
6125
|
+
if (cur.multiSelect) {
|
|
6126
|
+
// Enter CONFIRMS the multi-select; an empty selection is allowed (the
|
|
6127
|
+
// user may genuinely want none of them).
|
|
6128
|
+
const labels = [...qs.picked].sort((a, b) => a - b).map((i) => cur.options[i].label);
|
|
6129
|
+
if (labels.length) qs.answers[cur.question] = labels.join(', ');
|
|
6130
|
+
advance();
|
|
6131
|
+
return;
|
|
6132
|
+
}
|
|
6133
|
+
qs.answers[cur.question] = cur.options[qs.sel].label;
|
|
6134
|
+
advance();
|
|
6135
|
+
return;
|
|
6136
|
+
}
|
|
6137
|
+
return;
|
|
6138
|
+
}
|
|
6139
|
+
|
|
4754
6140
|
if (state.confirmExit) { state.confirmExit = false; if (confirmTimer) clearTimeout(confirmTimer); }
|
|
4755
6141
|
|
|
6142
|
+
// Ctrl+X / Ctrl+Shift+X — CUT. Removes the composer's selected text (or the
|
|
6143
|
+
// whole line when nothing is selected) and puts it on the clipboard, like a
|
|
6144
|
+
// normal editor. Ctrl+Shift+X is accepted as an alias because some terminals
|
|
6145
|
+
// only deliver the modified form.
|
|
6146
|
+
if (t.key === 'c-x' || t.key === 'c-s-x') {
|
|
6147
|
+
const cur = state.input || '';
|
|
6148
|
+
const sel = state.composerSel;
|
|
6149
|
+
if (sel && sel.anchor !== sel.head) {
|
|
6150
|
+
const a = Math.min(sel.anchor, sel.head);
|
|
6151
|
+
const h = Math.max(sel.anchor, sel.head);
|
|
6152
|
+
const removed = cur.slice(a, h);
|
|
6153
|
+
state.input = cur.slice(0, a) + cur.slice(h);
|
|
6154
|
+
state.caret = a;
|
|
6155
|
+
state.composerSel = null;
|
|
6156
|
+
copyToClipboard(removed);
|
|
6157
|
+
refreshMenu(state);
|
|
6158
|
+
} else {
|
|
6159
|
+
notice('Nothing selected to cut', 'error');
|
|
6160
|
+
}
|
|
6161
|
+
renderFrame();
|
|
6162
|
+
return;
|
|
6163
|
+
}
|
|
6164
|
+
|
|
4756
6165
|
if (t.key === 'c-s-c') {
|
|
4757
6166
|
// Ctrl+Shift+C: copy the mouse selection, else the last answer — the same
|
|
4758
6167
|
// two actions as the right-click menu's Copy / Copy last answer. Works
|
|
@@ -4967,6 +6376,7 @@ export async function startTUI(opts) {
|
|
|
4967
6376
|
return;
|
|
4968
6377
|
}
|
|
4969
6378
|
|
|
6379
|
+
|
|
4970
6380
|
if (state.form) {
|
|
4971
6381
|
const f = state.form;
|
|
4972
6382
|
const nRows = f.fields.length + (f.hideType ? 0 : 1);
|
|
@@ -5218,7 +6628,12 @@ export async function startTUI(opts) {
|
|
|
5218
6628
|
renderFrame(); return;
|
|
5219
6629
|
}
|
|
5220
6630
|
if (t.key === 'wheelup' || t.key === 'wheeldown') {
|
|
5221
|
-
|
|
6631
|
+
// With the pointer ON the scrollbar, scroll one line per notch: the bar is
|
|
6632
|
+
// the precision control, and 3-line steps there feel jumpy on a long
|
|
6633
|
+
// transcript. Everywhere else keeps the usual 3-line step.
|
|
6634
|
+
const onBar = !!(state._lastMouse && isOnScrollbar(state._lastMouse));
|
|
6635
|
+
const step = onBar ? 1 : 3;
|
|
6636
|
+
const d = t.key === 'wheelup' ? step : -step;
|
|
5222
6637
|
if (state.panel) {
|
|
5223
6638
|
const p = state.panel;
|
|
5224
6639
|
p.top = Math.max(0, (p.top || 0) + (t.key === 'wheelup' ? -3 : 3));
|
|
@@ -5262,6 +6677,41 @@ export async function startTUI(opts) {
|
|
|
5262
6677
|
scrollChat(state, t.key === 'pageup' ? 10 : -10);
|
|
5263
6678
|
renderFrame(); return;
|
|
5264
6679
|
}
|
|
6680
|
+
// Shift+arrows EXTEND a composer selection instead of moving the caret. The
|
|
6681
|
+
// anchor is remembered on the first shift-move so moving back shrinks it.
|
|
6682
|
+
if (t.key && t.key.startsWith('shift-') && !state.editor && !state.picker && !state.form && !state.panel) {
|
|
6683
|
+
const dirName = t.key.slice(6); // left | right | up | down | home | end
|
|
6684
|
+
const cur = state.input || '';
|
|
6685
|
+
const caret = state.caret || 0;
|
|
6686
|
+
const anchor = state.composerSel ? state.composerSel.anchor : caret;
|
|
6687
|
+
let next = caret;
|
|
6688
|
+
if (dirName === 'left') {
|
|
6689
|
+
const mk = adjacentPasteMarker(cur, caret, -1);
|
|
6690
|
+
next = mk ? mk.start : Math.max(0, caret - 1);
|
|
6691
|
+
} else if (dirName === 'right') {
|
|
6692
|
+
const mk = adjacentPasteMarker(cur, caret, 1);
|
|
6693
|
+
next = mk ? mk.end : Math.min(cur.length, caret + 1);
|
|
6694
|
+
} else if (dirName === 'home') {
|
|
6695
|
+
next = 0;
|
|
6696
|
+
} else if (dirName === 'end') {
|
|
6697
|
+
next = cur.length;
|
|
6698
|
+
} else {
|
|
6699
|
+
// up/down: move a visual row keeping the column, like the plain arrows,
|
|
6700
|
+
// so a multi-line prompt can be selected across lines.
|
|
6701
|
+
const insideW = Math.max(0, dims().cols - 2);
|
|
6702
|
+
const layout = composerLayout(state, insideW - 3);
|
|
6703
|
+
const targetRow = layout.caretRow + (dirName === 'down' ? 1 : -1);
|
|
6704
|
+
if (targetRow < 0 || targetRow >= layout.rows.length) { renderFrame(); return; }
|
|
6705
|
+
const starts = rowStartOffsets(cur, layout.rows.length, insideW);
|
|
6706
|
+
const colInRow = Math.max(0, layout.caretCol - 1);
|
|
6707
|
+
const targetStart = starts[targetRow] != null ? starts[targetRow] : 0;
|
|
6708
|
+
next = Math.min(cur.length, targetStart + colInRow);
|
|
6709
|
+
}
|
|
6710
|
+
state.caret = next;
|
|
6711
|
+
state.composerSel = (anchor === next) ? null : { anchor, head: next };
|
|
6712
|
+
renderFrame(); return;
|
|
6713
|
+
}
|
|
6714
|
+
|
|
5265
6715
|
if (t.key === 'left' || t.key === 'right') {
|
|
5266
6716
|
const dir = t.key === 'left' ? -1 : 1;
|
|
5267
6717
|
const mk = adjacentPasteMarker(state.input, state.caret || 0, dir);
|
|
@@ -5391,8 +6841,9 @@ export async function startTUI(opts) {
|
|
|
5391
6841
|
}
|
|
5392
6842
|
|
|
5393
6843
|
function statusExtra(state) {
|
|
6844
|
+
// No `notice` term: the notice shares the context row instead of taking a
|
|
6845
|
+
// row of its own, so it must not shrink the calculated chat viewport either.
|
|
5394
6846
|
return (state.confirmExit ? 1 : 0)
|
|
5395
|
-
+ (state.notice ? 1 : 0)
|
|
5396
6847
|
+ (state.running ? 1 : 0)
|
|
5397
6848
|
+ ((state.menuOpen && state.menuList.length) ? Math.min(state.menuList.length, MAX_MENU) + 1 : 0);
|
|
5398
6849
|
}
|
|
@@ -5425,6 +6876,10 @@ export async function startTUI(opts) {
|
|
|
5425
6876
|
state.turnStart = Date.now();
|
|
5426
6877
|
// Start animation: 0.5s type out + 0.5s fill with orange = 1s total
|
|
5427
6878
|
state.startAnim = { start: Date.now() };
|
|
6879
|
+
// Marks the tick the steady pulse begins on, so the pulse can start its
|
|
6880
|
+
// own orange -> yellow sweep instead of inheriting whatever phase the
|
|
6881
|
+
// spinner happens to be in when the start animation ends.
|
|
6882
|
+
state.pulseStart = null;
|
|
5428
6883
|
}
|
|
5429
6884
|
|
|
5430
6885
|
if (state.history[state.history.length - 1] !== text) state.history.push(text);
|
|
@@ -5452,8 +6907,14 @@ export async function startTUI(opts) {
|
|
|
5452
6907
|
await runAgent(text);
|
|
5453
6908
|
}
|
|
5454
6909
|
|
|
5455
|
-
|
|
5456
|
-
|
|
6910
|
+
// `asSystem` injects the text as a SYSTEM message instead of a user message and
|
|
6911
|
+
// skips the user bubble. Used by the approved-plan handoff: the plan is not
|
|
6912
|
+
// something the user typed, so it must not appear (or persist) as their message.
|
|
6913
|
+
async function runAgent(text, opts = {}) {
|
|
6914
|
+
const asSystem = !!opts.asSystem;
|
|
6915
|
+
if (!asSystem) addChat({ role: 'user', text });
|
|
6916
|
+
// Fresh buffer for this turn's <plan> split (see appendAssistant).
|
|
6917
|
+
state._planBuf = '';
|
|
5457
6918
|
// A message the user JUST SENT should always bring the newest content into
|
|
5458
6919
|
// view, even if they were scrolled up reading history. (Queue/steer do NOT
|
|
5459
6920
|
// come through here, so they keep the view exactly where the user left it.)
|
|
@@ -5483,6 +6944,28 @@ export async function startTUI(opts) {
|
|
|
5483
6944
|
if (cfg.calmMode) {
|
|
5484
6945
|
sysText += '\n\n' + CALM_MODE_INSTRUCTION;
|
|
5485
6946
|
}
|
|
6947
|
+
// PLAN MODE (/plan): tell the model what to PRODUCE. The tool filter alone
|
|
6948
|
+
// only removed its write tools — this is what makes it emit a <plan> block.
|
|
6949
|
+
if (state.plan) {
|
|
6950
|
+
sysText += '\n\n' + PLAN_MODE_INSTRUCTION;
|
|
6951
|
+
}
|
|
6952
|
+
// SWARM MODE (/swarm): push the model toward parallel decomposition.
|
|
6953
|
+
if (state.swarm) {
|
|
6954
|
+
sysText += '\n\n' + SWARM_MODE_INSTRUCTION;
|
|
6955
|
+
}
|
|
6956
|
+
const personal = readPersonalPrompt(state.workspace || cfg.workspace);
|
|
6957
|
+
if (personal) {
|
|
6958
|
+
sysText += '\n\n' + personal;
|
|
6959
|
+
}
|
|
6960
|
+
// AGENTS.md: the PROJECT's own instructions, read fresh each turn like the
|
|
6961
|
+
// personal notes above, so an edit (or a fresh /init) takes effect on the next
|
|
6962
|
+
// prompt. Injected LAST and deepest-last so a nested file outranks the root one,
|
|
6963
|
+
// which is the precedence order codex documents. Previously /init wrote this
|
|
6964
|
+
// file and nothing ever loaded it.
|
|
6965
|
+
const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state.workspace || cfg.workspace);
|
|
6966
|
+
if (agents) {
|
|
6967
|
+
sysText += '\n\n' + agents;
|
|
6968
|
+
}
|
|
5486
6969
|
let messages = [{ role: 'system', content: sysText }];
|
|
5487
6970
|
if (session.messages && session.messages.length) {
|
|
5488
6971
|
for (const m of session.messages) {
|
|
@@ -5496,7 +6979,10 @@ export async function startTUI(opts) {
|
|
|
5496
6979
|
messages.push(m);
|
|
5497
6980
|
}
|
|
5498
6981
|
}
|
|
5499
|
-
|
|
6982
|
+
// The approved-plan handoff arrives as a SYSTEM message: it is an instruction
|
|
6983
|
+
// from the harness, not something the user typed, and it must not show up as a
|
|
6984
|
+
// user bubble or be persisted as one.
|
|
6985
|
+
messages.push(asSystem ? { role: 'system', content: text } : { role: 'user', content: text });
|
|
5500
6986
|
// 只持久化对话本身,system 不进 session。
|
|
5501
6987
|
Object.assign(session, { model: cfg.model, messages: messages.filter((m) => m.role !== 'system') });
|
|
5502
6988
|
sess.saveSession(session);
|
|
@@ -5509,24 +6995,61 @@ export async function startTUI(opts) {
|
|
|
5509
6995
|
cfg.toolFilter = undefined;
|
|
5510
6996
|
}
|
|
5511
6997
|
|
|
5512
|
-
|
|
5513
|
-
|
|
6998
|
+
// AUTO ("Never Ask") implies unrestricted paths: the mode already auto-approves
|
|
6999
|
+
// every tool, so ALSO lifting the tool-layer workspace check keeps behaviour
|
|
7000
|
+
// consistent. Without this the approval callback said "yes" while
|
|
7001
|
+
// resolvePath() still threw `Path outside workspace` — the mode looked broken.
|
|
7002
|
+
//
|
|
7003
|
+
// YOLO gets the same treatment for BASH only: its approval no longer runs the
|
|
7004
|
+
// workspace test (see onApproval), so the tool layer must not re-impose it.
|
|
7005
|
+
// Other tools keep the check, because for them "inside the workspace" is still
|
|
7006
|
+
// what decides approve-vs-ask.
|
|
7007
|
+
const mode = state.mode || 'ask';
|
|
7008
|
+
cfg.allowExternal = (mode === 'auto' || mode === 'yolo') ? true : cfg.allowExternal;
|
|
7009
|
+
// AskUserQuestion reads this to refuse in auto mode. Set here so the FIRST
|
|
7010
|
+
// tool call of the turn already sees it (onApproval re-syncs it live after).
|
|
7011
|
+
cfg.permissionMode = mode;
|
|
7012
|
+
|
|
5514
7013
|
// Approval callback: called before every tool execution
|
|
5515
7014
|
const onApproval = async (toolName, args) => {
|
|
5516
7015
|
// Read the mode LIVE: /permission, /yolo and /auto can change while the
|
|
5517
7016
|
// turn runs, and the decision must follow the CURRENT setting.
|
|
5518
7017
|
const cur = state.mode || 'ask';
|
|
7018
|
+
// Keep the TOOL LAYER in step with the LIVE mode. Agent snapshots cfg into
|
|
7019
|
+
// its own ctx at construction, so mutating cfg alone would not reach a turn
|
|
7020
|
+
// that is already running. Two things depend on this:
|
|
7021
|
+
// * allowExternal — resolvePath()'s workspace guard
|
|
7022
|
+
// * permissionMode — AskUserQuestion refuses to ask while auto is on
|
|
7023
|
+
if (state.agent && state.agent.ctx) {
|
|
7024
|
+
state.agent.ctx.allowExternal = (cur === 'auto') || !!cfg.allowExternal;
|
|
7025
|
+
state.agent.ctx.permissionMode = cur;
|
|
7026
|
+
}
|
|
5519
7027
|
if (cur === 'auto') return true;
|
|
5520
|
-
|
|
5521
|
-
//
|
|
5522
|
-
|
|
7028
|
+
// Read-only tools never need permission in any mode. AskUserQuestion is on
|
|
7029
|
+
// this list because ASKING the user is not a side effect — gating it behind
|
|
7030
|
+
// an approval prompt would mean answering two prompts to ask one question.
|
|
7031
|
+
// (In auto mode the TOOL refuses itself before any UI; see ctx.permissionMode.)
|
|
7032
|
+
const safeTools = ['Read', 'Grep', 'Glob', 'FetchURL', 'WebSearch', 'TaskOutput', 'TaskList', 'TaskStop', 'TaskWait', 'TodoList', 'FileLines', 'AskUserQuestion'];
|
|
5523
7033
|
if (safeTools.includes(toolName)) return true;
|
|
5524
7034
|
|
|
5525
7035
|
// YOLO ("Ask When Needed"): anything that stays INSIDE the workspace runs
|
|
5526
7036
|
// without asking — edits, writes and commands alike. Only work that touches
|
|
5527
7037
|
// a path OUTSIDE the workspace (or a command whose target we cannot prove is
|
|
5528
7038
|
// inside) needs the user.
|
|
5529
|
-
|
|
7039
|
+
//
|
|
7040
|
+
// BASH is the exception: a shell command's paths cannot be proven either way
|
|
7041
|
+
// (variables, pipes, subshells), so the old path heuristic flagged ordinary
|
|
7042
|
+
// commands like `ls /tmp` and asked about them. In yolo the user has already
|
|
7043
|
+
// said "run routine work", so Bash runs without the workspace test — only an
|
|
7044
|
+
// obviously destructive command still asks.
|
|
7045
|
+
if (cur === 'yolo') {
|
|
7046
|
+
if (toolName === 'Bash') {
|
|
7047
|
+
const cmd = String((args && (args.command || args.cmd)) || '');
|
|
7048
|
+
if (!isDestructiveCommand(cmd)) return true;
|
|
7049
|
+
} else if (isInsideWorkspace(state, toolName, args)) {
|
|
7050
|
+
return true;
|
|
7051
|
+
}
|
|
7052
|
+
}
|
|
5530
7053
|
|
|
5531
7054
|
return new Promise((resolve) => {
|
|
5532
7055
|
let desc = '';
|
|
@@ -5554,6 +7077,40 @@ export async function startTUI(opts) {
|
|
|
5554
7077
|
// Seed the agent with the persisted list: Agent() otherwise starts from an
|
|
5555
7078
|
// empty todoState, which wiped the panel on every new turn.
|
|
5556
7079
|
cfg.todoState = state.todos || [];
|
|
7080
|
+
// AskUserQuestion bridge: the tool calls ctx.askQuestion(questions, ctx) and
|
|
7081
|
+
// awaits the answers. Same promise-handshake the approval prompt uses, so the
|
|
7082
|
+
// agent simply blocks while the user reads and chooses.
|
|
7083
|
+
cfg.askQuestion = (questions, ctx) => new Promise((resolve) => {
|
|
7084
|
+
state.question = {
|
|
7085
|
+
items: questions,
|
|
7086
|
+
index: 0,
|
|
7087
|
+
sel: 0,
|
|
7088
|
+
picked: new Set(),
|
|
7089
|
+
answers: {},
|
|
7090
|
+
// Free-text state. `editing` is null | 'other' | 'supplement' and says
|
|
7091
|
+
// WHICH row is open; `editingText` is its live buffer. The committed
|
|
7092
|
+
// values live in `otherText` / `supplement` so an Esc back-out keeps them.
|
|
7093
|
+
editing: null,
|
|
7094
|
+
editingText: '',
|
|
7095
|
+
otherText: '',
|
|
7096
|
+
supplement: '',
|
|
7097
|
+
resolve: (answers) => resolve(answers),
|
|
7098
|
+
};
|
|
7099
|
+
// An aborted turn must not leave the dialog stranded on screen.
|
|
7100
|
+
const sig = ctx && ctx.signal;
|
|
7101
|
+
const onAbort = () => {
|
|
7102
|
+
if (state.question && state.question.resolve === resolve) {
|
|
7103
|
+
state.question = null;
|
|
7104
|
+
resolve({});
|
|
7105
|
+
renderFrame();
|
|
7106
|
+
}
|
|
7107
|
+
};
|
|
7108
|
+
if (sig) {
|
|
7109
|
+
if (sig.aborted) { onAbort(); return; }
|
|
7110
|
+
sig.addEventListener('abort', onAbort, { once: true });
|
|
7111
|
+
}
|
|
7112
|
+
renderFrame();
|
|
7113
|
+
});
|
|
5557
7114
|
const agent = new Agent({
|
|
5558
7115
|
// maxSteps omitted: the agent loop is uncapped (see agent.js).
|
|
5559
7116
|
cfg, messages, onApproval,
|
|
@@ -5629,7 +7186,7 @@ export async function startTUI(opts) {
|
|
|
5629
7186
|
if (e.type === 'tool_start') {
|
|
5630
7187
|
const dup = state.chat.some((m) => m.role === 'tool' && m.pending && m.id === e.id);
|
|
5631
7188
|
if (!dup) {
|
|
5632
|
-
state.chat.push({ role: 'tool', toolName: e.name, toolArgs: {}, pending: true, id: e.id, streamContent: '' });
|
|
7189
|
+
state.chat.push({ role: 'tool', toolName: e.name, toolArgs: {}, pending: true, id: e.id, streamContent: '', startAt: Date.now() });
|
|
5633
7190
|
}
|
|
5634
7191
|
} else if (e.type === 'tool_args') {
|
|
5635
7192
|
const entry = [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending && m.id === e.id);
|
|
@@ -5648,6 +7205,15 @@ export async function startTUI(opts) {
|
|
|
5648
7205
|
|| [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending);
|
|
5649
7206
|
if (entry) {
|
|
5650
7207
|
entry.liveOutput = (entry.liveOutput || '') + e.chunk;
|
|
7208
|
+
// A swarm streams `[n/m] finished` lines as subagents land: advance the
|
|
7209
|
+
// matching cells from queued -> completed so the grid fills in live.
|
|
7210
|
+
if (entry.toolName === 'AgentSwarm' && Array.isArray(entry.swarmMembers)) {
|
|
7211
|
+
const done = (String(entry.liveOutput).match(/^\[\d+\/\d+\] finished$/gm) || []).length;
|
|
7212
|
+
entry.swarmMembers.forEach((mem, i) => {
|
|
7213
|
+
if (i < done) { mem.phase = 'completed'; mem.ratio = 1; }
|
|
7214
|
+
else if (i === done) { mem.phase = 'working'; mem.ratio = 0.5; }
|
|
7215
|
+
});
|
|
7216
|
+
}
|
|
5651
7217
|
renderSoon();
|
|
5652
7218
|
}
|
|
5653
7219
|
return;
|
|
@@ -5656,6 +7222,22 @@ export async function startTUI(opts) {
|
|
|
5656
7222
|
|| [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending && m.toolName === e.name);
|
|
5657
7223
|
if (entry) {
|
|
5658
7224
|
entry.toolArgs = { ...(entry.toolArgs || {}), ...(e.args || {}) };
|
|
7225
|
+
// AgentSwarm: turn the args into a live member list so the progress
|
|
7226
|
+
// block renders from the moment the swarm is announced. Every item
|
|
7227
|
+
// starts "queued" and flips as the tool reports progress.
|
|
7228
|
+
if (e.name === 'AgentSwarm') {
|
|
7229
|
+
const a = entry.toolArgs || {};
|
|
7230
|
+
entry.swarmDescription = a.description || '';
|
|
7231
|
+
entry.swarmModel = a.subagent_type || '';
|
|
7232
|
+
if (!entry.swarmMembers && Array.isArray(a.items)) {
|
|
7233
|
+
entry.swarmMembers = a.items.map(() => ({ phase: 'queued', ratio: 0, latestText: '' }));
|
|
7234
|
+
}
|
|
7235
|
+
}
|
|
7236
|
+
// Edit: build the +/- diff. The tool now reports the text it actually
|
|
7237
|
+
// replaced on the tool_result (`editDiff`), which is the only way a
|
|
7238
|
+
// LINE-RANGE edit (start_line/end_line/new_content) can produce a
|
|
7239
|
+
// diff — it has no `old_string` in its args. Args still seed a
|
|
7240
|
+
// provisional diff so the rows appear while the edit is still running.
|
|
5659
7241
|
if (e.name === 'Edit' && e.args && typeof e.args.old_string === 'string') {
|
|
5660
7242
|
let startLine = 1;
|
|
5661
7243
|
try {
|
|
@@ -5676,16 +7258,52 @@ export async function startTUI(opts) {
|
|
|
5676
7258
|
// A failed run must be visible on the TOOL line too: the status bullet
|
|
5677
7259
|
// turns red (see messageLines).
|
|
5678
7260
|
const failedRun = isFailureResult(e.content, e.name);
|
|
5679
|
-
if (entry) {
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
//
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
7261
|
+
if (entry) {
|
|
7262
|
+
entry.pending = false; entry.id = undefined; entry.failed = failedRun;
|
|
7263
|
+
// The AUTHORITATIVE diff comes from the tool itself (`editDiff`): it
|
|
7264
|
+
// knows the text it really replaced, so this is the only source that
|
|
7265
|
+
// covers line-range edits. Fall back to the provisional diff built
|
|
7266
|
+
// from the args for older/other callers.
|
|
7267
|
+
if (e.editDiff && typeof e.editDiff.old === 'string') {
|
|
7268
|
+
entry.diff = lineDiff(e.editDiff.old, e.editDiff.new, e.editDiff.startLine || 1);
|
|
7269
|
+
}
|
|
7270
|
+
// Hand the counts to the TOOL row (which keeps `+N -N` next to its
|
|
7271
|
+
// name) and the diff to the RESULT row (rendered beneath the `↳`).
|
|
7272
|
+
if (entry.diff) entry._diffCounts = entry.diff;
|
|
7273
|
+
// A finished swarm has no `↳` dump: the progress block IS the result.
|
|
7274
|
+
// Settle every member so the grid and the pip bar read as final.
|
|
7275
|
+
if (entry.toolName === 'AgentSwarm' && Array.isArray(entry.swarmMembers)) {
|
|
7276
|
+
entry.swarmFailed = failedRun;
|
|
7277
|
+
entry.swarmMembers.forEach((mem) => {
|
|
7278
|
+
if (mem.phase === 'queued' || mem.phase === 'working' || mem.phase === 'prompting') {
|
|
7279
|
+
mem.phase = failedRun ? 'failed' : 'completed';
|
|
7280
|
+
}
|
|
7281
|
+
mem.ratio = mem.phase === 'completed' ? 1 : mem.ratio;
|
|
7282
|
+
});
|
|
7283
|
+
}
|
|
7284
|
+
}
|
|
7285
|
+
// An AgentSwarm is the ONE exception: it has already rendered its own
|
|
7286
|
+
// result as the live progress block above (cells + status pip bar), so a
|
|
7287
|
+
// `↳ Swarm finished: …` dump underneath would print the same thing twice.
|
|
7288
|
+
const isSwarm = e.name === 'AgentSwarm' && entry && Array.isArray(entry.swarmMembers);
|
|
7289
|
+
const resultMsg = normalizeMsg({
|
|
7290
|
+
role: 'tool_result', text: e.content, failed: failedRun,
|
|
7291
|
+
diff: e.name === 'Write' ? (entry && entry.diff || []) : (entry && entry.diff),
|
|
7292
|
+
});
|
|
7293
|
+
if (entry && !isSwarm) {
|
|
7294
|
+
const at = state.chat.indexOf(entry);
|
|
7295
|
+
// Skip any rows already sitting between the call and the transcript
|
|
7296
|
+
// tail (the result rows of EARLIER calls in this same step), so each
|
|
7297
|
+
// block reads call -> its result -> call -> its result.
|
|
7298
|
+
let ins = at + 1;
|
|
7299
|
+
while (ins < state.chat.length && state.chat[ins].role === 'tool_result') ins++;
|
|
7300
|
+
state.chat.splice(ins, 0, resultMsg);
|
|
7301
|
+
renderFrame();
|
|
7302
|
+
if ((state.scroll || 0) === 0) state.scroll = 0;
|
|
7303
|
+
} else if (!isSwarm) {
|
|
7304
|
+
addChat(resultMsg);
|
|
7305
|
+
} else {
|
|
7306
|
+
renderFrame();
|
|
5689
7307
|
}
|
|
5690
7308
|
// NOTE: queued input is deliberately NOT drained into the running turn
|
|
5691
7309
|
// here. A message typed while the agent works is a NEW turn, so it waits
|
|
@@ -5698,12 +7316,66 @@ export async function startTUI(opts) {
|
|
|
5698
7316
|
});
|
|
5699
7317
|
state.agent = agent;
|
|
5700
7318
|
if (agent.ctx && agent.ctx.tasks) state.tasks = agent.ctx.tasks;
|
|
7319
|
+
// A background subagent (Agent {run_in_background:true}, or an AgentSwarm)
|
|
7320
|
+
// finishes AFTER the turn that launched it returned. The tools call
|
|
7321
|
+
// `ctx._onBackgroundTaskDone`, which nothing installed, so the parent was
|
|
7322
|
+
// never told: its result sat in the task store until the user opened /tasks.
|
|
7323
|
+
// Post it into the transcript when it lands (mirroring how a queued message
|
|
7324
|
+
// arrives after the turn), so the parent model sees it on its next turn.
|
|
7325
|
+
if (agent.ctx) {
|
|
7326
|
+
agent.ctx._onBackgroundTaskDone = (task) => {
|
|
7327
|
+
if (!task) return;
|
|
7328
|
+
const status = task.status || 'completed';
|
|
7329
|
+
const head = status === 'completed'
|
|
7330
|
+
? `[background agent finished: ${task.taskId}]`
|
|
7331
|
+
: `[background agent ${status}: ${task.taskId}${task.stopReason ? ' - ' + task.stopReason : ''}]`;
|
|
7332
|
+
const body = String(task.output || '').trim();
|
|
7333
|
+
addChat({ role: 'system', text: body ? `${head}\n${body}` : head });
|
|
7334
|
+
};
|
|
7335
|
+
}
|
|
5701
7336
|
await agent.run();
|
|
5702
7337
|
messages = agent.messages;
|
|
5703
7338
|
if (agent.ctx && agent.ctx.tasks) state.tasks = agent.ctx.tasks;
|
|
5704
7339
|
state.agent = null;
|
|
5705
7340
|
const liveThink = [...state.chat].reverse().find((m) => m.role === 'thinking' && m.pending);
|
|
5706
7341
|
if (liveThink) liveThink.pending = false;
|
|
7342
|
+
|
|
7343
|
+
// ---- PLAN MODE: finalize the plan and ask to proceed --------------------
|
|
7344
|
+
// The plan bubble is ALREADY on screen (appendAssistant streams the text after
|
|
7345
|
+
// `<plan>` straight into it), so this only: closes the tags out of the prose,
|
|
7346
|
+
// asks for approval, and clears the streaming buffer.
|
|
7347
|
+
let approvedPlan = null;
|
|
7348
|
+
if (state.plan) {
|
|
7349
|
+
const lastAssistant = [...state.chat].reverse().find((m) => m.role === 'assistant' && (m.text || '').trim());
|
|
7350
|
+
// Prefer what arrived on the wire; fall back to scanning the prose for the
|
|
7351
|
+
// case where the tags were split across bubbles.
|
|
7352
|
+
const streamedPlan = [...state.chat].reverse().find((m) => m.role === 'plan');
|
|
7353
|
+
const planText = (streamedPlan && String(streamedPlan.text).trim())
|
|
7354
|
+
|| extractPlan(lastAssistant ? lastAssistant.text : '');
|
|
7355
|
+
if (planText) {
|
|
7356
|
+
// Make sure the bubble holds the final, complete text, and drop any
|
|
7357
|
+
// leftover tag remnants from the prose.
|
|
7358
|
+
if (streamedPlan) streamedPlan.text = planText;
|
|
7359
|
+
else addChat({ role: 'plan', text: planText });
|
|
7360
|
+
if (lastAssistant) {
|
|
7361
|
+
lastAssistant.text = String(lastAssistant.text).replace(/<\/?plan>/gi, '').trim();
|
|
7362
|
+
if (!lastAssistant.text.trim()) {
|
|
7363
|
+
const i = state.chat.indexOf(lastAssistant);
|
|
7364
|
+
if (i >= 0) state.chat.splice(i, 1);
|
|
7365
|
+
}
|
|
7366
|
+
}
|
|
7367
|
+
// ask / yolo -> the user decides; auto -> approved without asking, which
|
|
7368
|
+
// is the whole point of "Never Ask".
|
|
7369
|
+
const cur = state.mode || 'ask';
|
|
7370
|
+
const ok = cur === 'auto' ? true : await new Promise((resolve) => {
|
|
7371
|
+
state.planPending = { plan: planText, resolve };
|
|
7372
|
+
renderFrame();
|
|
7373
|
+
});
|
|
7374
|
+
state.planPending = null;
|
|
7375
|
+
if (ok) approvedPlan = planText;
|
|
7376
|
+
}
|
|
7377
|
+
}
|
|
7378
|
+
|
|
5707
7379
|
const approx = estimateMessagesTokens(messages, cfg);
|
|
5708
7380
|
state.ctxTokens = approx;
|
|
5709
7381
|
state.ctxMax = cfg.maxContextTokens || state.ctxMax;
|
|
@@ -5719,6 +7391,7 @@ export async function startTUI(opts) {
|
|
|
5719
7391
|
session.plan = !!state.plan;
|
|
5720
7392
|
session.planPath = state.planPath || null;
|
|
5721
7393
|
session.focus = !!state.focus;
|
|
7394
|
+
session.swarm = !!state.swarm;
|
|
5722
7395
|
sess.saveSession(session);
|
|
5723
7396
|
const turnMs = state.turnStart ? Date.now() - state.turnStart : 0;
|
|
5724
7397
|
const dur = fmtDuration(turnMs);
|
|
@@ -5758,6 +7431,19 @@ export async function startTUI(opts) {
|
|
|
5758
7431
|
const next = state.queued.shift();
|
|
5759
7432
|
if (next) { void submit(next); }
|
|
5760
7433
|
}
|
|
7434
|
+
// Approved plan: Plan mode OFF (write tools come back), then re-enter the agent
|
|
7435
|
+
// with the plan as a SYSTEM message. Deliberately NOT via submit(): that path
|
|
7436
|
+
// posts a user bubble and re-runs the whole submit flow, which is what produced
|
|
7437
|
+
// a stray user message and an extra turn teardown before the work started.
|
|
7438
|
+
// Done LAST so it cannot race the queue drain above.
|
|
7439
|
+
if (approvedPlan) {
|
|
7440
|
+
state.plan = false;
|
|
7441
|
+
session.plan = false;
|
|
7442
|
+
persistState();
|
|
7443
|
+
addChat({ role: 'system', text: 'Plan approved — Plan mode is off, executing.' });
|
|
7444
|
+
renderFrame();
|
|
7445
|
+
void runAgent(PLAN_EXECUTE_PREFIX, { asSystem: true });
|
|
7446
|
+
}
|
|
5761
7447
|
}
|
|
5762
7448
|
|
|
5763
7449
|
// Remember the session's working mode (permission / plan / focus / effort)
|
|
@@ -5769,6 +7455,7 @@ export async function startTUI(opts) {
|
|
|
5769
7455
|
session.plan = !!state.plan;
|
|
5770
7456
|
session.planPath = state.planPath || null;
|
|
5771
7457
|
session.focus = !!state.focus;
|
|
7458
|
+
session.swarm = !!state.swarm;
|
|
5772
7459
|
session.effort = state.effort || '';
|
|
5773
7460
|
// Theme removed - forced dark only
|
|
5774
7461
|
session.steps = state.steps || 0;
|
|
@@ -5778,7 +7465,7 @@ export async function startTUI(opts) {
|
|
|
5778
7465
|
}
|
|
5779
7466
|
|
|
5780
7467
|
const host = {
|
|
5781
|
-
addChat, openPicker, openForm, notice, openPanel, openEditor,
|
|
7468
|
+
addChat, openPicker, openForm, notice, openPanel, openEditor, openTasksPanel,
|
|
5782
7469
|
sendPrompt: (text) => { void runAgent(text); },
|
|
5783
7470
|
quit: () => { state._quit = true; },
|
|
5784
7471
|
saveSession: (s) => sess.saveSession(s),
|
|
@@ -5802,7 +7489,78 @@ export async function startTUI(opts) {
|
|
|
5802
7489
|
state._anchorRows = rows;
|
|
5803
7490
|
}
|
|
5804
7491
|
|
|
7492
|
+
// Live split of the streamed reply into prose vs. <plan> block.
|
|
7493
|
+
// While Plan mode is on and the model is inside <plan>…</plan>, the text is
|
|
7494
|
+
// routed into a `plan` bubble so it renders in its frame AS IT ARRIVES — the old
|
|
7495
|
+
// behaviour buffered the whole reply and only showed the plan at turn end, so
|
|
7496
|
+
// the user watched prose and then the plan popped in.
|
|
5805
7497
|
function appendAssistant(text) {
|
|
7498
|
+
if (state.plan && typeof text === 'string' && text) {
|
|
7499
|
+
state._planBuf = (state._planBuf || '') + text;
|
|
7500
|
+
const buf = state._planBuf;
|
|
7501
|
+
const open = buf.toLowerCase().indexOf('<plan>');
|
|
7502
|
+
if (open === -1) {
|
|
7503
|
+
// Still in the prose part (or the tag has not finished arriving).
|
|
7504
|
+
const last = state.chat[state.chat.length - 1];
|
|
7505
|
+
if (last && last.role === 'assistant') last.text += text;
|
|
7506
|
+
else state.chat.push({ role: 'assistant', text });
|
|
7507
|
+
anchorScroll();
|
|
7508
|
+
return;
|
|
7509
|
+
}
|
|
7510
|
+
// Everything before the tag is prose; everything after it is the plan body.
|
|
7511
|
+
const prose = buf.slice(0, open);
|
|
7512
|
+
const body = buf.slice(open + 6);
|
|
7513
|
+
const close = body.toLowerCase().indexOf('</plan>');
|
|
7514
|
+
const planSoFar = close === -1 ? body : body.slice(0, close);
|
|
7515
|
+
setStreamedProse(prose);
|
|
7516
|
+
setStreamedPlan(planSoFar);
|
|
7517
|
+
anchorScroll();
|
|
7518
|
+
return;
|
|
7519
|
+
}
|
|
7520
|
+
appendAssistantRaw(text);
|
|
7521
|
+
}
|
|
7522
|
+
|
|
7523
|
+
// Replace the trailing assistant bubble with exactly `prose`.
|
|
7524
|
+
// Order is FIXED: prose bubbles are BEFORE the plan bubble (the plan is last).
|
|
7525
|
+
// Pushing an assistant AFTER the plan put `plan` mid-list on the next update, so
|
|
7526
|
+
// setStreamedPlan saw a non-plan tail, pushed ANOTHER plan, and repeated — every
|
|
7527
|
+
// stream chunk created a new plan bubble (the "1, 12, 123, 1234" spam the user
|
|
7528
|
+
// reported). Find the prose bubble that PRECEDES the plan and update it.
|
|
7529
|
+
function setStreamedProse(prose) {
|
|
7530
|
+
const trimmed = String(prose).replace(/^\s*\n/, '');
|
|
7531
|
+
// Find the FIRST plan bubble; prose must be updated BEFORE it, never after.
|
|
7532
|
+
let planIdx = -1;
|
|
7533
|
+
for (let i = 0; i < state.chat.length; i++) {
|
|
7534
|
+
if (state.chat[i].role === 'plan') { planIdx = i; break; }
|
|
7535
|
+
}
|
|
7536
|
+
// Look for the assistant bubble right BEFORE the plan; with no plan, take the
|
|
7537
|
+
// LAST assistant. This loop indexes UP TO planIdx so it can never miss one —
|
|
7538
|
+
// the old code broke at the first plan it hit from the tail, so it never saw
|
|
7539
|
+
// the assistant in front of it and duplicated the prose on every chunk.
|
|
7540
|
+
const bound = planIdx >= 0 ? planIdx : state.chat.length;
|
|
7541
|
+
let proseIdx = -1;
|
|
7542
|
+
for (let i = bound - 1; i >= 0; i--) {
|
|
7543
|
+
if (state.chat[i].role === 'assistant') { proseIdx = i; break; }
|
|
7544
|
+
}
|
|
7545
|
+
if (proseIdx >= 0) {
|
|
7546
|
+
state.chat[proseIdx].text = trimmed;
|
|
7547
|
+
if (!trimmed.trim()) state.chat.splice(proseIdx, 1); // nothing left: drop it
|
|
7548
|
+
} else if (trimmed.trim()) {
|
|
7549
|
+
state.chat.unshift({ role: 'assistant', text: trimmed });
|
|
7550
|
+
}
|
|
7551
|
+
}
|
|
7552
|
+
|
|
7553
|
+
// Create/update the live plan bubble, always at the END of the transcript.
|
|
7554
|
+
function setStreamedPlan(planSoFar) {
|
|
7555
|
+
let last = state.chat[state.chat.length - 1];
|
|
7556
|
+
if (last && last.role === 'plan') {
|
|
7557
|
+
last.text = planSoFar;
|
|
7558
|
+
} else {
|
|
7559
|
+
state.chat.push({ role: 'plan', text: planSoFar });
|
|
7560
|
+
}
|
|
7561
|
+
}
|
|
7562
|
+
|
|
7563
|
+
function appendAssistantRaw(text) {
|
|
5806
7564
|
const last = state.chat[state.chat.length - 1];
|
|
5807
7565
|
if (last && last.role === 'assistant') {
|
|
5808
7566
|
last.text += text;
|
|
@@ -5844,11 +7602,15 @@ export async function startTUI(opts) {
|
|
|
5844
7602
|
function normalizeMsg(m) {
|
|
5845
7603
|
// Keep `failed`: a failed tool result must render in RED. Dropping it here
|
|
5846
7604
|
// made every failure look like ordinary gray output.
|
|
5847
|
-
|
|
7605
|
+
// Keep `diff`: an Edit's diff travels ON the tool_result message so it renders
|
|
7606
|
+
// below the `↳` receipt (see messageLines). Dropping it removed the diff.
|
|
7607
|
+
const out = {
|
|
5848
7608
|
role: m && m.role ? m.role : 'system',
|
|
5849
7609
|
text: m && m.text != null ? String(m.text) : '',
|
|
5850
7610
|
failed: !!(m && m.failed),
|
|
5851
7611
|
};
|
|
7612
|
+
if (m && Array.isArray(m.diff)) out.diff = m.diff;
|
|
7613
|
+
return out;
|
|
5852
7614
|
}
|
|
5853
7615
|
|
|
5854
7616
|
let stopped = false;
|
|
@@ -5861,6 +7623,9 @@ export async function startTUI(opts) {
|
|
|
5861
7623
|
if (spinTimer) clearInterval(spinTimer);
|
|
5862
7624
|
if (tokTimer) clearInterval(tokTimer);
|
|
5863
7625
|
if (confirmTimer) clearTimeout(confirmTimer);
|
|
7626
|
+
// The /tasks panel owns a flash timer; leaving it armed keeps the process
|
|
7627
|
+
// alive until it fires after exit.
|
|
7628
|
+
if (state.tasksPanel && state.tasksPanel.flashTimer) clearTimeout(state.tasksPanel.flashTimer);
|
|
5864
7629
|
try { sess.saveSession(session); } catch {}
|
|
5865
7630
|
stdout.write('\x1b[<u\x1b[?2004l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l');
|
|
5866
7631
|
stdout.write(alternateScreen(false));
|
|
@@ -5888,4 +7653,4 @@ export async function startTUI(opts) {
|
|
|
5888
7653
|
// as soon as the TUI is wired up; without a pending promise, main()'s await
|
|
5889
7654
|
// resolves immediately and Node exits (the "flash of UI then quit" bug).
|
|
5890
7655
|
return new Promise(() => {});
|
|
5891
|
-
}
|
|
7656
|
+
}
|