@hncode/hncode 0.2.0 → 0.3.1
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 +1 -0
- package/package.json +1 -1
- package/src/agent-task.js +178 -0
- package/src/agent.js +101 -8
- package/src/colors.js +14 -0
- package/src/config.js +191 -10
- package/src/index.js +31 -9
- package/src/llm.js +60 -11
- package/src/plugin.js +25 -2
- package/src/prompt-presets.js +429 -0
- package/src/session.js +257 -24
- 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 +208 -0
- package/src/tools/agent.js +342 -0
- package/src/tools/ask-user-question.js +206 -0
- package/src/tools/bash.js +53 -29
- package/src/tools/edit.js +122 -11
- package/src/tools/file-lines.js +41 -8
- package/src/tools/glob.js +17 -5
- package/src/tools/grep.js +16 -3
- package/src/tools/index.js +19 -2
- package/src/tools/matchers.js +75 -71
- package/src/tools/read-media-file.js +17 -3
- package/src/tools/read.js +2 -0
- package/src/tools/tasks.js +141 -126
- package/src/tools/todo.js +29 -2
- package/src/tools/utils.js +11 -1
- package/src/tui.js +2264 -337
- package/src/update.js +95 -0
- package/src/version.js +20 -0
package/src/tui.js
CHANGED
|
@@ -25,11 +25,19 @@ 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';
|
|
37
|
+
import { localVersion } from './version.js';
|
|
30
38
|
|
|
31
39
|
const ESC = '\x1b';
|
|
32
|
-
const VERSION =
|
|
40
|
+
const VERSION = localVersion();
|
|
33
41
|
|
|
34
42
|
// ---- Slash command registry ----
|
|
35
43
|
// Mirrors kimi-code-cli's BUILTIN_SLASH_COMMANDS (registry.ts): name, aliases,
|
|
@@ -49,10 +57,12 @@ export const COMMANDS = [
|
|
|
49
57
|
{ name: 'help', aliases: ['h', '?'], desc: 'Show available commands and shortcuts', priority: 80 },
|
|
50
58
|
{ name: 'new', aliases: ['clear'], desc: 'Start a fresh session in the current workspace', priority: 80 },
|
|
51
59
|
{ name: 'sessions', aliases: ['resume'], desc: 'Browse and resume sessions', priority: 80 },
|
|
52
|
-
{ name: 'tasks', aliases: ['task'], desc: 'Browse background tasks', priority: 80 },
|
|
60
|
+
{ name: 'tasks', aliases: ['task'], desc: 'Browse background tasks (full-screen panel)', priority: 80 },
|
|
61
|
+
{ name: 'swarm', desc: 'Toggle swarm mode: decompose work across parallel subagents', priority: 80, argumentHint: '[on|off]' },
|
|
62
|
+
{ 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
63
|
{ name: 'compact', desc: 'Compact the conversation context (AI summary + keep last 20%)', priority: 80, argumentHint: '[ratio]' },
|
|
54
64
|
{ name: 'goal', aliases: ['objective'], desc: 'Start or manage an autonomous goal', priority: 80, argumentHint: '[status|pause|resume|cancel] | <objective>' },
|
|
55
|
-
{ name: 'init', desc: '
|
|
65
|
+
{ name: 'init', desc: 'Generate or update AGENTS.md from the codebase', priority: 70, argumentHint: '[instructions]' },
|
|
56
66
|
{ name: 'fork', desc: 'Fork the current session into a copy without switching to it', priority: 80 },
|
|
57
67
|
{ name: 'undo', desc: 'Withdraw the last prompt from the transcript', priority: 80, argumentHint: '[count]' },
|
|
58
68
|
{ name: 'title', aliases: ['rename'], desc: 'Set or show session title (also sets window title)', priority: 60, argumentHint: '<title>' },
|
|
@@ -65,7 +75,8 @@ export const COMMANDS = [
|
|
|
65
75
|
{ name: 'export-md', aliases: ['export'], desc: 'Export current session as a Markdown file', priority: 40, argumentHint: '[output-path]' },
|
|
66
76
|
{ 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
77
|
{ name: 'copy', desc: 'Copy the last assistant message to the clipboard', priority: 40 },
|
|
68
|
-
{ name: 'set-system-prompt', aliases: ['system-prompt'], desc: 'Edit
|
|
78
|
+
{ name: 'set-system-prompt', aliases: ['system-prompt'], desc: 'Edit the system prompt, or load one of the model-family presets', priority: 60 },
|
|
79
|
+
{ name: 'personal', aliases: ['preferences'], desc: 'Edit personal preferences injected into every prompt (global or per-project)', priority: 60, argumentHint: '[global|project]' },
|
|
69
80
|
{ name: 'calm-mode', desc: 'Terse replies: stop the model narrating what it will do and why unless asked', priority: 60, argumentHint: '[on|off]' },
|
|
70
81
|
{ name: 'add-dir', desc: 'Add or list an additional workspace directory', priority: 60, argumentHint: '[list] | <path>' },
|
|
71
82
|
{ name: 'move', desc: 'Move current session to another directory (must exist)', priority: 60, argumentHint: '<path>' },
|
|
@@ -73,6 +84,8 @@ export const COMMANDS = [
|
|
|
73
84
|
{ name: 'plugins', desc: 'List loaded plugins and their status', priority: 60 },
|
|
74
85
|
{ name: 'logout', aliases: ['disconnect'], desc: 'Log out of a configured provider', priority: 40 },
|
|
75
86
|
{ name: 'feedback', aliases: ['bug'], desc: 'Send feedback to the maintainers', priority: 60 },
|
|
87
|
+
{ name: 'update', desc: 'Check for and install a newer version', priority: 40, argumentHint: '[-y]' },
|
|
88
|
+
{ name: 'auto-update', desc: 'Toggle automatic background updates (check at startup + every 30 min)', priority: 40, argumentHint: '[on|off]' },
|
|
76
89
|
{ name: 'version', desc: 'Show version information', priority: 20 },
|
|
77
90
|
{ name: 'exit', aliases: ['quit', 'q'], desc: 'Exit the application', priority: 20 },
|
|
78
91
|
].sort((a, b) => (b.priority || 0) - (a.priority || 0) || a.name.localeCompare(b.name));
|
|
@@ -111,6 +124,71 @@ export const CALM_MODE_INSTRUCTION =
|
|
|
111
124
|
+ '- Explain reasoning only if the user explicitly asks for it, or if a genuine\n'
|
|
112
125
|
+ ' blocker/decision needs their input.';
|
|
113
126
|
|
|
127
|
+
// Appended to the system prompt while PLAN MODE is on (/plan). Without this the
|
|
128
|
+
// mode only removed the write tools, leaving the model with no idea what it was
|
|
129
|
+
// supposed to PRODUCE — it just read files and stopped. The instruction gives it
|
|
130
|
+
// the deliverable and the exact shape it must arrive in.
|
|
131
|
+
export const SWARM_MODE_INSTRUCTION =
|
|
132
|
+
'SWARM MODE is ON. The user expects large parallel decomposition.\n'
|
|
133
|
+
+ '- First do a SMALL amount of exploratory work to decide how to split the task.\n'
|
|
134
|
+
+ '- Then do not handle the main work yourself. Use AgentSwarm with a\n'
|
|
135
|
+
+ ' prompt_template containing the {{item}} placeholder and an items array\n'
|
|
136
|
+
+ ' partitioning the problem, so each subagent gets a distinct part.\n'
|
|
137
|
+
+ '- Give each subagent a distinct scope; avoid duplicating work or assigning\n'
|
|
138
|
+
+ ' conflicting changes.\n'
|
|
139
|
+
+ '- Do not conserve agents: AgentSwarm queues launches automatically. Decompose\n'
|
|
140
|
+
+ ' as finely as the work allows while keeping scopes non-conflicting.\n'
|
|
141
|
+
+ '- This mode IS the explicit permission to delegate: the rule against spawning\n'
|
|
142
|
+
+ ' subagents unprompted still applies everywhere else.\n'
|
|
143
|
+
+ '- If after exploring you conclude no subagent is needed, say why and stop.';
|
|
144
|
+
|
|
145
|
+
export const PLAN_MODE_INSTRUCTION =
|
|
146
|
+
'PLAN MODE is ON. You are in a read-only planning phase.\n'
|
|
147
|
+
+ '- The write tools (Write, Edit, Bash, ...) are NOT available. Investigate with\n'
|
|
148
|
+
+ ' the read-only tools you do have, then produce a PLAN.\n'
|
|
149
|
+
+ '- Do NOT try to make the changes. Your job this turn is the plan, not the code.\n'
|
|
150
|
+
+ '- End your reply with the plan wrapped in <plan> and </plan> tags, on their own\n'
|
|
151
|
+
+ ' lines. Everything between them is shown to the user verbatim and is what they\n'
|
|
152
|
+
+ ' approve. Only the LAST such block is used.\n'
|
|
153
|
+
+ '- Inside the block, use short Markdown: a heading per step, and bullets for the\n'
|
|
154
|
+
+ ' files to touch and why. Be concrete enough that the plan can be executed as\n'
|
|
155
|
+
+ ' written — name files, functions and commands.\n'
|
|
156
|
+
+ '- Keep any text OUTSIDE the tags to a one-line summary of what you found.\n'
|
|
157
|
+
+ '- Once the plan is approved the mode turns off and you get the write tools, so\n'
|
|
158
|
+
+ ' the plan must stand on its own.\n'
|
|
159
|
+
+ '\n'
|
|
160
|
+
+ 'Example shape:\n'
|
|
161
|
+
+ '<plan>\n'
|
|
162
|
+
+ '## Add the /personal command\n'
|
|
163
|
+
+ '- `src/config.js`: add readPersonalPrompt() merging global + project files.\n'
|
|
164
|
+
+ '- `src/tui.js`: register /personal, inject the text each turn.\n'
|
|
165
|
+
+ '## Verify\n'
|
|
166
|
+
+ '- Write a temp workspace, check both scopes merge in order.\n'
|
|
167
|
+
+ '</plan>';
|
|
168
|
+
|
|
169
|
+
// Sent back to the model once the user approves a plan. Plan mode is off by then,
|
|
170
|
+
// so it has the write tools; this frames the approved text as an instruction
|
|
171
|
+
// rather than as another question, and forbids re-planning.
|
|
172
|
+
export const PLAN_EXECUTE_PREFIX =
|
|
173
|
+
'The user approved the plan. Plan mode is OFF — you now have the write tools.\n'
|
|
174
|
+
+ 'Execute the plan you produced. Do not produce another plan and do not re-ask\n'
|
|
175
|
+
+ 'for approval; if a step turns out to be impossible, do the rest and report\n'
|
|
176
|
+
+ 'plainly what you could not do.\n\n';
|
|
177
|
+
|
|
178
|
+
// Pull the LAST <plan>…</plan> block out of an assistant reply. Returns null when
|
|
179
|
+
// there is none, so callers can tell "no plan offered" from "empty plan".
|
|
180
|
+
export function extractPlan(text) {
|
|
181
|
+
const s = String(text || '');
|
|
182
|
+
const open = /<plan>\s*/gi;
|
|
183
|
+
let match = null, m;
|
|
184
|
+
while ((m = open.exec(s)) !== null) match = m;
|
|
185
|
+
if (!match) return null;
|
|
186
|
+
const start = match.index + match[0].length;
|
|
187
|
+
const close = s.indexOf('</plan>', start);
|
|
188
|
+
const body = (close === -1 ? s.slice(start) : s.slice(start, close)).trim();
|
|
189
|
+
return body || null;
|
|
190
|
+
}
|
|
191
|
+
|
|
114
192
|
export const TIPS = [
|
|
115
193
|
'Press Esc to interrupt the current turn at any time',
|
|
116
194
|
'/init generates an AGENTS.md from your codebase for smarter agent context',
|
|
@@ -161,7 +239,7 @@ export const TIPS = [
|
|
|
161
239
|
'Shift+Arrow selects text in the composer; Backspace or Delete removes it',
|
|
162
240
|
'Ctrl+J inserts a newline without sending the message',
|
|
163
241
|
'Ctrl+T expands or collapses the todo panel',
|
|
164
|
-
'Ctrl+O expands or collapses tool output and
|
|
242
|
+
'Ctrl+O expands or collapses tool output, thinking blocks and Edit diffs',
|
|
165
243
|
'Ctrl+B moves a long-running foreground Bash command to the background',
|
|
166
244
|
'Ctrl+Shift+V pastes the clipboard as a bracketed paste',
|
|
167
245
|
'Ctrl+Shift+C copies the mouse selection, or the last answer',
|
|
@@ -204,94 +282,301 @@ const MAX_PICKER = 12; // max picker list rows shown at once
|
|
|
204
282
|
const TIP_INTERVAL = 15000;
|
|
205
283
|
// Braille spinner frames for the "Working" indicator (orange).
|
|
206
284
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
285
|
+
// Stand-in for the LIVE spinner glyph inside a CACHED row. A pending thinking
|
|
286
|
+
// block must not be re-wrapped by the 80ms spin tick (see renderChatLines), so
|
|
287
|
+
// its preview is cached with this sentinel and the real frame is substituted in
|
|
288
|
+
// afterwards. It occupies 0 columns, so the swap never disturbs the layout.
|
|
289
|
+
const SPIN_PLACEHOLDER = '\u0000';
|
|
290
|
+
// How long a foreground Bash command runs before its card advertises Ctrl+B, and
|
|
291
|
+
// the hint text itself. Mirrors kimi-code's tool-call card (DETACH_HINT_*).
|
|
292
|
+
const DETACH_HINT_DELAY_MS = 10_000;
|
|
293
|
+
const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background';
|
|
207
294
|
|
|
208
295
|
// Rotating status messages for the "Working..." line — all mean "the agent is
|
|
209
296
|
// thinking / working", but vary the wording so it doesn't look stuck.
|
|
210
297
|
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
|
-
'
|
|
298
|
+
// ---- A (100) ----
|
|
299
|
+
'Accelerating...', 'Accessing...', 'Acclaiming...', 'Acclimating...', 'Accommodating...', 'Accompanying...', 'Accomplishing...', 'Accounting...', 'Accrediting...', 'Accruing...',
|
|
300
|
+
'Accumulating...', 'Achieving...', 'Acknowledging...', 'Acquainting...', 'Acquiring...', 'Acting...', 'Activating...', 'Actualizing...', 'Adapting...', 'Adding...',
|
|
301
|
+
'Addressing...', 'Adhering...', 'Adjusting...', 'Administering...', 'Admiring...', 'Adopting...', 'Adorning...', 'Advancing...', 'Advocating...', 'Affirming...',
|
|
302
|
+
'Affording...', 'Aggregating...', 'Agreeing...', 'Aiding...', 'Aiming...', 'Aligning...', 'Alleviating...', 'Allocating...', 'Allowing...', 'Alluring...',
|
|
303
|
+
'Altering...', 'Amalgamating...', 'Amassing...', 'Amending...', 'Amplifying...', 'Analyzing...', 'Anchoring...', 'Animating...', 'Annotating...', 'Announcing...',
|
|
304
|
+
'Answering...', 'Anticipating...', 'Appealing...', 'Applauding...', 'Applying...', 'Appointing...', 'Appraising...', 'Appreciating...', 'Approaching...', 'Approving...',
|
|
305
|
+
'Architecting...', 'Archiving...', 'Arranging...', 'Articulating...', 'Ascending...', 'Ascertaining...', 'Aspiring...', 'Assembling...', 'Asserting...', 'Assessing...',
|
|
306
|
+
'Assigning...', 'Assimilating...', 'Assisting...', 'Assuring...', 'Astonishing...', 'Attaining...', 'Attempting...', 'Attending...', 'Attracting...', 'Auditing...',
|
|
307
|
+
'Augmenting...', 'Authenticating...', 'Authoring...', 'Authorizing...', 'Automating...', 'Awakening...', 'Awarding...', 'Attuning...', 'Absorbing...', 'Accenting...',
|
|
308
|
+
'Acquiescing...', 'Adjudicating...', 'Adventuring...', 'Alerting...', 'Ameliorating...', 'Allying...', 'Annexing...', 'Aping...', 'Availing...', 'Averaging...',
|
|
309
|
+
// ---- B (100) ----
|
|
310
|
+
'Babbling...', 'Backing...', 'Backtracking...', 'Balancing...', 'Ballooning...', 'Banking...', 'Bargaining...', 'Bartering...', 'Basing...', 'Basking...',
|
|
311
|
+
'Batching...', 'Bathing...', 'Battling...', 'Beaching...', 'Beaming...', 'Bearing...', 'Beating...', 'Beautifying...', 'Beckoning...', 'Becoming...',
|
|
312
|
+
'Bedazzling...', 'Befriending...', 'Beginning...', 'Behaving...', 'Beholding...', 'Believing...', 'Belonging...', 'Benchmarking...', 'Bending...', 'Benefiting...',
|
|
313
|
+
'Bequeathing...', 'Beseeching...', 'Bestowing...', 'Bettering...', 'Bidding...', 'Biding...', 'Billowing...', 'Binding...', 'Birthing...', 'Biting...',
|
|
314
|
+
'Blasting...', 'Blazing...', 'Bleaching...', 'Bleeding...', 'Blending...', 'Blessing...', 'Blinking...', 'Blooming...', 'Blossoming...', 'Blowing...',
|
|
315
|
+
'Blurring...', 'Blushing...', 'Boarding...', 'Boasting...', 'Boiling...', 'Bolstering...', 'Bonding...', 'Booking...', 'Booming...', 'Boosting...',
|
|
316
|
+
'Bootstrapping...', 'Borrowing...', 'Bottling...', 'Bouncing...', 'Bounding...', 'Bowing...', 'Bowling...', 'Boxing...', 'Bracing...', 'Brainstorming...',
|
|
317
|
+
'Braiding...', 'Branching...', 'Branding...', 'Braving...', 'Breaking...', 'Breathing...', 'Breeding...', 'Breezing...', 'Brewing...', 'Bridging...',
|
|
318
|
+
'Briefing...', 'Brightening...', 'Bringing...', 'Broadcasting...', 'Broadening...', 'Bronzing...', 'Browsing...', 'Brushing...', 'Bubbling...', 'Budding...',
|
|
319
|
+
'Budgeting...', 'Buffering...', 'Building...', 'Bulking...', 'Bundling...', 'Buoying...', 'Burnishing...', 'Bustling...', 'Buying...', 'Buzzing...',
|
|
320
|
+
// ---- C (100) ----
|
|
321
|
+
'Caching...', 'Cajoling...', 'Caking...', 'Calibrating...', 'Calling...', 'Calming...', 'Campaigning...', 'Camping...', 'Canceling...', 'Canoodling...',
|
|
322
|
+
'Canvasing...', 'Capitalizing...', 'Captioning...', 'Captivating...', 'Capturing...', 'Caring...', 'Carving...', 'Cascading...', 'Cashing...', 'Casting...',
|
|
323
|
+
'Cataloging...', 'Catapulting...', 'Catching...', 'Categorizing...', 'Catering...', 'Cautioning...', 'Ceasing...', 'Celebrating...', 'Cementing...', 'Centralizing...',
|
|
324
|
+
'Certifying...', 'Chaining...', 'Chairing...', 'Chalking...', 'Challenging...', 'Championing...', 'Channeling...', 'Chanting...', 'Chaperoning...', 'Charging...',
|
|
325
|
+
'Charting...', 'Chasing...', 'Chatting...', 'Cheering...', 'Cherishing...', 'Chewing...', 'Chilling...', 'Chiming...', 'Chipping...', 'Chirping...',
|
|
326
|
+
'Chiseling...', 'Choosing...', 'Chopping...', 'Choreographing...', 'Chronicling...', 'Chugging...', 'Ciphering...', 'Circling...', 'Circulating...', 'Citing...',
|
|
327
|
+
'Civilizing...', 'Clamping...', 'Clanging...', 'Clapping...', 'Clarifying...', 'Clashing...', 'Classifying...', 'Clawing...', 'Cleaning...', 'Cleansing...',
|
|
328
|
+
'Clearing...', 'Cleaving...', 'Climbing...', 'Clinching...', 'Clipping...', 'Cloaking...', 'Clocking...', 'Cloning...', 'Closing...', 'Clouding...',
|
|
329
|
+
'Clustering...', 'Clutching...', 'Coaching...', 'Coalescing...', 'Coating...', 'Coaxing...', 'Cocooning...', 'Codifying...', 'Coexisting...', 'Cogitating...',
|
|
330
|
+
'Cohering...', 'Coiling...', 'Coinciding...', 'Collaborating...', 'Collapsing...', 'Collating...', 'Collecting...', 'Coloring...', 'Combining...', 'Comforting...',
|
|
331
|
+
// ---- D (100) ----
|
|
332
|
+
'Dabbling...', 'Dampening...', 'Dancing...', 'Daring...', 'Darting...', 'Dashing...', 'Dating...', 'Dawdling...', 'Dazzling...', 'Deactivating...',
|
|
333
|
+
'Debugging...', 'Debuting...', 'Decanting...', 'Decelerating...', 'Decentralizing...', 'Deciding...', 'Deciphering...', 'Decking...', 'Declaring...', 'Decoding...',
|
|
334
|
+
'Decomposing...', 'Decorating...', 'Decoupling...', 'Dedicating...', 'Deducing...', 'Deepening...', 'Defeating...', 'Defending...', 'Deferring...', 'Defining...',
|
|
335
|
+
'Deflecting...', 'Defragmenting...', 'Defusing...', 'Degreasing...', 'Dehydrating...', 'Delegating...', 'Deliberating...', 'Delighting...', 'Delivering...', 'Delving...',
|
|
336
|
+
'Demanding...', 'Demarcating...', 'Demonstrating...', 'Demystifying...', 'Denoting...', 'Densifying...', 'Departing...', 'Depending...', 'Deploying...', 'Depicting...',
|
|
337
|
+
'Depositing...', 'Depressurizing...', 'Deriving...', 'Descending...', 'Describing...', 'Desiring...', 'Despatching...', 'Detailing...', 'Detecting...', 'Deterring...',
|
|
338
|
+
'Determining...', 'Detoxing...', 'Devaluing...', 'Developing...', 'Deviating...', 'Devising...', 'Devoting...', 'Diagnosing...', 'Diagramming...', 'Dialing...',
|
|
339
|
+
'Digesting...', 'Digging...', 'Digitizing...', 'Diluting...', 'Dimensioning...', 'Directing...', 'Disarming...', 'Disbursing...', 'Discarding...', 'Discerning...',
|
|
340
|
+
'Discharging...', 'Disciplining...', 'Disclosing...', 'Disconnecting...', 'Discounting...', 'Discovering...', 'Discrediting...', 'Discussing...', 'Disentangling...', 'Disguising...',
|
|
341
|
+
'Disinfecting...', 'Dislodging...', 'Dismantling...', 'Dismissing...', 'Dispatching...', 'Dispensing...', 'Dispersing...', 'Displaying...', 'Disposing...', 'Disproving...',
|
|
342
|
+
'Dissecting...', 'Disseminating...', 'Dissipating...', 'Dissolving...', 'Distilling...', 'Distinguishing...', 'Distributing...', 'Diversifying...', 'Diverting...', 'Dividing...',
|
|
343
|
+
'Divining...', 'Divulging...', 'Docking...', 'Documenting...', 'Dodging...', 'Domesticating...', 'Dominating...', 'Donating...', 'Doodling...', 'Dosing...',
|
|
344
|
+
'Doubling...', 'Dovetailing...', 'Downloading...', 'Drafting...', 'Draining...', 'Dramatizing...', 'Drawing...', 'Dreaming...', 'Dredging...', 'Dressing...',
|
|
345
|
+
'Drifting...', 'Drilling...', 'Driving...', 'Dropping...', 'Drumming...', 'Drying...', 'Ducking...', 'Dusting...', 'Dwelling...', 'Dyeing...',
|
|
346
|
+
// ---- E (100) ----
|
|
347
|
+
'Earning...', 'Easing...', 'Echoing...', 'Eclipsing...', 'Economizing...', 'Edging...', 'Editing...', 'Educating...', 'Effecting...', 'Effervescing...',
|
|
348
|
+
'Elaborating...', 'Elating...', 'Electing...', 'Electrifying...', 'Elevating...', 'Eliciting...', 'Eliminating...', 'Elucidating...', 'Emancipating...', 'Embarking...',
|
|
349
|
+
'Embedding...', 'Embellishing...', 'Embodying...', 'Embracing...', 'Embroidering...', 'Emerging...', 'Emitting...', 'Empathizing...', 'Emphasizing...', 'Empowering...',
|
|
350
|
+
'Emulating...', 'Enabling...', 'Enacting...', 'Encapsulating...', 'Enchanting...', 'Encircling...', 'Enclosing...', 'Encoding...', 'Encouraging...', 'Encrypting...',
|
|
351
|
+
'Endearing...', 'Endeavoring...', 'Endorsing...', 'Endowing...', 'Enduring...', 'Energizing...', 'Enforcing...', 'Engaging...', 'Engineering...', 'Engraving...',
|
|
352
|
+
'Enhancing...', 'Enjoying...', 'Enlarging...', 'Enlightening...', 'Enlisting...', 'Enlivening...', 'Enriching...', 'Enrolling...', 'Ensuring...', 'Entering...',
|
|
353
|
+
'Entertaining...', 'Enthralling...', 'Enthusing...', 'Enticing...', 'Entitling...', 'Enumerating...', 'Enveloping...', 'Envisioning...', 'Equipping...', 'Equalizing...',
|
|
354
|
+
'Erecting...', 'Erasing...', 'Escalating...', 'Escaping...', 'Escorting...', 'Establishing...', 'Esteeming...', 'Estimating...', 'Eulogizing...', 'Evaluating...',
|
|
355
|
+
'Evaporating...', 'Evolving...', 'Examining...', 'Exceeding...', 'Excelling...', 'Exchanging...', 'Exciting...', 'Excluding...', 'Executing...', 'Exercising...',
|
|
356
|
+
'Exhaling...', 'Exhibiting...', 'Exhilarating...', 'Existing...', 'Expanding...', 'Expecting...', 'Expediting...', 'Experiencing...', 'Experimenting...', 'Explaining...',
|
|
357
|
+
'Exploring...', 'Exporting...', 'Exposing...', 'Expressing...', 'Extending...', 'Externalizing...', 'Extracting...', 'Extrapolating...', 'Eyeing...',
|
|
358
|
+
// ---- F (100) ----
|
|
359
|
+
'Fabricating...', 'Facilitating...', 'Facing...', 'Factoring...', 'Fading...', 'Farming...', 'Fascinating...', 'Fashioning...', 'Fastening...', 'Fathoming...',
|
|
360
|
+
'Favoring...', 'Feathering...', 'Featuring...', 'Feeding...', 'Feeling...', 'Fencing...', 'Fermenting...', 'Ferrying...', 'Fertilizing...', 'Fetching...',
|
|
361
|
+
'Fiddling...', 'Fielding...', 'Figuring...', 'Filing...', 'Filling...', 'Filtering...', 'Financing...', 'Finding...', 'Fingerprinting...', 'Finishing...',
|
|
362
|
+
'Firing...', 'Firming...', 'Fishing...', 'Fitting...', 'Fixing...', 'Flagging...', 'Flanking...', 'Flashing...', 'Flattening...', 'Flattering...',
|
|
363
|
+
'Flavoring...', 'Fleshing...', 'Flexing...', 'Flicking...', 'Flinging...', 'Flipping...', 'Floating...', 'Flooding...', 'Flooring...', 'Flourishing...',
|
|
364
|
+
'Flowing...', 'Flushing...', 'Fluttering...', 'Flying...', 'Foaming...', 'Focusing...', 'Folding...', 'Following...', 'Forging...', 'Forgiving...',
|
|
365
|
+
'Formalizing...', 'Forming...', 'Formulating...', 'Fortifying...', 'Forwarding...', 'Fostering...', 'Founding...', 'Fragmenting...', 'Framing...', 'Freeing...',
|
|
366
|
+
'Freezing...', 'Freshening...', 'Frosting...', 'Frying...', 'Fueling...', 'Fulfilling...', 'Functioning...', 'Funding...', 'Furnishing...', 'Furthering...',
|
|
367
|
+
'Fusing...', 'Fussing...', 'Factorizing...', 'Fantasizing...', 'Faultfinding...', 'Federating...', 'Fending...', 'Ferreting...', 'Filming...', 'Finalizing...',
|
|
368
|
+
'Finessing...', 'Fireproofing...', 'Fizzing...', 'Flameproofing...', 'Fleecing...', 'Flickering...', 'Flitting...', 'Fluctuating...', 'Fluorescing...', 'Freewheeling...',
|
|
369
|
+
// ---- G (100) ----
|
|
370
|
+
'Gaining...', 'Galloping...', 'Galvanizing...', 'Gaming...', 'Gardening...', 'Garlanding...', 'Garnering...', 'Gathering...', 'Gauging...', 'Gazing...',
|
|
371
|
+
'Gearing...', 'Generalizing...', 'Generating...', 'Gesticulating...', 'Gesturing...', 'Getting...', 'Gifting...', 'Giggling...', 'Gilding...', 'Girding...',
|
|
372
|
+
'Giving...', 'Glancing...', 'Glazing...', 'Gleaming...', 'Gleaning...', 'Glimmering...', 'Glimpsing...', 'Gliding...', 'Glistening...', 'Glittering...',
|
|
373
|
+
'Glowing...', 'Glueing...', 'Goading...', 'Gobbling...', 'Golfing...', 'Governing...', 'Grabbing...', 'Graduating...', 'Grafting...', 'Granting...',
|
|
374
|
+
'Graphing...', 'Grasping...', 'Gratifying...', 'Grating...', 'Greasing...', 'Greeting...', 'Gridding...', 'Grilling...', 'Grinding...', 'Gripping...',
|
|
375
|
+
'Grooving...', 'Grooming...', 'Grounding...', 'Grouping...', 'Growing...', 'Guaranteeing...', 'Guarding...', 'Guessing...', 'Guiding...', 'Gushing...',
|
|
376
|
+
'Gusting...', 'Gutting...', 'Guzzling...', 'Gamboling...', 'Gating...', 'Gazetting...', 'Gelling...', 'Gentrifying...', 'Germinating...', 'Gestating...',
|
|
377
|
+
'Ghosting...', 'Glaciating...', 'Gladdening...', 'Globalizing...', 'Glorifying...', 'Glossing...', 'Gonging...', 'Gouging...', 'Grandstanding...', 'Granulating...',
|
|
378
|
+
'Gravitating...', 'Grazing...', 'Greening...', 'Grimacing...', 'Grubbing...', 'Gurgling...', 'Gyrating...', 'Gabbing...', 'Gaffing...', 'Gallivanting...',
|
|
379
|
+
'Galumphing...', 'Garbling...', 'Garrisoning...', 'Gasping...', 'Gentling...', 'Geotagging...', 'Gimballing...', 'Glinting...', 'Gorging...', 'Grinning...',
|
|
380
|
+
// ---- H (100) ----
|
|
381
|
+
'Habilitating...', 'Hacking...', 'Haggling...', 'Hailing...', 'Halting...', 'Hammering...', 'Handcrafting...', 'Handing...', 'Handling...', 'Hanging...',
|
|
382
|
+
'Happening...', 'Harboring...', 'Hardening...', 'Harmonizing...', 'Harnessing...', 'Harvesting...', 'Hashing...', 'Hastening...', 'Hatching...', 'Hauling...',
|
|
383
|
+
'Having...', 'Heading...', 'Healing...', 'Heaping...', 'Hearing...', 'Heartening...', 'Heating...', 'Heaving...', 'Hedging...', 'Heeding...',
|
|
384
|
+
'Helping...', 'Heralding...', 'Herding...', 'Hesitating...', 'Hibernating...', 'Hiding...', 'Highlighting...', 'Hiking...', 'Hinging...', 'Hinting...',
|
|
385
|
+
'Hiring...', 'Hitting...', 'Hoarding...', 'Hoisting...', 'Holding...', 'Hollowing...', 'Homing...', 'Honing...', 'Honoring...', 'Hooking...',
|
|
386
|
+
'Hopping...', 'Hoping...', 'Horsing...', 'Hosting...', 'Hotfixing...', 'Hovering...', 'Howling...', 'Huddling...', 'Hugging...', 'Hulling...',
|
|
387
|
+
'Humanizing...', 'Humbling...', 'Humming...', 'Hunting...', 'Hurling...', 'Hurrying...', 'Hushing...', 'Hustling...', 'Hybridizing...', 'Hydrating...',
|
|
388
|
+
'Hydroplaning...', 'Hymning...', 'Hyping...', 'Hypothesizing...', 'Habituating...', 'Hairdressing...', 'Hallowing...', 'Halving...', 'Handholding...', 'Handpicking...',
|
|
389
|
+
'Handwriting...', 'Hankering...', 'Hardcoding...', 'Harkening...', 'Hawking...', 'Haymaking...', 'Headhunting...', 'Headlining...', 'Heartwarming...', 'Heightening...',
|
|
390
|
+
'Heehawing...', 'Homesteading...', 'Homeschooling...', 'Honeying...', 'Hooping...', 'Hornswoggling...', 'Hosing...', 'Hounding...', 'Huckstering...', 'Humoring...',
|
|
391
|
+
// ---- I (100) ----
|
|
392
|
+
'Idealizing...', 'Identifying...', 'Igniting...', 'Ignoring...', 'Illuminating...', 'Illustrating...', 'Imagining...', 'Imitating...', 'Immersing...', 'Immortalizing...',
|
|
393
|
+
'Impacting...', 'Imparting...', 'Impersonating...', 'Implementing...', 'Implicating...', 'Importing...', 'Imposing...', 'Impressing...', 'Imprinting...', 'Improving...',
|
|
394
|
+
'Improvising...', 'Inaugurating...', 'Incentivizing...', 'Inching...', 'Including...', 'Incorporating...', 'Increasing...', 'Incrementing...', 'Incubating...', 'Incurring...',
|
|
395
|
+
'Indenting...', 'Indexing...', 'Indicating...', 'Inducting...', 'Indulging...', 'Industrializing...', 'Inferring...', 'Infiltrating...', 'Inflating...', 'Influencing...',
|
|
396
|
+
'Informing...', 'Infusing...', 'Ingesting...', 'Inhabiting...', 'Inhaling...', 'Inheriting...', 'Inhibiting...', 'Initializing...', 'Initiating...', 'Injecting...',
|
|
397
|
+
'Injuring...', 'Inking...', 'Innovating...', 'Inoculating...', 'Inquiring...', 'Inscribing...', 'Inserting...', 'Insisting...', 'Inspecting...', 'Inspiring...',
|
|
398
|
+
'Installing...', 'Instantiating...', 'Instigating...', 'Instilling...', 'Instituting...', 'Instructing...', 'Insulating...', 'Insuring...', 'Integrating...', 'Intending...',
|
|
399
|
+
'Intensifying...', 'Interacting...', 'Intercepting...', 'Interfacing...', 'Interleaving...', 'Interlinking...', 'Interlacing...', 'Interlocking...', 'Interning...', 'Interpolating...',
|
|
400
|
+
'Interpreting...', 'Interrogating...', 'Interrupting...', 'Intersecting...', 'Intervening...', 'Interviewing...', 'Interweaving...', 'Intoning...', 'Introducing...', 'Intuiting...',
|
|
401
|
+
'Inventing...', 'Inventorying...', 'Inverting...', 'Investing...', 'Investigating...', 'Invigorating...', 'Inviting...', 'Involving...', 'Ionizing...', 'Iterating...',
|
|
402
|
+
// ---- J ----
|
|
403
|
+
'Jabbing...', 'Jacketing...', 'Jailing...', 'Jamming...', 'Jangling...', 'Jarring...', 'Jaunting...', 'Jazzing...',
|
|
404
|
+
'Jeering...', 'Jelling...', 'Jeopardizing...', 'Jerking...', 'Jesting...', 'Jettisoning...', 'Jeweling...', 'Jibing...',
|
|
405
|
+
'Jiggling...', 'Jigsawing...', 'Jilting...', 'Jingling...', 'Jiving...', 'Jobbing...', 'Jockeying...', 'Jogging...',
|
|
406
|
+
'Joining...', 'Joking...', 'Jollying...', 'Jolting...', 'Jostling...', 'Jotting...', 'Journeying...', 'Jousting...',
|
|
407
|
+
'Joyriding...', 'Judging...', 'Juggling...', 'Julienning...', 'Jumping...', 'Junketing...', 'Justifying...', 'Jutting...',
|
|
408
|
+
'Juxtaposing...', 'Jabbering...', 'Jading...', 'Jalousing...', 'Japing...', 'Jargonizing...', 'Jauncing...', 'Jawboning...',
|
|
409
|
+
'Jawing...', 'Jeopardying...', 'Jellifying...', 'Jemmying...', 'Jettying...', 'Jewelling...', 'Jibbing...', 'Jigging...',
|
|
410
|
+
'Jitterbugging...', 'Jinxing...', 'Jittering...', 'Jobhunting...', 'Joggling...', 'Joisting...', 'Jokifying...', 'Jollifying...',
|
|
411
|
+
'Jonesing...', 'Joshing...', 'Jouking...', 'Journalling...', 'Jovializing...', 'Jowing...', 'Joying...', 'Joypopping...',
|
|
412
|
+
'Jubilating...', 'Juddering...', 'Judoing...', 'Juking...', 'Jumbling...', 'Jumpstarting...', 'Junking...', 'Jurifying...',
|
|
413
|
+
'Jurying...', 'Justling...', 'Juicing...', 'Jouncing...', 'Javelining...', 'Jocundizing...', 'Japering...', 'Japonicaing...',
|
|
414
|
+
'Jasperizing...', 'Jazzercising...', 'Jellyfishing...', 'Jerrybuilding...', 'Jerseying...', 'Jessying...', 'Jobseeking...', 'Jointing...',
|
|
415
|
+
'Junketeering...', 'Jawbreaking...', 'Jaywalking...',
|
|
416
|
+
// ---- K ----
|
|
417
|
+
'Keeling...', 'Keening...', 'Keeping...', 'Kenning...', 'Kerbing...', 'Kerneling...', 'Kettling...', 'Keyboarding...',
|
|
418
|
+
'Keying...', 'Keynoting...', 'Kibitzing...', 'Kicking...', 'Kidding...', 'Kidnapping...', 'Kilning...', 'Kindling...',
|
|
419
|
+
'Kinging...', 'Kinking...', 'Kissing...', 'Kitting...', 'Kneading...', 'Kneeling...', 'Knifing...', 'Knighting...',
|
|
420
|
+
'Knitting...', 'Knocking...', 'Knotting...', 'Knowing...', 'Knuckling...', 'Koshering...', 'Kowtowing...', 'Kvetching...',
|
|
421
|
+
'Karaoking...', 'Kayoing...', 'Kecking...', 'Kedging...', 'Keeking...', 'Kelping...', 'Kenneling...', 'Keratinizing...',
|
|
422
|
+
'Kerfing...', 'Kernelling...', 'Kibbutzing...', 'Kiboshing...', 'Kickboxing...', 'Kickstarting...', 'Kiddying...', 'Kiltering...',
|
|
423
|
+
'Kippering...', 'Kiting...', 'Kittening...', 'Klaxoning...', 'Knapping...', 'Kneecapping...', 'Knelling...', 'Knobbling...',
|
|
424
|
+
'Knolling...', 'Kodaking...', 'Konking...', 'Kooking...', 'Koreanizing...', 'Kremlining...', 'Krumping...', 'Kudosing...',
|
|
425
|
+
'Kurbling...', 'Kvelling...', 'Kingmaking...', 'Kyanising...', 'Kyboshing...', 'Kything...', 'Kaolinizing...', 'Kaputing...',
|
|
426
|
+
'Katalysing...', 'Kathoding...', 'Keshing...', 'Keypunching...', 'Khediving...', 'Kalsomining...', 'Kamiing...', 'Kantianing...',
|
|
427
|
+
'Karaokeing...', 'Karateing...', 'Karstifying...', 'Katabolizing...', 'Kathing...', 'Kazaching...', 'Kebabbing...', 'Kebobbing...',
|
|
428
|
+
'Keeving...', 'Keelhauling...', 'Keenlying...', 'Keepnetting...', 'Keltering...', 'Kerseying...', 'Ketling...', 'Keyframing...',
|
|
429
|
+
'Keyseating...', 'Kibbling...', 'Kickboarding...', 'Kickflipping...', 'Kidneystoning...', 'Kieving...', 'Kimchifying...', 'Kimmering...',
|
|
430
|
+
'Kindergartening...', 'Kinescoping...', 'Kingfishing...', 'Kirkifying...', 'Kirshening...', 'Kitbagging...', 'Kitchening...', 'Kitesurfing...',
|
|
431
|
+
'Klondiking...', 'Kludging...', 'Knackering...', 'Kneippering...', 'Knobkerrying...', 'Knowledging...', 'Kohlrabiing...', 'Koorifying...',
|
|
432
|
+
'Kotowing...', 'Kronaing...', 'Krytroning...', 'Kugelhopfing...', 'Kumquating...', 'Kurrajonging...', 'Kyanizing...',
|
|
433
|
+
// ---- L (100) ----
|
|
434
|
+
'Labeling...', 'Laboring...', 'Lacing...', 'Laddering...', 'Lading...', 'Lagging...', 'Lambasting...', 'Laminating...', 'Landing...', 'Languishing...',
|
|
435
|
+
'Lapping...', 'Lapsing...', 'Larding...', 'Lashing...', 'Lasting...', 'Latching...', 'Lathering...', 'Lauding...', 'Laughing...', 'Launching...',
|
|
436
|
+
'Laundering...', 'Lavishing...', 'Laying...', 'Lazing...', 'Leaching...', 'Leading...', 'Leafing...', 'Leaking...', 'Leaning...', 'Leaping...',
|
|
437
|
+
'Learning...', 'Leasing...', 'Leathering...', 'Leaving...', 'Lecturing...', 'Ledgering...', 'Leeching...', 'Leering...', 'Legitimizing...', 'Lending...',
|
|
438
|
+
'Lengthening...', 'Lessening...', 'Letting...', 'Leveling...', 'Leveraging...', 'Levying...', 'Liberating...', 'Licensing...', 'Licking...', 'Lifting...',
|
|
439
|
+
'Lightening...', 'Lighting...', 'Liking...', 'Limbering...', 'Limiting...', 'Lining...', 'Linking...', 'Lionizing...', 'Liquidating...', 'Lisping...',
|
|
440
|
+
'Listening...', 'Litigating...', 'Littering...', 'Living...', 'Loading...', 'Loafing...', 'Loaning...', 'Lobbying...', 'Lobing...', 'Localizing...',
|
|
441
|
+
'Locating...', 'Locking...', 'Lodging...', 'Lofting...', 'Logging...', 'Longing...', 'Looking...', 'Looping...', 'Loosening...', 'Looting...',
|
|
442
|
+
'Loping...', 'Losing...', 'Lounging...', 'Loving...', 'Lowering...', 'Lubricating...', 'Lugging...', 'Lulling...', 'Lumbering...', 'Lumping...',
|
|
443
|
+
'Lunching...', 'Lunging...', 'Luring...', 'Lurking...', 'Lustering...', 'Lustrating...', 'Luxuriating...', 'Lying...', 'Lynching...', 'Lyricizing...',
|
|
444
|
+
// ---- M (100) ----
|
|
445
|
+
'Machining...', 'Magnetizing...', 'Magnifying...', 'Maintaining...', 'Mainstreaming...', 'Making...', 'Managing...', 'Mandating...',
|
|
446
|
+
'Maneuvering...', 'Mangling...', 'Manifesting...', 'Manipulating...', 'Manning...', 'Manufacturing...', 'Mapping...', 'Marching...',
|
|
447
|
+
'Marketing...', 'Marking...', 'Marrying...', 'Marshaling...', 'Marveling...', 'Mashing...', 'Masking...', 'Massaging...',
|
|
448
|
+
'Massing...', 'Mastering...', 'Matching...', 'Materializing...', 'Matriculating...', 'Mattering...', 'Maximizing...', 'Meaning...',
|
|
449
|
+
'Measuring...', 'Mediating...', 'Meditating...', 'Meeting...', 'Melding...', 'Mellowing...', 'Melting...', 'Memorizing...',
|
|
450
|
+
'Mending...', 'Mentioning...', 'Mentoring...', 'Merging...', 'Meriting...', 'Meshing...', 'Mesmerizing...', 'Messaging...',
|
|
451
|
+
'Metabolizing...', 'Metalworking...', 'Metamorphosing...', 'Metering...', 'Microblogging...', 'Microchipping...', 'Microfilming...', 'Micromanaging...',
|
|
452
|
+
'Migrating...', 'Milking...', 'Milling...', 'Mimicking...', 'Mincing...', 'Minding...', 'Mingling...', 'Miniaturizing...',
|
|
453
|
+
'Minimizing...', 'Mining...', 'Ministering...', 'Minting...', 'Mirroring...', 'Misaligning...', 'Miscalculating...', 'Misdiagnosing...',
|
|
454
|
+
'Misfiring...', 'Misjudging...', 'Mislaying...', 'Mismatching...', 'Misplacing...', 'Misquoting...', 'Misreading...', 'Missing...',
|
|
455
|
+
'Misspelling...', 'Mistaking...', 'Misting...', 'Misunderstanding...', 'Mitigating...', 'Mixing...', 'Moaning...', 'Mobilizing...',
|
|
456
|
+
'Mocking...', 'Modeling...', 'Moderating...', 'Modernizing...', 'Modifying...', 'Modularizing...', 'Modulating...', 'Moistening...',
|
|
457
|
+
'Moisturizing...', 'Molding...', 'Mollifying...', 'Monitoring...', 'Monetizing...', 'Monogramming...', 'Monologuing...', 'Monopolizing...',
|
|
458
|
+
'Mooring...', 'Moralizing...', 'Morphing...', 'Mortgaging...', 'Moseying...', 'Motivating...', 'Motoring...', 'Mottling...',
|
|
459
|
+
'Moulding...', 'Mounting...', 'Mountaineering...', 'Mourning...', 'Mousetrapping...', 'Mouthing...', 'Moving...', 'Mowing...',
|
|
460
|
+
'Muckraking...', 'Muddling...', 'Mudslinging...', 'Muffling...', 'Mulching...', 'Multiplying...', 'Multiplexing...', 'Multitasking...',
|
|
461
|
+
'Mumbling...', 'Mummifying...', 'Munching...', 'Murmuring...', 'Muscling...', 'Musicalizing...', 'Musing...', 'Musseling...',
|
|
462
|
+
'Mustering...', 'Mutating...', 'Muting...', 'Mutinying...', 'Muttering...', 'Mutualizing...', 'Muzzling...', 'Mystifying...',
|
|
463
|
+
'Mailing...', 'Mamboing...', 'Manacling...', 'Manhandling...', 'Manicuring...', 'Mansplaining...', 'Mantling...', 'Marinating...',
|
|
464
|
+
'Marooning...', 'Marring...', 'Masquerading...', 'Masterminding...', 'Masticating...', 'Mauling...', 'Meandering...', 'Mechanizing...',
|
|
465
|
+
'Meddling...', 'Meliorating...', 'Melodizing...', 'Memorializing...', 'Menacing...', 'Mercerizing...', 'Merchandising...', 'Metallizing...',
|
|
466
|
+
'Mildewing...', 'Militating...', 'Mimeographing...', 'Mindmapping...', 'Minesweeping...', 'Misappropriating...', 'Misconstruing...', 'Misrepresenting...',
|
|
467
|
+
// ---- N (100) ----
|
|
468
|
+
'Nabbing...', 'Nailing...', 'Naming...', 'Nanosizing...', 'Napping...', 'Narrating...', 'Narrowing...', 'Nattering...',
|
|
469
|
+
'Naturalizing...', 'Navigating...', 'Neatening...', 'Nebulizing...', 'Necessitating...', 'Necking...', 'Necropsying...', 'Needling...',
|
|
470
|
+
'Negating...', 'Negotiating...', 'Nerving...', 'Nesting...', 'Nestling...', 'Netting...', 'Nettling...', 'Networking...',
|
|
471
|
+
'Neutralizing...', 'Newscasting...', 'Nibbling...', 'Nicking...', 'Nicknaming...', 'Nictitating...', 'Niggling...', 'Nipping...',
|
|
472
|
+
'Nitpicking...', 'Nobbling...', 'Nocking...', 'Nodding...', 'Noising...', 'Nominating...', 'Nonplussing...', 'Noodling...',
|
|
473
|
+
'Normalizing...', 'Nosediving...', 'Nosing...', 'Notarizing...', 'Notating...', 'Notching...', 'Noticing...', 'Notifying...',
|
|
474
|
+
'Noting...', 'Nourishing...', 'Novelizing...', 'Nucleating...', 'Nudging...', 'Nuggetting...', 'Nuking...', 'Nullifying...',
|
|
475
|
+
'Numbering...', 'Numbing...', 'Nursemaiding...', 'Nursing...', 'Nurturing...', 'Nutmegging...', 'Nuzzling...', 'Narrowcasting...',
|
|
476
|
+
// ---- O (100) ----
|
|
477
|
+
'Obeying...', 'Objecting...', 'Objectivizing...', 'Obligating...', 'Obliging...', 'Obliterating...', 'Obnubilating...', 'Obscuring...',
|
|
478
|
+
'Observing...', 'Obsessing...', 'Obsoleting...', 'Obstructing...', 'Obtaining...', 'Obtruding...', 'Obviating...', 'Occasioning...',
|
|
479
|
+
'Occupying...', 'Occurring...', 'Offering...', 'Officiating...', 'Offloading...', 'Offsetting...', 'Ogling...', 'Oiling...',
|
|
480
|
+
'Okaying...', 'Ominating...', 'Omitting...', 'Onboarding...', 'Oozing...', 'Opacifying...', 'Opalescing...', 'Opening...',
|
|
481
|
+
'Operating...', 'Opining...', 'Opposing...', 'Optimizing...', 'Optioning...', 'Orating...', 'Orbiting...', 'Orchestrating...',
|
|
482
|
+
'Ordaining...', 'Ordering...', 'Organizing...', 'Orienting...', 'Originating...', 'Ornamenting...', 'Orphaning...', 'Oscillating...',
|
|
483
|
+
'Ossifying...', 'Ostracizing...', 'Outbidding...', 'Outclassing...', 'Outdoing...', 'Outfitting...', 'Outgrowing...', 'Outlasting...',
|
|
484
|
+
'Outlining...', 'Outmaneuvering...', 'Outpacing...', 'Outperforming...', 'Outreaching...', 'Outsmarting...', 'Outsourcing...', 'Outstripping...',
|
|
485
|
+
'Outwitting...', 'Overachieving...', 'Overarching...', 'Overbalancing...', 'Overbooking...', 'Overbuilding...', 'Overcoming...', 'Overcompensating...',
|
|
486
|
+
'Overdelivering...', 'Overdoing...', 'Overeating...', 'Overemphasizing...', 'Overestimating...', 'Overflowing...', 'Overhauling...', 'Overhearing...',
|
|
487
|
+
'Overheating...', 'Overlaying...', 'Overloading...', 'Overlooking...', 'Overpowering...', 'Overprinting...', 'Overproducing...', 'Overreaching...',
|
|
488
|
+
'Overriding...', 'Overruling...', 'Overseeing...', 'Overshadowing...', 'Overshooting...', 'Oversimplifying...', 'Oversleeping...', 'Overstating...',
|
|
489
|
+
'Overstepping...', 'Overstocking...', 'Overstretching...', 'Overtaking...', 'Overthinking...', 'Overthrowing...', 'Overturning...', 'Overvaluing...',
|
|
490
|
+
'Overwatching...', 'Overwhelming...', 'Owing...', 'Owning...', 'Oxidizing...', 'Oxygenating...', 'Ozonizing...', 'Obolizing...',
|
|
491
|
+
// ---- P ----
|
|
492
|
+
'Pacing...', 'Packaging...', 'Packing...', 'Padding...', 'Paddling...', 'Paging...', 'Painting...', 'Pairing...',
|
|
493
|
+
'Palatalizing...', 'Palling...', 'Palming...', 'Palpating...', 'Pampering...', 'Panelizing...', 'Panicking...', 'Pantomiming...',
|
|
494
|
+
'Papering...', 'Parachuting...', 'Parading...', 'Paragliding...', 'Paralysing...', 'Paraphrasing...', 'Parceling...', 'Pardoning...',
|
|
495
|
+
'Parenting...', 'Pariking...', 'Parking...', 'Parleying...', 'Parodying...', 'Paroling...', 'Parrying...', 'Parsing...',
|
|
496
|
+
'Partaking...', 'Partitioning...', 'Partnering...', 'Partying...', 'Passing...', 'Passivizing...', 'Pasting...', 'Pastoring...',
|
|
497
|
+
'Patching...', 'Patenting...', 'Patrolling...', 'Patronizing...', 'Patterning...', 'Pausing...', 'Paving...', 'Pawing...',
|
|
498
|
+
'Paying...', 'Peaking...', 'Pealing...', 'Pecking...', 'Pedaling...', 'Peeking...', 'Peeling...', 'Peering...',
|
|
499
|
+
'Pegging...', 'Pelting...', 'Penalizing...', 'Penciling...', 'Pending...', 'Penetrating...', 'Pensioning...', 'Peppering...',
|
|
500
|
+
'Perceiving...', 'Perching...', 'Perfecting...', 'Perforating...', 'Performing...', 'Perfuming...', 'Perishing...', 'Permeating...',
|
|
501
|
+
'Permitting...', 'Permuting...', 'Perpetuating...', 'Perplexing...', 'Persevering...', 'Persisting...', 'Personalizing...', 'Personifying...',
|
|
502
|
+
'Persuading...', 'Perving...', 'Pestering...', 'Petitioning...', 'Petrifying...', 'Pettifogging...', 'Phaseing...', 'Philandering...',
|
|
503
|
+
'Philosophizing...', 'Phoneing...', 'Photocopying...', 'Photographing...', 'Photosynthesizing...', 'Phrasing...', 'Picking...', 'Picnicking...',
|
|
504
|
+
'Picturing...', 'Piecing...', 'Piercing...', 'Piggybacking...', 'Pilfering...', 'Piloting...', 'Pinching...', 'Pinging...',
|
|
505
|
+
'Pinning...', 'Pioneering...', 'Piping...', 'Pitching...', 'Pitying...', 'Pivoting...', 'Placating...', 'Placing...',
|
|
506
|
+
'Plagiarizing...', 'Plaiting...', 'Planning...', 'Planting...', 'Plastering...', 'Plating...', 'Playing...', 'Pleading...',
|
|
507
|
+
'Pleasuring...', 'Pledging...', 'Plodding...', 'Plotting...', 'Ploughing...', 'Plowing...', 'Plucking...', 'Plugging...',
|
|
508
|
+
'Plumbing...', 'Plummeting...', 'Plumping...', 'Plundering...', 'Plunging...', 'Pluralizing...', 'Plying...', 'Poaching...',
|
|
509
|
+
'Pocketing...', 'Podcasting...', 'Poeticizing...', 'Pointing...', 'Poising...', 'Poisoning...', 'Poking...', 'Polarizing...',
|
|
510
|
+
'Policing...', 'Polishing...', 'Politicizing...', 'Pollinating...', 'Polling...', 'Pondering...', 'Pooling...', 'Popping...',
|
|
511
|
+
'Popularizing...', 'Populating...', 'Poring...', 'Porting...', 'Portioning...', 'Portraying...', 'Posing...', 'Positioning...',
|
|
512
|
+
'Positing...', 'Possessing...', 'Posting...', 'Postponing...', 'Postulating...', 'Posturing...', 'Potting...', 'Pouncing...',
|
|
513
|
+
'Pounding...', 'Pouring...', 'Pouting...', 'Powdering...', 'Powering...', 'Practicing...', 'Praising...', 'Prancing...',
|
|
514
|
+
'Prattling...', 'Praying...', 'Preaching...', 'Preambleing...', 'Preceding...', 'Precipitating...', 'Precluding...', 'Precooking...',
|
|
515
|
+
'Predicting...', 'Predisposing...', 'Prefacing...', 'Preferring...', 'Prefiguring...', 'Prefixing...', 'Preheating...', 'Prejudging...',
|
|
516
|
+
'Preluding...', 'Premising...', 'Preoccupying...', 'Preordaining...', 'Preparing...', 'Prepaving...', 'Prepaying...', 'Preponderating...',
|
|
517
|
+
'Prerecording...', 'Prescribing...', 'Presenting...', 'Preserving...', 'Presetting...', 'Presiding...', 'Pressuring...', 'Pressurizing...',
|
|
518
|
+
'Prestressing...', 'Presuming...', 'Pretending...', 'Prettifying...', 'Prevailing...', 'Preventing...', 'Previewing...', 'Preying...',
|
|
519
|
+
'Pricing...', 'Pricking...', 'Pridiing...', 'Priming...', 'Printing...', 'Prioritizing...', 'Privatizing...', 'Privileging...',
|
|
520
|
+
'Prizing...', 'Probing...', 'Proceeding...', 'Processing...', 'Proclaiming...', 'Procrastinating...', 'Procuring...', 'Prodding...',
|
|
521
|
+
'Producing...', 'Profaning...', 'Professing...', 'Profiling...', 'Profiting...', 'Programming...', 'Progressing...', 'Prohibiting...',
|
|
522
|
+
'Projecting...', 'Proliferating...', 'Prolonging...', 'Promenading...', 'Promising...', 'Promoting...', 'Prompting...', 'Promulgating...',
|
|
523
|
+
'Pronouncing...', 'Proofing...', 'Proofreading...', 'Propagating...', 'Propelling...', 'Prophesying...', 'Proposing...', 'Propping...',
|
|
524
|
+
'Proroguing...', 'Prosecuting...', 'Proselytizing...', 'Prospecting...', 'Prospering...', 'Protecting...', 'Protesting...', 'Protruding...',
|
|
525
|
+
'Proving...', 'Providing...', 'Provisioning...', 'Provoking...', 'Prowling...', 'Pruning...', 'Psyching...', 'Publicizing...',
|
|
526
|
+
'Publishing...', 'Puckering...', 'Puffing...', 'Pulling...', 'Pulping...', 'Pulsating...', 'Pulverizing...', 'Pummeling...',
|
|
527
|
+
'Pumping...', 'Punching...', 'Puncturing...', 'Punishing...', 'Punning...', 'Purging...', 'Purifying...', 'Purloining...',
|
|
528
|
+
'Purporting...', 'Purring...', 'Pursuing...', 'Pushing...', 'Putting...', 'Puzzling...', 'Pyramiding...',
|
|
529
|
+
// ---- Q ----
|
|
530
|
+
'Quacking...', 'Quadrupling...', 'Quaffing...', 'Quailing...', 'Qualifying...', 'Quantifying...', 'Quarreling...', 'Quarrying...',
|
|
531
|
+
'Quartering...', 'Quashing...', 'Quavering...', 'Queening...', 'Quelling...', 'Quenching...', 'Querying...', 'Questing...',
|
|
532
|
+
'Questioning...', 'Queueing...', 'Quickening...', 'Quieting...', 'Quilting...', 'Quipping...', 'Quivering...', 'Quizzing...',
|
|
533
|
+
'Quoting...',
|
|
534
|
+
// ---- R ----
|
|
535
|
+
'Racing...', 'Racking...', 'Radiating...', 'Raffling...', 'Rafting...', 'Raging...', 'Raining...', 'Raising...',
|
|
536
|
+
'Rallying...', 'Rambling...', 'Ramifying...', 'Ramping...', 'Ransacking...', 'Ransoming...', 'Ranting...', 'Rapping...',
|
|
537
|
+
'Rapproching...', 'Rapturing...', 'Raring...', 'Rasping...', 'Ratifying...', 'Rating...', 'Rationalizing...',
|
|
538
|
+
'Rationing...', 'Rattling...', 'Ravaging...', 'Raving...', 'Reaching...', 'Reacting...', 'Readjusting...', 'Readying...',
|
|
539
|
+
'Realigning...', 'Realizing...', 'Reallocating...', 'Reanimating...', 'Reaping...', 'Reappearing...', 'Reapplying...', 'Rearranging...',
|
|
540
|
+
'Reasoning...', 'Reassembling...', 'Reasserting...', 'Reassessing...', 'Reassigning...', 'Reassuring...', 'Rebalancing...', 'Rebating...',
|
|
541
|
+
'Rebelling...', 'Rebooting...', 'Rebounding...', 'Rebranding...', 'Rebuilding...', 'Rebuking...', 'Recalculating...', 'Recalibrating...',
|
|
542
|
+
'Recalling...', 'Recanting...', 'Recapping...', 'Recapturing...', 'Receding...', 'Receiving...', 'Recentering...', 'Recharging...',
|
|
543
|
+
'Rechecking...', 'Rechristening...', 'Recirculating...', 'Reciting...', 'Reclaiming...', 'Reclassifying...', 'Reclining...', 'Recognizing...',
|
|
544
|
+
'Recoiling...', 'Recollecting...', 'Recommending...', 'Recommissioning...', 'Recompiling...', 'Reconciling...', 'Reconfiguring...', 'Reconnecting...',
|
|
545
|
+
'Reconsidering...', 'Reconstituting...', 'Reconstructing...', 'Recording...', 'Recounting...', 'Recouping...', 'Recovering...', 'Recreating...',
|
|
546
|
+
'Recruiting...', 'Rectifying...', 'Recuperating...', 'Recurring...', 'Recycling...', 'Redacting...', 'Redefining...', 'Redeploying...',
|
|
547
|
+
'Redesigning...', 'Redeveloping...', 'Redialing...', 'Redirecting...', 'Rediscovering...', 'Redistributing...', 'Redoing...', 'Redoubling...',
|
|
548
|
+
'Redrafting...', 'Reducing...', 'Reeling...', 'Refactoring...', 'Referencing...', 'Referring...', 'Refilling...', 'Refinancing...',
|
|
549
|
+
'Refining...', 'Refitting...', 'Reflecting...', 'Reformatting...', 'Reforming...', 'Refracting...', 'Refraining...', 'Refreshing...',
|
|
550
|
+
'Refueling...', 'Refunding...', 'Refurbishing...', 'Refusing...', 'Refuting...', 'Regaining...', 'Regaling...', 'Regarding...',
|
|
551
|
+
'Regenerating...', 'Registering...', 'Regressing...', 'Regretting...', 'Regrouping...', 'Regulating...', 'Rehabilitating...', 'Rehearsing...',
|
|
552
|
+
'Reigning...', 'Reimbursing...', 'Reinforcing...', 'Reining...', 'Reinstalling...', 'Reinstating...', 'Reintegrating...', 'Reinterpreting...',
|
|
553
|
+
'Reintroducing...', 'Reinventing...', 'Reinvesting...', 'Reissuing...', 'Reiterating...', 'Rejecting...', 'Rejoicing...', 'Rejoining...',
|
|
554
|
+
'Rejuvenating...', 'Rekindling...', 'Relating...', 'Relaxing...', 'Relaying...', 'Releasing...', 'Relegating...', 'Relenting...',
|
|
555
|
+
'Relieving...', 'Relighting...', 'Relishing...', 'Reliving...', 'Reloading...', 'Relocating...', 'Relying...', 'Remaining...',
|
|
556
|
+
'Remanding...', 'Remarking...', 'Remarrying...', 'Remastering...', 'Remedying...', 'Remembering...', 'Reminding...', 'Reminiscing...',
|
|
557
|
+
'Remitting...', 'Remodeling...', 'Remolding...', 'Remonstrating...', 'Removing...', 'Rendering...', 'Rendezvousing...', 'Reneging...',
|
|
558
|
+
'Renegotiating...', 'Renewing...', 'Renouncing...', 'Renovating...', 'Renting...', 'Reopening...', 'Reorganizing...', 'Repacking...',
|
|
559
|
+
'Repairing...', 'Repaying...', 'Repealing...', 'Repeating...', 'Repelling...', 'Repenting...', 'Repercussing...', 'Replacing...',
|
|
560
|
+
'Replaying...', 'Replenishing...', 'Replicating...', 'Replying...', 'Repointing...', 'Reporting...', 'Reposing...', 'Repositioning...',
|
|
561
|
+
'Representing...', 'Repressing...', 'Reprimanding...', 'Reprinting...', 'Reproaching...', 'Reprocessing...', 'Reproducing...', 'Reprogramming...',
|
|
562
|
+
'Reproving...', 'Repulsing...', 'Repurposing...', 'Requesting...', 'Requiring...', 'Requisitioning...', 'Requiting...', 'Rescinding...',
|
|
563
|
+
'Rescuing...', 'Researching...', 'Reselling...', 'Resembling...', 'Resenting...', 'Reserving...', 'Resetting...', 'Resettling...',
|
|
564
|
+
'Reshaping...', 'Residing...', 'Resigning...', 'Resisting...', 'Reskilling...', 'Resoling...', 'Resolving...', 'Resonating...',
|
|
565
|
+
'Resorting...', 'Resounding...', 'Respecting...', 'Respiring...', 'Responding...', 'Restarting...', 'Restating...', 'Resting...',
|
|
566
|
+
'Restocking...', 'Restoring...', 'Restraining...', 'Restricting...', 'Restructuring...', 'Resulting...', 'Resuming...', 'Resurfacing...',
|
|
567
|
+
'Resurging...', 'Resurrecting...', 'Retailing...', 'Retaining...', 'Retaliating...', 'Retelling...', 'Rethinking...', 'Retiring...',
|
|
568
|
+
'Retorting...', 'Retouching...', 'Retracing...', 'Retracting...', 'Retraining...', 'Retranslating...', 'Retreating...', 'Retrieving...',
|
|
569
|
+
'Retrofitting...', 'Retrying...', 'Returning...', 'Reunifying...', 'Reuniting...', 'Reusing...', 'Revealing...', 'Reveling...',
|
|
570
|
+
'Revenging...', 'Reverberating...', 'Revering...', 'Reversing...', 'Reverting...', 'Reviewing...', 'Reviling...', 'Revising...',
|
|
571
|
+
'Revisiting...', 'Revitalizing...', 'Reviving...', 'Revoking...', 'Revolting...', 'Revolutionizing...', 'Revolving...', 'Rewarding...',
|
|
572
|
+
'Rewiring...', 'Rewording...', 'Reworking...', 'Rewriting...', 'Rhapsodizing...', 'Rhyming...', 'Ribbing...', 'Ridding...',
|
|
573
|
+
'Riding...', 'Riffling...', 'Rifling...', 'Rigging...', 'Righting...', 'Rimming...', 'Ringing...', 'Rinsing...',
|
|
574
|
+
'Rioting...', 'Ripping...', 'Rippling...', 'Rising...', 'Risking...', 'Rivaling...', 'Riveting...', 'Roaming...',
|
|
575
|
+
'Roaring...', 'Roasting...', 'Robbing...', 'Robing...', 'Rocking...', 'Rolling...', 'Romancing...', 'Romanticizing...',
|
|
576
|
+
'Roofing...', 'Rooming...', 'Rooting...', 'Roping...', 'Rorting...', 'Rostering...', 'Rotating...', 'Roting...',
|
|
577
|
+
'Roughening...', 'Rounding...', 'Rousing...', 'Rousting...', 'Roving...', 'Rowing...', 'Rubbing...', 'Ruining...',
|
|
578
|
+
'Ruling...', 'Rumbling...', 'Ruminating...', 'Rummaging...', 'Running...', 'Ruptioning...', 'Rushing...', 'Rusticating...',
|
|
579
|
+
'Rustling...', 'Reticulating...',
|
|
295
580
|
];
|
|
296
581
|
|
|
297
582
|
// ---- ANSI helpers ----
|
|
@@ -301,6 +586,39 @@ function stripAnsi(s) {
|
|
|
301
586
|
.replace(/\x1b\[[0-9;?]* [a-zA-Z]/g, '')
|
|
302
587
|
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
|
|
303
588
|
}
|
|
589
|
+
|
|
590
|
+
// Make UNTRUSTED text safe to paint: file contents from Read, command output from
|
|
591
|
+
// Bash, diff bodies, tool arguments.
|
|
592
|
+
//
|
|
593
|
+
// The goal is to keep the content as ORIGINAL as possible while stopping the
|
|
594
|
+
// terminal from OBEYING it. Only the ESC byte is rewritten — every following byte
|
|
595
|
+
// (the `[31m`, the `[10;20H` parameters) is shown verbatim, so nothing is lost:
|
|
596
|
+
//
|
|
597
|
+
// file contains: ESC [ 3 1 m E R R O R ESC [ 0 m
|
|
598
|
+
// screen shows : ^[ [ 3 1 m E R R O R ^[ [ 0 m
|
|
599
|
+
//
|
|
600
|
+
// A terminal cannot act on a sequence whose ESC has been split into `^[`, so:
|
|
601
|
+
// * `ESC[31m` no longer recolours half the transcript
|
|
602
|
+
// * `ESC[0m` no longer resets the row's styling mid-line
|
|
603
|
+
// * `ESC[10;20H` / `ESC[2J` no longer move the cursor or clear the screen
|
|
604
|
+
//
|
|
605
|
+
// `^[` is the same convention `cat -v`, `less` and `bat` use for ESC, so it reads
|
|
606
|
+
// as "there is an escape here" rather than as content. Tab is kept (the renderer
|
|
607
|
+
// expands it) and newlines are the caller's business; only the dangerous C0
|
|
608
|
+
// controls are defanged, and they are shown as their caret form so they remain
|
|
609
|
+
// visible instead of silently vanishing.
|
|
610
|
+
export function sanitizeText(s) {
|
|
611
|
+
return String(s == null ? '' : s)
|
|
612
|
+
// ESC -> "^[" . The rest of the sequence is untouched.
|
|
613
|
+
.replace(/\x1b/g, '^[')
|
|
614
|
+
// A bare BEL/BS/VT/FF would still be interpreted by some terminals.
|
|
615
|
+
.replace(/\x07/g, '^G')
|
|
616
|
+
.replace(/\x08/g, '^H')
|
|
617
|
+
.replace(/[\x0b\x0c]/g, ' ')
|
|
618
|
+
// Drop NUL and the remaining C0 controls (they have no visible form).
|
|
619
|
+
.replace(/[\x00-\x06\x0e-\x1f\x7f]/g, '');
|
|
620
|
+
}
|
|
621
|
+
|
|
304
622
|
function visualCol(s) { return visualWidth(stripAnsi(String(s))); }
|
|
305
623
|
function col(text, c) { return c + String(text) + C.reset; }
|
|
306
624
|
|
|
@@ -787,7 +1105,12 @@ function keyArgument(name, args, workspace) {
|
|
|
787
1105
|
for (const k of keys) {
|
|
788
1106
|
const v = args[k];
|
|
789
1107
|
if (typeof v !== 'string' || !v.length) continue;
|
|
790
|
-
|
|
1108
|
+
// Neutralise escapes BEFORE measuring: sanitizeText turns ESC into the two
|
|
1109
|
+
// visible characters `^[`, so truncating first and sanitizing after made a
|
|
1110
|
+
// long command (a `node -e "…"` one-liner is the usual case) come out `+1`
|
|
1111
|
+
// column wider than MAX_ARG — and a trailing ESC cut at the boundary left the
|
|
1112
|
+
// row ending in a bare `^` instead of a proper `…`.
|
|
1113
|
+
let text = sanitizeText(v.split('\n')[0]);
|
|
791
1114
|
// Make absolute paths under the workspace relative (shorter + familiar).
|
|
792
1115
|
if ((k === 'path' || k === 'file_path') && path.isAbsolute(text) && workspace) {
|
|
793
1116
|
const rel = path.relative(workspace, text);
|
|
@@ -807,7 +1130,7 @@ function keyArgument(name, args, workspace) {
|
|
|
807
1130
|
// "● Using Read (src/tui.js)" while running, "● Used Read (src/tui.js)" when done.
|
|
808
1131
|
// The verb is plain (white) / green once finished; the tool NAME is always the
|
|
809
1132
|
// theme (cyan) colour; the key argument is dim/gray.
|
|
810
|
-
export function formatToolLine(msg, workspace, spin) {
|
|
1133
|
+
export function formatToolLine(msg, workspace, spin, pulseStart) {
|
|
811
1134
|
const name = msg.toolName || '';
|
|
812
1135
|
const arg = keyArgument(name, msg.toolArgs, workspace);
|
|
813
1136
|
const verb = msg.pending ? 'Using' : 'Used';
|
|
@@ -822,15 +1145,27 @@ export function formatToolLine(msg, workspace, spin) {
|
|
|
822
1145
|
const SWEEP = 6; // 0.5s
|
|
823
1146
|
const HALF = SWEEP + SWEEP; // 12 ticks per colour pair (sweep + sweep back)
|
|
824
1147
|
const CYCLE = HALF * 2; // 24 ticks for the full loop (~2s)
|
|
825
|
-
|
|
1148
|
+
// Phase is measured from the tick the pulse BEGAN (state.pulseStart), not
|
|
1149
|
+
// from 0: the spinner has been running since turn start, so a raw
|
|
1150
|
+
// `spin % CYCLE` dropped the pulse into a mid-cycle colour (often the red
|
|
1151
|
+
// half) the instant the start animation handed over.
|
|
1152
|
+
const inCycle = ((spin - (pulseStart || spin)) % CYCLE + CYCLE) % CYCLE;
|
|
826
1153
|
const inHalf = inCycle % HALF; // 0..23 within this colour pair's half
|
|
827
1154
|
const phase = inCycle < HALF ? 0 : 1; // 0 = l↔c, 1 = c↔b
|
|
828
1155
|
|
|
829
|
-
// Colours as plain RGB triples (mirroring Working's orange→yellow/red pattern)
|
|
830
|
-
//
|
|
1156
|
+
// Colours as plain RGB triples (mirroring Working's orange→yellow/red pattern).
|
|
1157
|
+
// The orange pulse swings HARD — yellow is +100G/+120B off the base and red is
|
|
1158
|
+
// -140G — but this sweep used to move only +64R / -35G, i.e. about a QUARTER of
|
|
1159
|
+
// that amplitude, so "pale cyan" and "cyan-blue" were both nearly
|
|
1160
|
+
// indistinguishable from plain cyan. Match the orange pulse's swing:
|
|
1161
|
+
// pale cyan = base + a strong red lift (toward white)
|
|
1162
|
+
// cyan-blue = base with the green pulled DOWN but not away: green 90 read as
|
|
1163
|
+
// plain blue and clashed with the cyan base, so it is kept at 150
|
|
1164
|
+
// — still clearly darker/cooler than the base, still cyan-family
|
|
1165
|
+
// so each phase is a DIFFERENT colour, not a shade of the same one.
|
|
831
1166
|
const c = [0, 215, 255]; // pure cyan (like ORANGE base)
|
|
832
|
-
const l = [
|
|
833
|
-
const b = [0,
|
|
1167
|
+
const l = [150, 255, 255]; // pale cyan (like YELLOW: base + strong R/G lift)
|
|
1168
|
+
const b = [0, 150, 255]; // cyan-blue (like RED: base with green pulled down)
|
|
834
1169
|
const base = c; // always cyan, like Working's constant ORANGE
|
|
835
1170
|
const target = phase === 0 ? l : b; // pale cyan, then cyan-blue
|
|
836
1171
|
|
|
@@ -883,11 +1218,13 @@ export function formatToolLine(msg, workspace, spin) {
|
|
|
883
1218
|
: bytes >= 1024 ? ` ${(bytes / 1024).toFixed(1)}KB`
|
|
884
1219
|
: ` ${bytes}B`;
|
|
885
1220
|
}
|
|
886
|
-
// Show +xx -xx diff counts for Edit tool when done
|
|
1221
|
+
// Show +xx -xx diff counts for Edit tool when done. The diff itself is rendered
|
|
1222
|
+
// under the `↳` receipt, so prefer the counts the tool_result handler copied over.
|
|
887
1223
|
let diffStr = '';
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
const
|
|
1224
|
+
const edDiff = (Array.isArray(msg.diff) && msg.diff.length) ? msg.diff : msg._diffCounts;
|
|
1225
|
+
if (name === 'Edit' && !msg.pending && Array.isArray(edDiff) && edDiff.length) {
|
|
1226
|
+
const adds = edDiff.filter((d) => d.type === 'add').length;
|
|
1227
|
+
const deletes = edDiff.filter((d) => d.type === 'del').length;
|
|
891
1228
|
if (adds || deletes) {
|
|
892
1229
|
diffStr = ' ' + (adds ? col(`+${adds}`, C.green) : '') + ' ' + (deletes ? col(`-${deletes}`, C.red) : '');
|
|
893
1230
|
}
|
|
@@ -1066,11 +1403,11 @@ export function todoPanelHeight(state) {
|
|
|
1066
1403
|
|
|
1067
1404
|
// Render one chat message into display lines.
|
|
1068
1405
|
// Returns { lines: [{text, ind, color}] } (ind = indent string for continuation).
|
|
1069
|
-
function messageLines(msg, width, workspace, expanded, spin) {
|
|
1406
|
+
function messageLines(msg, width, workspace, expanded, spin, pulseStart) {
|
|
1070
1407
|
// Tool calls render as a single "● Using/Used Name (arg)" line (kimi style).
|
|
1071
1408
|
// The body already carries its own ANSI colours, so mark it pre-colored.
|
|
1072
1409
|
if (msg.role === 'tool') {
|
|
1073
|
-
const body = formatToolLine(msg, workspace, spin);
|
|
1410
|
+
const body = formatToolLine(msg, workspace, spin, pulseStart);
|
|
1074
1411
|
// Red bullet for a failed call (a Bash non-zero exit / [error: …]).
|
|
1075
1412
|
const failed = msg.failed === true || (msg.role === 'tool' && msg.failed === true);
|
|
1076
1413
|
const pre = col('● ', msg.pending ? C.orange : (failed ? C.red : C.green));
|
|
@@ -1085,12 +1422,27 @@ function messageLines(msg, width, workspace, expanded, spin) {
|
|
|
1085
1422
|
const indent = ' ';
|
|
1086
1423
|
const avail = Math.max(1, width - visualCol(indent));
|
|
1087
1424
|
|
|
1425
|
+
// AgentSwarm renders kimi's live progress BLOCK instead of a `↳` output dump:
|
|
1426
|
+
// a header, one cell per subagent, and a status pip bar. The members live on
|
|
1427
|
+
// the message (see the tool_use handler, which registers them as soon as the
|
|
1428
|
+
// args arrive) and update in place while the swarm runs.
|
|
1429
|
+
if (name === 'AgentSwarm' && Array.isArray(msg.swarmMembers) && msg.swarmMembers.length) {
|
|
1430
|
+
const block = renderSwarmProgress({
|
|
1431
|
+
description: msg.swarmDescription || '',
|
|
1432
|
+
model: msg.swarmModel || '',
|
|
1433
|
+
members: msg.swarmMembers,
|
|
1434
|
+
failed: msg.swarmFailed === true,
|
|
1435
|
+
}, width, { indent: ' ', availableGridHeight: 8 });
|
|
1436
|
+
for (const line of block) out.push({ text: line, ind: '', raw: true });
|
|
1437
|
+
return out;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1088
1440
|
// Live output of a RUNNING command (Bash): rendered under the "Using …"
|
|
1089
1441
|
// row exactly like the finished "Used …" output, just not done yet. Only
|
|
1090
1442
|
// the tail is shown (capped) so a chatty command cannot flood the frame.
|
|
1091
1443
|
if (msg.pending && typeof msg.liveOutput === 'string' && msg.liveOutput.length) {
|
|
1092
1444
|
const bodyW = Math.max(1, width - visualCol(' '));
|
|
1093
|
-
const rawLines = msg.liveOutput.replace(/\r\n/g, '\n').split('\n');
|
|
1445
|
+
const rawLines = sanitizeText(msg.liveOutput).replace(/\r\n/g, '\n').split('\n');
|
|
1094
1446
|
const MAX_RUN_LINES = expanded ? Infinity : 8;
|
|
1095
1447
|
const shown = rawLines.slice(-MAX_RUN_LINES); // running: show the newest output
|
|
1096
1448
|
if (rawLines.length > MAX_RUN_LINES) {
|
|
@@ -1103,35 +1455,75 @@ function messageLines(msg, width, workspace, expanded, spin) {
|
|
|
1103
1455
|
}
|
|
1104
1456
|
}
|
|
1105
1457
|
|
|
1458
|
+
// Ctrl+B detach hint (mirrors kimi-code). A foreground Bash/Agent call that
|
|
1459
|
+
// has been running a while advertises the shortcut that moves it to the
|
|
1460
|
+
// background; the hint disappears the moment the result lands. Bash waits
|
|
1461
|
+
// DETACH_HINT_DELAY_MS so short commands never flash it; Agent-like tools
|
|
1462
|
+
// announce it immediately since they are long-running by nature.
|
|
1463
|
+
if (msg.pending && (name === 'Bash' || name === 'Agent' || name === 'AgentSwarm') && msg.startAt) {
|
|
1464
|
+
const waited = Date.now() - msg.startAt;
|
|
1465
|
+
const delay = name === 'Bash' ? DETACH_HINT_DELAY_MS : 0;
|
|
1466
|
+
if (waited >= delay) {
|
|
1467
|
+
out.push({ text: col(DETACH_HINT_TEXT, C.gray), ind: indent, raw: true });
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1106
1471
|
// Write: stream the file content while the model is still emitting it.
|
|
1107
1472
|
if (name === 'Write' && typeof msg.streamContent === 'string' && msg.streamContent.length) {
|
|
1108
|
-
const lines = msg.streamContent.replace(/\r\n/g, '\n').split('\n');
|
|
1473
|
+
const lines = sanitizeText(msg.streamContent).replace(/\r\n/g, '\n').split('\n');
|
|
1109
1474
|
const MAX = 20;
|
|
1110
1475
|
const wNum = String(lines.length).length;
|
|
1111
1476
|
lines.slice(0, MAX).forEach((ln, i) => {
|
|
1112
|
-
out.push({ text: col(`${String(i + 1).padStart(wNum)} ${ln}`, C.gray), ind: indent, raw: true });
|
|
1477
|
+
out.push({ text: col(`${String(i + 1).padStart(wNum)} ${ln}`, C.gray), ind: i === 0 ? '↳ ' : indent, raw: true });
|
|
1113
1478
|
});
|
|
1114
1479
|
if (lines.length > MAX) out.push({ text: col(`… ${lines.length - MAX} more lines`, C.gray), ind: indent, raw: true });
|
|
1115
1480
|
}
|
|
1116
1481
|
|
|
1117
|
-
// Edit:
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1482
|
+
// The Edit diff is NOT drawn here: it rides on the tool_result message and is
|
|
1483
|
+
// rendered BELOW the `↳` receipt (see the tool_result branch). Drawing it here
|
|
1484
|
+
// put it between the `● Used Edit` status bullet and the receipt.
|
|
1485
|
+
return out;
|
|
1486
|
+
}
|
|
1487
|
+
// A PLAN block (Plan mode): the model's <plan>…</plan> body, rendered as a
|
|
1488
|
+
// single-column TABLE — the same ╭─┬─╮ / ├─┼─┤ / ╰─┴─╯ vocabulary the Markdown
|
|
1489
|
+
// table renderer uses, so a plan reads as structured data rather than as prose
|
|
1490
|
+
// or as a second composer box.
|
|
1491
|
+
if (msg.role === 'plan') {
|
|
1492
|
+
// `width` is the transcript's inner width (the caller already removed the
|
|
1493
|
+
// one-column margin); the wrapped user boxes land at `width - 2`.
|
|
1494
|
+
const tableW = Math.max(12, width - 2);
|
|
1495
|
+
const inner = Math.max(1, tableW - 2); // between the two │
|
|
1496
|
+
// NOTE: rows must be {text, ind, raw} OBJECTS — messageLines' contract.
|
|
1497
|
+
// Pushing plain strings here made rowToLine read `undefined` and the whole
|
|
1498
|
+
// block rendered as blank lines.
|
|
1499
|
+
const out = [];
|
|
1500
|
+
const bar = (ch) => col(ch, C.border);
|
|
1501
|
+
const full = (l, r) => bar(l + '─'.repeat(inner) + r);
|
|
1502
|
+
// Header: the table's single column is captioned "Plan".
|
|
1503
|
+
out.push({ text: full('╭', '╮'), ind: '', raw: true });
|
|
1504
|
+
out.push({ text: bar('│') + fitAnsi(col(' Plan', C.cyan + C.bold), inner) + bar('│'), ind: '', raw: true });
|
|
1505
|
+
out.push({ text: bar('├' + '─'.repeat(inner) + '┤'), ind: '', raw: true });
|
|
1506
|
+
// Body rows.
|
|
1507
|
+
const lines = String(msg.text || '').replace(/\r\n/g, '\n').split('\n');
|
|
1508
|
+
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
|
1509
|
+
const textW = Math.max(1, inner - 2);
|
|
1510
|
+
for (const raw of lines) {
|
|
1511
|
+
if (raw.trim() === '') {
|
|
1512
|
+
out.push({ text: bar('│') + ' '.repeat(inner) + bar('│'), ind: '', raw: true });
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
// Headings are emphasised; everything else renders as Markdown body text so
|
|
1516
|
+
// **bold**, `code` and bullets work.
|
|
1517
|
+
const isHead = /^\s*#{1,6}\s/.test(raw);
|
|
1518
|
+
const shown = isHead ? raw.replace(/^\s*#{1,6}\s*/, '') : raw;
|
|
1519
|
+
for (const r of renderMdText(shown, textW, isHead ? (C.cyan + C.bold) : C.white, C.cyan)) {
|
|
1520
|
+
out.push({ text: bar('│') + ' ' + fitAnsi(r, textW) + ' ' + bar('│'), ind: '', raw: true });
|
|
1129
1521
|
}
|
|
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
1522
|
}
|
|
1523
|
+
out.push({ text: full('╰', '╯'), ind: '', raw: true });
|
|
1133
1524
|
return out;
|
|
1134
1525
|
}
|
|
1526
|
+
|
|
1135
1527
|
// Tool output: a single "↳ " marker on the first line, then the output
|
|
1136
1528
|
// indented by 2 columns. Long output is capped (kimi caps result previews
|
|
1137
1529
|
// too) so one command cannot flood the whole transcript.
|
|
@@ -1143,7 +1535,11 @@ function messageLines(msg, width, workspace, expanded, spin) {
|
|
|
1143
1535
|
const pre = '↳ ';
|
|
1144
1536
|
const cont = ' ';
|
|
1145
1537
|
const bodyW = Math.max(1, width - visualCol(cont));
|
|
1146
|
-
|
|
1538
|
+
// Strip escapes BEFORE splitting: a file or a command can emit raw SGR /
|
|
1539
|
+
// cursor codes, and letting them through made the terminal OBEY them —
|
|
1540
|
+
// recolouring the transcript, resetting styles mid-row, moving the cursor or
|
|
1541
|
+
// clearing the screen over the composer.
|
|
1542
|
+
const rawLines = sanitizeText(msg.text).replace(/\r\n/g, '\n').split('\n');
|
|
1147
1543
|
// Trim blank lines at BOTH ends: commands typically emit a trailing
|
|
1148
1544
|
// newline, and some tools emit a leading one. A stray blank line would
|
|
1149
1545
|
// render as a lone "↳ " with no content.
|
|
@@ -1157,18 +1553,71 @@ function messageLines(msg, width, workspace, expanded, spin) {
|
|
|
1157
1553
|
const visible = rawLines.filter((ln) => !/^\[exit code:/.test(ln.trim()));
|
|
1158
1554
|
const failed = msg.failed === true;
|
|
1159
1555
|
const shown = visible.slice(0, MAX_RESULT_LINES);
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1556
|
+
|
|
1557
|
+
// ---- DISPLAY ORDER -----------------------------------------------------
|
|
1558
|
+
// The row reads as a block under the tool line, with ONE `↳` marker:
|
|
1559
|
+
// ● Used Edit (a.js) +1 -1 <- status bullet
|
|
1560
|
+
// ↳ 3 - old line <- `↳` rides the FIRST body row
|
|
1561
|
+
// 3 + new line
|
|
1562
|
+
// Edited a.js: replaced 1 occurrence(s). <- receipt LAST
|
|
1563
|
+
// For an Edit the DIFF is the body (and carries the marker); the receipt text
|
|
1564
|
+
// moves to the bottom. For every other tool the tool output IS the body, so
|
|
1565
|
+
// the marker sits on its first line, exactly as before.
|
|
1566
|
+
const diffRows = [];
|
|
1567
|
+
const receiptRows = [];
|
|
1568
|
+
const hasDiff = Array.isArray(msg.diff); // empty array => receipt-only (Write)
|
|
1569
|
+
|
|
1570
|
+
if (hasDiff) {
|
|
1571
|
+
// Same collapse rule as the tool output and thinking blocks above: a short
|
|
1572
|
+
// preview by default, everything once Ctrl+O expands. 16 rows (not 12)
|
|
1573
|
+
// because a change renders as a PAIR: `- old` then `+ new`.
|
|
1574
|
+
const MAX = expanded ? Infinity : 16;
|
|
1575
|
+
const changed = msg.diff.filter((d) => d.type !== 'ctx');
|
|
1576
|
+
const wNo = Math.max(1, ...changed.map((d) => String(d.no || 0).length));
|
|
1577
|
+
for (const d of changed.slice(0, MAX)) {
|
|
1578
|
+
const no = String(d.no || 0).padStart(wNo);
|
|
1579
|
+
const mark = d.type === 'add' ? '+' : '-';
|
|
1580
|
+
const color = d.type === 'add' ? C.green : C.red;
|
|
1581
|
+
// Diff lines are FILE content and can carry escapes; show them as `^[`.
|
|
1582
|
+
diffRows.push({ text: col(`${no} ${mark} ${sanitizeText(d.text)}`, color), ind: cont, raw: true });
|
|
1583
|
+
}
|
|
1584
|
+
if (changed.length > MAX) {
|
|
1585
|
+
diffRows.push({
|
|
1586
|
+
text: col(`… (${changed.length - MAX} more changed lines, ctrl+o to expand)`, C.gray),
|
|
1587
|
+
ind: cont, raw: true,
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
if (msg.diff.length > 0 && changed.length === 0) diffRows.push({ text: col('(no changes)', C.gray), ind: cont, raw: true });
|
|
1591
|
+
// The receipt: one line per source line of the tool's own message.
|
|
1592
|
+
for (const ln of shown) {
|
|
1593
|
+
const color = failed ? C.red : C.gray;
|
|
1594
|
+
for (const t of (ln === '' ? [''] : wrapWords(ln, bodyW))) {
|
|
1595
|
+
receiptRows.push({ text: t, ind: cont, color });
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
} else {
|
|
1599
|
+
// No diff: the tool's own output is the body.
|
|
1600
|
+
shown.forEach((ln, idx) => {
|
|
1601
|
+
const isErr = /^\[error:/.test(ln.trim());
|
|
1602
|
+
// A failed run is red throughout so it cannot be skimmed past.
|
|
1603
|
+
const color = isErr ? C.red : (failed ? C.red : C.gray);
|
|
1604
|
+
const wrapped = ln === '' ? [''] : wrapWords(ln, bodyW);
|
|
1605
|
+
wrapped.forEach((t, i) => {
|
|
1606
|
+
diffRows.push({ text: t, ind: idx === 0 && i === 0 ? pre : cont, color });
|
|
1607
|
+
});
|
|
1168
1608
|
});
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1609
|
+
if (visible.length > MAX_RESULT_LINES) {
|
|
1610
|
+
diffRows.push({ text: `… ${visible.length - MAX_RESULT_LINES} more lines`, ind: cont, color: failed ? C.red : C.gray });
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
const out = [];
|
|
1615
|
+
for (const r of diffRows) out.push(r);
|
|
1616
|
+
for (const r of receiptRows) out.push(r);
|
|
1617
|
+
// Put the `↳` on the FIRST rendered row (diff or output) — never on the
|
|
1618
|
+
// receipt, which now sits last.
|
|
1619
|
+
if (out.length && out[0].ind === cont && Array.isArray(msg.diff) && msg.diff.length > 0) {
|
|
1620
|
+
out[0] = { ...out[0], ind: pre };
|
|
1172
1621
|
}
|
|
1173
1622
|
// Nothing to show (empty output) -> render no row at all rather than a
|
|
1174
1623
|
// dangling "↳ ".
|
|
@@ -1189,15 +1638,23 @@ function messageLines(msg, width, workspace, expanded, spin) {
|
|
|
1189
1638
|
for (const w of wrapWords(ln, bodyW)) contentLines.push(w);
|
|
1190
1639
|
}
|
|
1191
1640
|
const PREVIEW = expanded ? Infinity : 2;
|
|
1641
|
+
// The reasoning BODY is italic; the "thinking…" label and the "N more lines"
|
|
1642
|
+
// hint are NOT. Closed with endItalic rather than reset, so the row keeps the
|
|
1643
|
+
// dim gray it is drawn in.
|
|
1644
|
+
const it = (s) => (s === '' ? s : C.italic + s + C.endItalic);
|
|
1192
1645
|
const out = [];
|
|
1193
1646
|
if (msg.pending) {
|
|
1194
|
-
|
|
1195
|
-
|
|
1647
|
+
// The spinner frame is NOT baked in here: a glyph that changes every 80ms
|
|
1648
|
+
// tick would invalidate this row's cache and re-wrap the whole reasoning
|
|
1649
|
+
// block each time (measured ~44ms/frame vs ~6ms warm), which is what made
|
|
1650
|
+
// long thinking look laggy while ordinary streamed text stayed smooth.
|
|
1651
|
+
// renderChatLines substitutes the live frame outside the cache instead.
|
|
1652
|
+
out.push({ text: SPIN_PLACEHOLDER + ' thinking…', ind: '', color: C.gray });
|
|
1196
1653
|
const vis = contentLines.length > PREVIEW ? contentLines.slice(-PREVIEW) : contentLines;
|
|
1197
|
-
for (const l of vis) out.push({ text: l, ind: INDENT, color: C.gray });
|
|
1654
|
+
for (const l of vis) out.push({ text: it(l), ind: INDENT, color: C.gray });
|
|
1198
1655
|
} else {
|
|
1199
1656
|
const shown = contentLines.slice(0, PREVIEW);
|
|
1200
|
-
shown.forEach((l, i) => out.push({ text: l, ind: i === 0 ? '● ' : INDENT, color: C.gray }));
|
|
1657
|
+
shown.forEach((l, i) => out.push({ text: it(l), ind: i === 0 ? '● ' : INDENT, color: C.gray }));
|
|
1201
1658
|
if (contentLines.length > PREVIEW) {
|
|
1202
1659
|
out.push({ text: `… (${contentLines.length - PREVIEW} more lines, ctrl+o to expand)`, ind: INDENT, color: C.gray });
|
|
1203
1660
|
}
|
|
@@ -1206,6 +1663,7 @@ function messageLines(msg, width, workspace, expanded, spin) {
|
|
|
1206
1663
|
return out;
|
|
1207
1664
|
}
|
|
1208
1665
|
|
|
1666
|
+
|
|
1209
1667
|
let icon = '';
|
|
1210
1668
|
switch (msg.role) {
|
|
1211
1669
|
case 'system': icon = ''; break;
|
|
@@ -1555,24 +2013,35 @@ function renderChatLines(state, w) {
|
|
|
1555
2013
|
// The row WIDTH is keyed (not `w`) because the rows are laid out to it:
|
|
1556
2014
|
// `boxText` for a bordered message (inside the box padding), `innerW` otherwise.
|
|
1557
2015
|
const rowW = bordered ? boxText : innerW;
|
|
1558
|
-
|
|
1559
|
-
|
|
2016
|
+
// The cache key MUST include everything messageLines() reads. `spin` is
|
|
2017
|
+
// keyed ONLY for a pending TOOL row: formatToolLine sweeps the tool name's
|
|
2018
|
+
// colour with it. The pending THINKING row deliberately does not key it —
|
|
2019
|
+
// its spinner is substituted outside the cache (see SPIN_PLACEHOLDER), so
|
|
2020
|
+
// the 80ms tick no longer re-wraps the reasoning preview every frame.
|
|
2021
|
+
const spinKey = (msg.role === 'tool' && msg.pending) ? (state.spin || 0) : 0;
|
|
2022
|
+
const key = `${rowW}\u0000${expanded ? 1 : 0}\u0000${spinKey}\u0000${msg.text || ''}\u0000${msg.streamContent || ''}`
|
|
2023
|
+
+ `\u0000${msg.liveOutput ? msg.liveOutput.length : 0}\u0000${msg.failed ? 1 : 0}\u0000${JSON.stringify(msg.toolArgs || null)}`
|
|
2024
|
+
+ `\u0000${msg.diff ? msg.diff.length : 0}\u0000${msg._diffCounts ? msg._diffCounts.length : 0}`;
|
|
1560
2025
|
let cached = msg._cache;
|
|
1561
2026
|
if (!cached || cached.key !== key) {
|
|
1562
|
-
const rows = messageLines(msg, rowW, state.cwd, expanded, state.spin);
|
|
2027
|
+
const rows = messageLines(msg, rowW, state.cwd, expanded, state.spin, state.pulseStart);
|
|
1563
2028
|
cached = { key, rows: rows.map(rowToLine) };
|
|
1564
2029
|
msg._cache = cached;
|
|
1565
2030
|
}
|
|
2031
|
+
// Swap the live spinner frame into any row that carries the placeholder.
|
|
2032
|
+
// Done AFTER the cache, so the glyph animates without invalidating it.
|
|
2033
|
+
const spinFrame = SPINNER[(state.spin || 0) % SPINNER.length];
|
|
2034
|
+
const live = (r) => (r.indexOf(SPIN_PLACEHOLDER) >= 0 ? r.split(SPIN_PLACEHOLDER).join(spinFrame) : r);
|
|
1566
2035
|
// The margin is added here (outside the cache) so it is not baked into the
|
|
1567
2036
|
// cached rows. A bordered message also gets its side walls, laid out to the
|
|
1568
2037
|
// box's inner width so it lines up with the composer exactly.
|
|
1569
2038
|
if (bordered) {
|
|
1570
2039
|
const pad = ' '.repeat(boxPad);
|
|
1571
2040
|
for (const r of cached.rows) {
|
|
1572
|
-
out.push(PAD + col('│', C.border) + pad + fitAnsi(r, boxText) + pad + col('│', C.border));
|
|
2041
|
+
out.push(PAD + col('│', C.border) + pad + fitAnsi(live(r), boxText) + pad + col('│', C.border));
|
|
1573
2042
|
}
|
|
1574
2043
|
} else {
|
|
1575
|
-
for (const r of cached.rows) out.push(PAD + r);
|
|
2044
|
+
for (const r of cached.rows) out.push(PAD + live(r));
|
|
1576
2045
|
}
|
|
1577
2046
|
if (bordered && out[out.length - 1] !== botRule) out.push(botRule);
|
|
1578
2047
|
}
|
|
@@ -1593,8 +2062,10 @@ function lineToString(line) {
|
|
|
1593
2062
|
}
|
|
1594
2063
|
|
|
1595
2064
|
function modeLabel(mode) {
|
|
1596
|
-
|
|
1597
|
-
|
|
2065
|
+
// Short, uniform names — the long forms ("Always Ask", "Ask When Needed",
|
|
2066
|
+
// "Never Ask") made the status group wrap awkwardly next to Plan/Focus.
|
|
2067
|
+
if (mode === 'auto') return 'Auto';
|
|
2068
|
+
if (mode === 'yolo') return 'Yolo';
|
|
1598
2069
|
return 'Ask';
|
|
1599
2070
|
}
|
|
1600
2071
|
function trimDecimal(v) {
|
|
@@ -1626,7 +2097,10 @@ function usagePercent(used, max) {
|
|
|
1626
2097
|
}
|
|
1627
2098
|
|
|
1628
2099
|
// Human-readable elapsed duration, omitting zero leading units:
|
|
1629
|
-
//
|
|
2100
|
+
// 1y 1mon 1d 1h 1min 1s / 2y 2mon 2d 2h 2min 2s
|
|
2101
|
+
// Units are NEVER pluralised with a trailing "s": the old code rendered 5
|
|
2102
|
+
// minutes as "5ms", which reads as 5 MILLISECONDS (and 2 hours as "2hs").
|
|
2103
|
+
// Minutes are "m", so months use "mon" to stay unambiguous.
|
|
1630
2104
|
function fmtDuration(ms) {
|
|
1631
2105
|
ms = Math.max(0, Math.floor(ms));
|
|
1632
2106
|
const sec = Math.floor(ms / 1000) % 60;
|
|
@@ -1636,14 +2110,14 @@ function fmtDuration(ms) {
|
|
|
1636
2110
|
const mon = Math.floor(ms / 2592000000) % 12;
|
|
1637
2111
|
const year = Math.floor(ms / 31536000000);
|
|
1638
2112
|
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'
|
|
2113
|
+
if (year) parts.push(year + 'y');
|
|
2114
|
+
if (mon) parts.push(mon + 'mon');
|
|
2115
|
+
if (day) parts.push(day + 'd');
|
|
2116
|
+
if (hour) parts.push(hour + 'h');
|
|
2117
|
+
if (min) parts.push(min + 'm');
|
|
1644
2118
|
// Seconds are always shown (at least "0s").
|
|
1645
2119
|
parts.push(sec + 's');
|
|
1646
|
-
return parts.join('');
|
|
2120
|
+
return parts.join(' ');
|
|
1647
2121
|
}
|
|
1648
2122
|
|
|
1649
2123
|
// Left/right justify: left text, then padding, then right text flush to `w`.
|
|
@@ -1669,21 +2143,82 @@ export function composerHeight(state, cols) {
|
|
|
1669
2143
|
return composerLayout(state, insideW - 3).rows.length + 2;
|
|
1670
2144
|
}
|
|
1671
2145
|
|
|
2146
|
+
// UNCLAMPED height of the AskUserQuestion box. `composeFrame` caps the real
|
|
2147
|
+
// height to the space available (see `questionH`), and the renderer drops the
|
|
2148
|
+
// hint row / scrolls the option list to fit that cap. This is therefore the
|
|
2149
|
+
// height wanted when there IS room.
|
|
2150
|
+
// Rows: top border + header + wrapped question + one per row + hint/entry + bottom
|
|
2151
|
+
export function questionBoxHeight(state, insideW) {
|
|
2152
|
+
const qs = state.question;
|
|
2153
|
+
if (!qs) return 0;
|
|
2154
|
+
const cur = qs.items[qs.index] || { question: '', options: [] };
|
|
2155
|
+
const innerW = Math.max(1, insideW - 2);
|
|
2156
|
+
const isLast = qs.index === qs.items.length - 1;
|
|
2157
|
+
const optRows = (cur.options || []).length + 1 + (isLast ? 1 : 0); // + Other (+ supplement)
|
|
2158
|
+
const qRows = Math.max(1, wrapWords(cur.question, Math.max(1, innerW - 2)).length);
|
|
2159
|
+
return 2 + 1 + qRows + optRows + 1;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
// The height the box ACTUALLY emits, given the terminal height `h` and the rows
|
|
2163
|
+
// already taken by other chrome. Mirrors composeFrame's cap + the renderer's
|
|
2164
|
+
// fit logic, so geometry and painting cannot disagree.
|
|
2165
|
+
export function questionBoxClampedHeight(state, insideW, h, chromeH) {
|
|
2166
|
+
const want = questionBoxHeight(state, insideW);
|
|
2167
|
+
if (!want) return 0;
|
|
2168
|
+
const cap = Math.max(1, h - chromeH - 1);
|
|
2169
|
+
const q = Math.min(want, cap);
|
|
2170
|
+
const qs = state.question;
|
|
2171
|
+
const cur = qs.items[qs.index] || { question: '', options: [] };
|
|
2172
|
+
const innerW = Math.max(1, insideW - 2);
|
|
2173
|
+
const isLast = qs.index === qs.items.length - 1;
|
|
2174
|
+
const optRows = (cur.options || []).length + 1 + (isLast ? 1 : 0);
|
|
2175
|
+
const qRows = Math.max(1, wrapWords(cur.question, Math.max(1, innerW - 2)).length);
|
|
2176
|
+
const boxBudget = q - 2 - qRows; // room for options (+hint)
|
|
2177
|
+
const showHint = boxBudget >= optRows + 1;
|
|
2178
|
+
const optShown = Math.min(optRows, Math.max(0, boxBudget - (showHint ? 1 : 0)));
|
|
2179
|
+
return 2 + qRows + optShown + (showHint ? 1 : 0);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
1672
2182
|
// ---- scrollbar (Codewhale's TranscriptScrollbar) ---------------------------
|
|
1673
2183
|
// Geometry for a vertical scrollbar over the transcript body. `scroll` is the
|
|
1674
2184
|
// number of lines scrolled UP from the bottom (0 = pinned to newest). Returns
|
|
1675
2185
|
// the thumb's row range within the body (0-based) so the painter and the mouse
|
|
1676
2186
|
// hit-test share one source of truth.
|
|
2187
|
+
//
|
|
2188
|
+
// NOTE on precision: the terminal's mouse protocol reports INTEGER rows, so a
|
|
2189
|
+
// drag can only address `bodyH` distinct positions. On a long transcript that is
|
|
2190
|
+
// inherently coarse (100k lines over 24 rows = ~4.3k lines per row) and no
|
|
2191
|
+
// formula fixes it — fine control comes from the wheel / keyboard instead (see
|
|
2192
|
+
// the `sbFine` handling in handleKey). What this function DOES guarantee is that
|
|
2193
|
+
// the thumb's travel is monotonic, uses every row, and lands exactly on both
|
|
2194
|
+
// ends; `thumbLenF` is kept unrounded purely so the grab offset inside the thumb
|
|
2195
|
+
// is not quantised to a whole row when the thumb is 10+ rows tall.
|
|
1677
2196
|
export function scrollbarGeometry({ total, bodyH, scroll }) {
|
|
1678
2197
|
const maxScroll = Math.max(0, total - bodyH);
|
|
1679
2198
|
const pos = Math.min(maxScroll, Math.max(0, scroll || 0));
|
|
1680
2199
|
// Thumb length proportional to the visible fraction, at least 1 row.
|
|
1681
|
-
const
|
|
2200
|
+
const thumbLenF = Math.max(1, bodyH * (bodyH / Math.max(1, total)));
|
|
2201
|
+
const thumb = Math.max(1, Math.round(thumbLenF));
|
|
1682
2202
|
// 0 = bottom (pinned), maxScroll = top. Row 0 is the TOP of the body.
|
|
1683
2203
|
const fromTop = maxScroll === 0 ? 0 : (maxScroll - pos) / maxScroll;
|
|
1684
|
-
const
|
|
1685
|
-
|
|
1686
|
-
|
|
2204
|
+
const maxThumbStartF = Math.max(0, bodyH - thumbLenF);
|
|
2205
|
+
// Round once, at the end. Using `bodyH - thumb` (rounded) here made the last
|
|
2206
|
+
// row unreachable when the thumb was 1 row and bodyH was even.
|
|
2207
|
+
const thumbStart = Math.round(fromTop * maxThumbStartF);
|
|
2208
|
+
return { thumbStart, thumbLen: thumb, thumbLenF, maxThumbStartF, bodyH, total, maxScroll };
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
// Map a position on the track to a transcript scroll offset. `rowF` is the mouse
|
|
2212
|
+
// row (fractional allowed) and `grabOffset` is where inside the thumb the drag
|
|
2213
|
+
// started, in rows, so the thumb does not jump under the pointer on press.
|
|
2214
|
+
// Exported so the drag handler and its tests share ONE definition of the mapping.
|
|
2215
|
+
export function scrollFromThumbPos(g, rowF, grabOffset = 0) {
|
|
2216
|
+
if (!g) return 0;
|
|
2217
|
+
const track = Math.max(1e-6, g.maxThumbStartF != null
|
|
2218
|
+
? g.maxThumbStartF
|
|
2219
|
+
: Math.max(1, g.bodyH - (g.thumbLenF != null ? g.thumbLenF : g.thumbLen)));
|
|
2220
|
+
const frac = Math.max(0, Math.min(1, (rowF - grabOffset) / track));
|
|
2221
|
+
return Math.round((1 - frac) * g.maxScroll);
|
|
1687
2222
|
}
|
|
1688
2223
|
|
|
1689
2224
|
// Paint the scrollbar glyph into the LAST column of a body row. The row already
|
|
@@ -1834,9 +2369,28 @@ function sliceAnsi(s, width) {
|
|
|
1834
2369
|
export function composeFrame(state, cols, rows) {
|
|
1835
2370
|
const w = Math.max(20, cols | 0);
|
|
1836
2371
|
const h = Math.max(12, rows | 0);
|
|
2372
|
+
// Full-screen takeovers (kimi's screen-takeover): the /tasks browser and its
|
|
2373
|
+
// output viewer own the ENTIRE screen, so they short-circuit the normal
|
|
2374
|
+
// layout ? no chat, no composer, no status bar.
|
|
2375
|
+
if (state.tasksViewer) {
|
|
2376
|
+
const lines = renderTaskOutputViewer(state.tasksViewer, w, h);
|
|
2377
|
+
return { ansi: '', lines, cursor: { row: 0, col: 0 }, cursorVisible: false,
|
|
2378
|
+
width: w, height: h, cursorRow: 0, cursorCol: 0, cursorShape: hideCursor(), hitboxes: [], composerMeta: [] };
|
|
2379
|
+
}
|
|
2380
|
+
if (state.tasksPanel) {
|
|
2381
|
+
const lines = renderTasksBrowser(state.tasksPanel, w, h);
|
|
2382
|
+
return { ansi: '', lines, cursor: { row: 0, col: 0 }, cursorVisible: false,
|
|
2383
|
+
width: w, height: h, cursorRow: 0, cursorCol: 0, cursorShape: hideCursor(), hitboxes: [], composerMeta: [] };
|
|
2384
|
+
}
|
|
1837
2385
|
const insideW = Math.max(0, w - 2);
|
|
1838
2386
|
|
|
1839
|
-
|
|
2387
|
+
// A full-screen overlay: it takes the whole body and the composer is hidden.
|
|
2388
|
+
// `state.editor` MUST be in here — it renders into `lines` first, but without
|
|
2389
|
+
// this the `if (!dialog)` composer block further down painted the input box
|
|
2390
|
+
// straight over it, so /personal and /set-system-prompt showed only a caret.
|
|
2391
|
+
// The question prompt is deliberately NOT a dialog: it is a box above the
|
|
2392
|
+
// composer, so the transcript stays visible (see the `state.question` block).
|
|
2393
|
+
const dialog = state.editor || state.picker || state.form || state.panel || null;
|
|
1840
2394
|
|
|
1841
2395
|
// ---- geometry ----
|
|
1842
2396
|
const composer = composerLayout(state, insideW - 3); // bodyW = (insideW-3)-2 = 53, matches fitAnsi(textW-2) = cInner-4 = 53
|
|
@@ -1844,12 +2398,35 @@ export function composeFrame(state, cols, rows) {
|
|
|
1844
2398
|
const menuItems = (state.menuOpen && state.menuList.length && !dialog)
|
|
1845
2399
|
? Math.min(state.menuList.length, MAX_MENU) + 1
|
|
1846
2400
|
: 0;
|
|
2401
|
+
// The notice no longer reserves a row: it is drawn ON the context row (see the
|
|
2402
|
+
// bottom of this function), so `noticeH` only selects the colour there.
|
|
1847
2403
|
const noticeH = state.notice ? 1 : 0;
|
|
1848
2404
|
const confirmH = state.confirmExit ? 1 : 0;
|
|
1849
2405
|
const workingH = (!dialog && state.running) ? 1 : 0;
|
|
1850
2406
|
const todoH = dialog ? 0 : todoPanelHeight(state);
|
|
1851
2407
|
const queueH = dialog ? 0 : queuePanelHeight(state);
|
|
1852
|
-
|
|
2408
|
+
// The question box is chrome above the composer, so its rows must come OUT of
|
|
2409
|
+
// the transcript area — otherwise the box would overlap the chat.
|
|
2410
|
+
//
|
|
2411
|
+
// On a short terminal the box can be taller than the whole space available. It
|
|
2412
|
+
// must NOT simply overflow: the overflow is trimmed from the TOP (see the
|
|
2413
|
+
// topTrim below), which eats the box's own top border and header and leaves a
|
|
2414
|
+
// dangling `│`. Cap it so the box plus the composer always fit, and let the
|
|
2415
|
+
// transcript shrink to zero instead.
|
|
2416
|
+
// The `@file` list occupies the same rows as the `/` menu, so it counts against
|
|
2417
|
+
// the same budget — otherwise the frame overflows and the top trim eats the
|
|
2418
|
+
// transcript. Only one of the two can be open at a time (see refreshMention).
|
|
2419
|
+
const mentionItems = (state.mentionOpen && state.mentionList.length && !dialog)
|
|
2420
|
+
? Math.min(state.mentionList.length, MAX_MENU) + 1
|
|
2421
|
+
: 0;
|
|
2422
|
+
const chromeH = todoH + queueH + workingH + composerBoxH + menuItems + mentionItems + STATUS_H + confirmH + CTX_H;
|
|
2423
|
+
// NOTE the extra `- 1`: `bodyH` below is clamped to a MINIMUM of 1 row, so that
|
|
2424
|
+
// row has to be reserved here too. Without it the total came out one over and
|
|
2425
|
+
// the top trim ate the box's `╭` border.
|
|
2426
|
+
const questionH = (!dialog && state.question)
|
|
2427
|
+
? Math.min(questionBoxHeight(state, insideW), Math.max(1, h - chromeH - 1))
|
|
2428
|
+
: 0;
|
|
2429
|
+
const bottomH = chromeH + questionH;
|
|
1853
2430
|
const bodyH = Math.max(1, h - bottomH);
|
|
1854
2431
|
|
|
1855
2432
|
const lines = [];
|
|
@@ -1868,8 +2445,15 @@ export function composeFrame(state, cols, rows) {
|
|
|
1868
2445
|
const hint = ed.hint || 'Ctrl+S save · Esc cancel · Enter newline';
|
|
1869
2446
|
lines.push(col(hint, C.gray));
|
|
1870
2447
|
lines.push('');
|
|
1871
|
-
// Viewport height for the editor body
|
|
1872
|
-
|
|
2448
|
+
// Viewport height for the editor body. The editor's own rows are:
|
|
2449
|
+
// title + rule + hint + blank (4, pushed above)
|
|
2450
|
+
// viewH body rows (the text)
|
|
2451
|
+
// line-info (+ optional notice) + rule (2 or 3, pushed below)
|
|
2452
|
+
// so `viewH` must leave room for 6 (or 7 with a notice), NOT 5 — one row too
|
|
2453
|
+
// many made the frame one line over `h` and the top trim cut the editor's own
|
|
2454
|
+
// title and text, leaving just a bare caret on /personal & /set-system-prompt.
|
|
2455
|
+
const edFixed = ed.notice ? 7 : 6;
|
|
2456
|
+
const viewH = Math.max(1, bodyH - edFixed);
|
|
1873
2457
|
// Keep the caret row in view.
|
|
1874
2458
|
let top = Math.max(0, ed.top || 0);
|
|
1875
2459
|
if (ed.caretRow < top) top = ed.caretRow;
|
|
@@ -1885,9 +2469,13 @@ export function composeFrame(state, cols, rows) {
|
|
|
1885
2469
|
lines.push(col(fitAnsi(` line ${ed.caretRow + 1}/${textLines.length} · ${textLines.length} lines`, w), C.gray));
|
|
1886
2470
|
if (ed.notice) lines.push(col(fitAnsi(' ' + ed.notice, w), ed.noticeKind === 'error' ? C.red : C.green));
|
|
1887
2471
|
lines.push(rule);
|
|
1888
|
-
// Caret position = caret column within the visible window.
|
|
2472
|
+
// Caret position = caret column within the visible window. The `+ 4` is the
|
|
2473
|
+
// four rows pushed above the body (title, rule, hint, blank) — it was `3`
|
|
2474
|
+
// (i.e. the title / rule / hint / blank rows miscounted as three), so the
|
|
2475
|
+
// block caret was drawn ONE ROW ABOVE the line it was editing on /personal
|
|
2476
|
+
// and /set-system-prompt.
|
|
1889
2477
|
dialogCaret = {
|
|
1890
|
-
row:
|
|
2478
|
+
row: 4 + Math.max(0, Math.min(viewH - 1, ed.caretRow - top)),
|
|
1891
2479
|
col: Math.min(w - 1, visualCol(expandTabs(textLines[ed.caretRow] || '').slice(0, ed.caretCol))),
|
|
1892
2480
|
};
|
|
1893
2481
|
while (lines.length < bodyH) lines.push(' '.repeat(w));
|
|
@@ -1959,7 +2547,99 @@ export function composeFrame(state, cols, rows) {
|
|
|
1959
2547
|
for (const b of body) lines.push(b);
|
|
1960
2548
|
while (lines.length < bodyH) lines.push(' '.repeat(w));
|
|
1961
2549
|
if (dialogCaret) dialogCaret.row += padTop;
|
|
1962
|
-
}
|
|
2550
|
+
}
|
|
2551
|
+
// AskUserQuestion prompt: rendered as a BOX sitting directly above the
|
|
2552
|
+
// composer — the same treatment as the approval prompt — instead of a
|
|
2553
|
+
// full-screen dialog. It is a prompt about the turn, not a separate screen, and
|
|
2554
|
+
// taking over the whole viewport hid the conversation the question refers to.
|
|
2555
|
+
if (state.question && !dialog) {
|
|
2556
|
+
const qs = state.question;
|
|
2557
|
+
const cur = qs.items[qs.index] || { question: '', options: [] };
|
|
2558
|
+
const promptW = insideW;
|
|
2559
|
+
const innerW = Math.max(1, promptW - 2);
|
|
2560
|
+
const bar = (ch) => col(ch, C.border);
|
|
2561
|
+
const boxRow = (content) => bar('│') + ' ' + fitAnsi(content, innerW) + ' ' + bar('│');
|
|
2562
|
+
const counter = qs.items.length > 1 ? ` ${qs.index + 1}/${qs.items.length}` : '';
|
|
2563
|
+
lines.push(bar('╭' + '─'.repeat(promptW) + '╮'));
|
|
2564
|
+
// Body rows are built into `box` FIRST so the whole thing can be clamped to
|
|
2565
|
+
// `questionH` before any of it reaches `lines`. Pushing the border/header
|
|
2566
|
+
// straight into `lines` (as this used to) meant the budget below did not
|
|
2567
|
+
// count them, so the box overflowed by one row and the top trim ate its `╭`.
|
|
2568
|
+
const box = [];
|
|
2569
|
+
box.push(boxRow(
|
|
2570
|
+
col('Question', C.cyan + C.bold)
|
|
2571
|
+
+ (cur.header ? col(' ' + cur.header, C.yellow) : '')
|
|
2572
|
+
+ col(counter, C.gray),
|
|
2573
|
+
));
|
|
2574
|
+
// The question text itself, wrapped by DISPLAY width so CJK cannot push the
|
|
2575
|
+
// right border out.
|
|
2576
|
+
for (const seg of wrapWords(cur.question, innerW - 2)) {
|
|
2577
|
+
box.push(boxRow(col(' ' + seg, C.white)));
|
|
2578
|
+
}
|
|
2579
|
+
// Options, then the per-question free-text row, then (last question only) the
|
|
2580
|
+
// global supplement row. All three are appended by US, never by the model.
|
|
2581
|
+
const isLast = qs.index === qs.items.length - 1;
|
|
2582
|
+
const opts = cur.options.map((o) => ({ label: o.label, description: o.description, kind: 'option' }));
|
|
2583
|
+
opts.push({ label: OTHER_LABEL, description: 'Type your own answer', kind: 'other' });
|
|
2584
|
+
if (isLast) opts.push({ label: SUPPLEMENT_LABEL, description: 'Optional notes for the whole request', kind: 'supplement' });
|
|
2585
|
+
// The hint/entry row is the LAST body row; when space runs out it is the first
|
|
2586
|
+
// thing to drop (the keys still work, the reminder is just not shown).
|
|
2587
|
+
// `questionH` counts the WHOLE box, so the two border rows and the question
|
|
2588
|
+
// rows already in `box` come OUT of the budget before anything is added.
|
|
2589
|
+
const boxBudget = (questionH || Infinity) - 2 - box.length;
|
|
2590
|
+
const showHint = boxBudget >= opts.length + 1;
|
|
2591
|
+
const bodyBudget = Math.max(0, boxBudget - (showHint ? 1 : 0));
|
|
2592
|
+
// Scroll the option window so the selected row stays visible when the list is
|
|
2593
|
+
// taller than the budget (a 4-option question on a 12-row terminal).
|
|
2594
|
+
let firstOpt = 0;
|
|
2595
|
+
if (opts.length > bodyBudget && bodyBudget > 0) {
|
|
2596
|
+
firstOpt = Math.min(Math.max(0, qs.sel - bodyBudget + 1), opts.length - bodyBudget);
|
|
2597
|
+
}
|
|
2598
|
+
const shownOpts = opts.slice(firstOpt, firstOpt + (bodyBudget || opts.length));
|
|
2599
|
+
const itemRows = [];
|
|
2600
|
+
shownOpts.forEach((o, k) => {
|
|
2601
|
+
const j = firstOpt + k;
|
|
2602
|
+
const isSel = j === qs.sel;
|
|
2603
|
+
const ptr = isSel ? col('❯ ', C.cyan) : ' ';
|
|
2604
|
+
const chosen = qs.picked.has(j);
|
|
2605
|
+
// Only real options get checkboxes: Other/supplement are free text.
|
|
2606
|
+
const box2 = (cur.multiSelect && o.kind === 'option')
|
|
2607
|
+
? (chosen ? col('[x] ', C.green) : col('[ ] ', C.gray)) : '';
|
|
2608
|
+
// Echo what was already typed, so leaving the editor is not a dead end.
|
|
2609
|
+
const typed = (o.kind === 'supplement' && qs.supplement) ? `: ${qs.supplement}`
|
|
2610
|
+
: (o.kind === 'other' && qs.otherText) ? `: ${qs.otherText}` : '';
|
|
2611
|
+
const label = col(String(o.label) + typed, isSel ? (C.cyan + C.bold) : C.white);
|
|
2612
|
+
const desc = o.description ? col(' ' + o.description, C.gray) : '';
|
|
2613
|
+
// The marker column is 2 wide, so the rest gets innerW - 2.
|
|
2614
|
+
itemRows.push({ boxIdx: box.length, index: j });
|
|
2615
|
+
box.push(boxRow(ptr + box2 + fitAnsi(label + desc, Math.max(1, innerW - 4))));
|
|
2616
|
+
});
|
|
2617
|
+
let caretInBox = -1;
|
|
2618
|
+
if (qs.editing && showHint) {
|
|
2619
|
+
// Free-text entry; the caret is drawn on this row.
|
|
2620
|
+
const which = qs.editing === 'supplement' ? SUPPLEMENT_LABEL : OTHER_LABEL;
|
|
2621
|
+
const label = which + ': ';
|
|
2622
|
+
caretInBox = box.length;
|
|
2623
|
+
box.push(boxRow(col(label, C.gray) + col(qs.editingText || '', C.white)));
|
|
2624
|
+
} else if (showHint) {
|
|
2625
|
+
const hint = cur.multiSelect
|
|
2626
|
+
? '↑↓ move · Space toggle · Enter confirm · Esc dismiss'
|
|
2627
|
+
: '↑↓ move · Enter select · Esc dismiss';
|
|
2628
|
+
box.push(boxRow(col(hint, C.gray)));
|
|
2629
|
+
}
|
|
2630
|
+
// Emit the box. `boxTop` is where row 0 of `box` lands in `lines`.
|
|
2631
|
+
const boxTop = lines.length;
|
|
2632
|
+
for (const b of box) lines.push(b);
|
|
2633
|
+
lines.push(bar('╰' + '─'.repeat(promptW) + '╯'));
|
|
2634
|
+
// Mouse hitboxes: the box rows are pushed into `lines`, and composeFrame's
|
|
2635
|
+
// hitbox pass shifts them to final screen coordinates.
|
|
2636
|
+
itemRows.forEach((r) => addHit(boxTop + r.boxIdx, 0, w - 1, { kind: 'questionItem', index: r.index }));
|
|
2637
|
+
if (caretInBox >= 0) {
|
|
2638
|
+
const which = qs.editing === 'supplement' ? SUPPLEMENT_LABEL : OTHER_LABEL;
|
|
2639
|
+
dialogCaret = { row: boxTop + caretInBox, col: visualCol(which + ': ') + visualCol(qs.editingText || '') };
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
if (state.picker) {
|
|
1963
2643
|
const pick = state.picker;
|
|
1964
2644
|
const query = state.pickerQuery || '';
|
|
1965
2645
|
const list = pickerFiltered(state);
|
|
@@ -2060,7 +2740,13 @@ export function composeFrame(state, cols, rows) {
|
|
|
2060
2740
|
for (const b of body) lines.push(b);
|
|
2061
2741
|
while (lines.length < bodyH) lines.push(' '.repeat(w));
|
|
2062
2742
|
if (dialogCaret) dialogCaret.row += padTop;
|
|
2063
|
-
}
|
|
2743
|
+
}
|
|
2744
|
+
// The chat body is the LAST fallback for an open overlay. It used to be the
|
|
2745
|
+
// bare `else` of the PICKER branch, so opening the editor (picker == null) ran
|
|
2746
|
+
// this too and appended a whole extra viewport of rows ON TOP of the editor.
|
|
2747
|
+
// The frame then overflowed, the top trim ate the editor's own title and text,
|
|
2748
|
+
// and /personal & /set-system-prompt showed nothing but a stray caret.
|
|
2749
|
+
if (!state.editor && !state.panel && !state.form && !state.picker) {
|
|
2064
2750
|
const chat = renderChatLines(state, w);
|
|
2065
2751
|
if (state.selection && state.selection.anchor && state.selection.head) {
|
|
2066
2752
|
// Apply selection highlight directly on ANSI strings
|
|
@@ -2120,7 +2806,17 @@ export function composeFrame(state, cols, rows) {
|
|
|
2120
2806
|
|
|
2121
2807
|
if (workingH) {
|
|
2122
2808
|
const frame = SPINNER[(state.spin || 0) % SPINNER.length];
|
|
2123
|
-
|
|
2809
|
+
// ---- shared palette for the whole Working row -------------------------
|
|
2810
|
+
// Everything on this row (spinner glyph, typed chars, elapsed tail, sweep)
|
|
2811
|
+
// must come from ONE set of RGB values, rendered through the SAME lerpColor
|
|
2812
|
+
// path. Mixing C.gray (ANSI 90 ≈ #808080) with the sweep's #969696, or
|
|
2813
|
+
// C.orange (256-colour 208) with the sweep's #ff8c00 (=214), made the row
|
|
2814
|
+
// visibly change shade at each phase boundary.
|
|
2815
|
+
const GREY_RGB = [150, 150, 150];
|
|
2816
|
+
const ORANGE_RGB = [255, 140, 0];
|
|
2817
|
+
const greyEsc = lerpColor(GREY_RGB[0], GREY_RGB[1], GREY_RGB[2], GREY_RGB[0], GREY_RGB[1], GREY_RGB[2], 0);
|
|
2818
|
+
const orangeEsc = lerpColor(ORANGE_RGB[0], ORANGE_RGB[1], ORANGE_RGB[2], ORANGE_RGB[0], ORANGE_RGB[1], ORANGE_RGB[2], 0);
|
|
2819
|
+
const elapsed = state.turnStart ? ` ${col('[' + fmtDuration(Date.now() - state.turnStart) + ']', greyEsc)}` : '';
|
|
2124
2820
|
// The wording was chosen once at turn start; the gradient below loops
|
|
2125
2821
|
// independently, so the phrase stays put while the colours sweep.
|
|
2126
2822
|
const workMsg = state.workMsg || WORKING_MESSAGES[0];
|
|
@@ -2145,17 +2841,17 @@ export function composeFrame(state, cols, rows) {
|
|
|
2145
2841
|
const out = [];
|
|
2146
2842
|
|
|
2147
2843
|
if (phase === 'type') {
|
|
2148
|
-
// Type the WHOLE row (phrase + tail) left → right, all grey.
|
|
2844
|
+
// Type the WHOLE row (phrase + tail) left → right, all in the grey.
|
|
2149
2845
|
const typedCount = Math.floor(phaseT * totalChars);
|
|
2150
2846
|
for (let i = 0; i < phrase.length; i++) {
|
|
2151
|
-
out.push(i < typedCount ? col(phrase[i],
|
|
2847
|
+
out.push(i < typedCount ? col(phrase[i], greyEsc) : ' ');
|
|
2152
2848
|
}
|
|
2153
2849
|
const tailTyped = Math.max(0, typedCount - phrase.length);
|
|
2154
2850
|
for (let i = 0; i < tailText.length; i++) {
|
|
2155
|
-
out.push(i < tailTyped ? col(tailText[i],
|
|
2851
|
+
out.push(i < tailTyped ? col(tailText[i], greyEsc) : ' ');
|
|
2156
2852
|
}
|
|
2157
2853
|
// The tail is already inside `out`, so do NOT append `elapsed` here.
|
|
2158
|
-
lines.push(col(frame,
|
|
2854
|
+
lines.push(col(frame, orangeEsc) + ' ' + out.join(''));
|
|
2159
2855
|
} else {
|
|
2160
2856
|
// Colour sweep: only the PHRASE goes grey → orange. The tail stays grey
|
|
2161
2857
|
// (it is appended as the colored `elapsed` chunk after `out`).
|
|
@@ -2164,21 +2860,23 @@ export function composeFrame(state, cols, rows) {
|
|
|
2164
2860
|
// head sweeps from just left of the phrase (0) to just right of it (1),
|
|
2165
2861
|
// extended by BAND on both ends so every char crosses the band smoothly.
|
|
2166
2862
|
const head = -BAND + phaseT * (1 + 2 * BAND);
|
|
2167
|
-
const GREY = [150, 150, 150];
|
|
2168
|
-
const ORANGE = [255, 140, 0];
|
|
2169
2863
|
for (let i = 0; i < phrase.length; i++) {
|
|
2170
2864
|
const charAt = i / phrase.length;
|
|
2171
2865
|
let tChar = (head - charAt) / BAND;
|
|
2172
2866
|
tChar = Math.max(0, Math.min(1, tChar));
|
|
2173
|
-
const r = Math.round(
|
|
2174
|
-
const g = Math.round(
|
|
2175
|
-
const b = Math.round(
|
|
2867
|
+
const r = Math.round(GREY_RGB[0] + (ORANGE_RGB[0] - GREY_RGB[0]) * tChar);
|
|
2868
|
+
const g = Math.round(GREY_RGB[1] + (ORANGE_RGB[1] - GREY_RGB[1]) * tChar);
|
|
2869
|
+
const b = Math.round(GREY_RGB[2] + (ORANGE_RGB[2] - GREY_RGB[2]) * tChar);
|
|
2176
2870
|
out.push(col(phrase[i], lerpColor(r, g, b, r, g, b, 0)));
|
|
2177
2871
|
}
|
|
2178
|
-
lines.push(col(frame,
|
|
2872
|
+
lines.push(col(frame, orangeEsc) + ' ' + out.join('') + elapsed);
|
|
2179
2873
|
}
|
|
2180
2874
|
if ((Date.now() - state.startAnim.start) >= 1000) {
|
|
2181
2875
|
state.startAnim = null;
|
|
2876
|
+
// The colour phase has just finished painting the phrase orange. Anchor
|
|
2877
|
+
// the pulse to THIS tick so its first frame is the orange start of the
|
|
2878
|
+
// orange -> yellow sweep, not a mid-cycle colour.
|
|
2879
|
+
state.pulseStart = state.spin || 0;
|
|
2182
2880
|
}
|
|
2183
2881
|
}
|
|
2184
2882
|
// ---- turn-finish animation ----
|
|
@@ -2206,14 +2904,13 @@ export function composeFrame(state, cols, rows) {
|
|
|
2206
2904
|
for (let i = 0; i < width; i++) {
|
|
2207
2905
|
word += (i < done) ? (toPadded[i] || '') : from[i];
|
|
2208
2906
|
}
|
|
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);
|
|
2907
|
+
// Colour: orange → grey over the animation. Uses the SAME shared constants
|
|
2908
|
+
// so the row does not shift shade when the finish morph begins.
|
|
2909
|
+
const r = Math.round(ORANGE_RGB[0] + (GREY_RGB[0] - ORANGE_RGB[0]) * t);
|
|
2910
|
+
const g = Math.round(ORANGE_RGB[1] + (GREY_RGB[1] - ORANGE_RGB[1]) * t);
|
|
2911
|
+
const b = Math.round(ORANGE_RGB[2] + (GREY_RGB[2] - ORANGE_RGB[2]) * t);
|
|
2215
2912
|
const animColor = lerpColor(r, g, b, r, g, b, 0);
|
|
2216
|
-
lines.push(col(frame, animColor) + ' ' + col('[', animColor) + col(word, animColor) + col(tail,
|
|
2913
|
+
lines.push(col(frame, animColor) + ' ' + col('[', animColor) + col(word, animColor) + col(tail, greyEsc));
|
|
2217
2914
|
} else {
|
|
2218
2915
|
|
|
2219
2916
|
// Timing (spinner ticks every 80ms, so 0.5s ≈ 6 ticks):
|
|
@@ -2225,12 +2922,16 @@ export function composeFrame(state, cols, rows) {
|
|
|
2225
2922
|
const SWEEP = 6; // 0.5s
|
|
2226
2923
|
const HALF = SWEEP + SWEEP; // 12 ticks per colour (sweep + sweep back)
|
|
2227
2924
|
const CYCLE = HALF * 2; // 24 ticks for the full loop (~2s)
|
|
2228
|
-
|
|
2925
|
+
// Phase is measured from the tick the pulse BEGAN (state.pulseStart), not
|
|
2926
|
+
// from 0: the spinner has been running since turn start, so a raw
|
|
2927
|
+
// `spin % CYCLE` dropped the pulse into a mid-cycle colour (often the red
|
|
2928
|
+
// half) the instant the start animation handed over.
|
|
2929
|
+
const inCycle = ((spin - (state.pulseStart || spin)) % CYCLE + CYCLE) % CYCLE;
|
|
2229
2930
|
const inHalf = inCycle % HALF; // 0..23 within this colour's half
|
|
2230
2931
|
const phase = inCycle < HALF ? 0 : 1; // 0 = yellow, 1 = red
|
|
2231
2932
|
|
|
2232
|
-
// Colours as plain RGB triples (never ANSI strings).
|
|
2233
|
-
|
|
2933
|
+
// Colours as plain RGB triples (never ANSI strings). ORANGE is the shared
|
|
2934
|
+
// constant so the pulse starts exactly where the start animation ended.
|
|
2234
2935
|
const YELLOW = [255, 240, 120];
|
|
2235
2936
|
const RED = [255, 0, 0]; // pure red
|
|
2236
2937
|
const target = phase === 0 ? YELLOW : RED;
|
|
@@ -2249,12 +2950,12 @@ export function composeFrame(state, cols, rows) {
|
|
|
2249
2950
|
const mix = (t) => {
|
|
2250
2951
|
const k = Math.max(0, Math.min(1, t));
|
|
2251
2952
|
return lerpColor(
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2953
|
+
ORANGE_RGB[0] + (target[0] - ORANGE_RGB[0]) * k,
|
|
2954
|
+
ORANGE_RGB[1] + (target[1] - ORANGE_RGB[1]) * k,
|
|
2955
|
+
ORANGE_RGB[2] + (target[2] - ORANGE_RGB[2]) * k,
|
|
2956
|
+
ORANGE_RGB[0] + (target[0] - ORANGE_RGB[0]) * k,
|
|
2957
|
+
ORANGE_RGB[1] + (target[1] - ORANGE_RGB[1]) * k,
|
|
2958
|
+
ORANGE_RGB[2] + (target[2] - ORANGE_RGB[2]) * k,
|
|
2258
2959
|
0,
|
|
2259
2960
|
);
|
|
2260
2961
|
};
|
|
@@ -2276,7 +2977,7 @@ export function composeFrame(state, cols, rows) {
|
|
|
2276
2977
|
}
|
|
2277
2978
|
out.push(col(chars[i], mix(t)));
|
|
2278
2979
|
}
|
|
2279
|
-
lines.push(col(frame,
|
|
2980
|
+
lines.push(col(frame, orangeEsc) + ' ' + out.join('') + elapsed);
|
|
2280
2981
|
}
|
|
2281
2982
|
}
|
|
2282
2983
|
|
|
@@ -2331,6 +3032,22 @@ export function composeFrame(state, cols, rows) {
|
|
|
2331
3032
|
lines.push(boxRow(col('Enter to approve | Esc to reject', C.gray)));
|
|
2332
3033
|
lines.push(bar('╰' + '─'.repeat(promptW) + '╯'));
|
|
2333
3034
|
}
|
|
3035
|
+
|
|
3036
|
+
// Plan approval: shown once the model hands over a <plan> in Plan mode. Same
|
|
3037
|
+
// box shape as the tool approval above so the two read as the same kind of
|
|
3038
|
+
// prompt. Enter approves (turn Plan off and execute); Esc keeps planning.
|
|
3039
|
+
if (state.planPending && !dialog) {
|
|
3040
|
+
const pp = state.planPending;
|
|
3041
|
+
const promptW = insideW;
|
|
3042
|
+
const innerW = Math.max(1, promptW - 2);
|
|
3043
|
+
const bar = (ch) => col(ch, C.border);
|
|
3044
|
+
const boxRow = (content) => bar('│') + ' ' + fitAnsi(content, innerW) + ' ' + bar('│');
|
|
3045
|
+
lines.push(bar('╭' + '─'.repeat(promptW) + '╮'));
|
|
3046
|
+
lines.push(boxRow(col('Approve', C.white + C.bold) + ' ' + col('this plan?', C.cyan + C.bold)));
|
|
3047
|
+
// The plan is ALREADY shown in its table above - do not repeat it here.
|
|
3048
|
+
lines.push(boxRow(col('Enter to approve & execute | Esc to keep planning', C.gray)));
|
|
3049
|
+
lines.push(bar('╰' + '─'.repeat(promptW) + '╯'));
|
|
3050
|
+
}
|
|
2334
3051
|
|
|
2335
3052
|
let composerFirstRow = -1;
|
|
2336
3053
|
if (!dialog) {
|
|
@@ -2399,45 +3116,103 @@ export function composeFrame(state, cols, rows) {
|
|
|
2399
3116
|
lines.push(col('│' + fitAnsi(col(`(${sel}/${totalMatches})`, C.gray), insideW) + '│', C.border));
|
|
2400
3117
|
}
|
|
2401
3118
|
|
|
3119
|
+
// Inline `@file` candidate list — the same widget as the `/` menu above, so the
|
|
3120
|
+
// two read as one mechanism. Shows the path, dims the directory prefix of a
|
|
3121
|
+
// nested entry so the BASENAME is what your eye lands on.
|
|
3122
|
+
if (state.mentionOpen && state.mentionList.length && !dialog && !state.menuOpen) {
|
|
3123
|
+
const totalMatches = state.mentionList.length;
|
|
3124
|
+
const sel = state.mentionSel + 1;
|
|
3125
|
+
const off = state.mentionOffset || 0;
|
|
3126
|
+
const shown = state.mentionList.slice(off, off + MAX_MENU);
|
|
3127
|
+
shown.forEach((it, j) => {
|
|
3128
|
+
const selected = off + j === state.mentionSel;
|
|
3129
|
+
const mark = selected ? col('❯ ', C.cyan) : ' ';
|
|
3130
|
+
const rel = String(it.rel || it.label || '');
|
|
3131
|
+
const cut = rel.lastIndexOf('/') + 1;
|
|
3132
|
+
const dirPart = cut > 0 ? rel.slice(0, cut) : '';
|
|
3133
|
+
const basePart = rel.slice(cut);
|
|
3134
|
+
const label = (dirPart ? col(dirPart, C.gray) : '')
|
|
3135
|
+
+ col(basePart, selected ? (C.cyan + C.bold) : C.gray);
|
|
3136
|
+
const kind = it.isDir ? col(' dir', C.gray) : '';
|
|
3137
|
+
const pad = Math.max(2, 40 - visualCol(rel) - visualCol(kind));
|
|
3138
|
+
addHit(lines.length, 1, insideW, { kind: 'mentionItem', index: off + j });
|
|
3139
|
+
lines.push(col('│' + fitAnsi(mark + label + kind + ' '.repeat(pad), insideW) + '│', C.border));
|
|
3140
|
+
});
|
|
3141
|
+
lines.push(col('│' + fitAnsi(col(`(${sel}/${totalMatches}) Enter or Tab to insert · Esc to dismiss`, C.gray), insideW) + '│', C.border));
|
|
3142
|
+
}
|
|
3143
|
+
|
|
2402
3144
|
const cwd = state.cwd || '';
|
|
2403
3145
|
const thinking = state.reasoning
|
|
2404
3146
|
? (state.effort && state.effort !== 'on' ? ` thinking: ${state.effort}` : ' thinking')
|
|
2405
3147
|
: state.seenThinking ? ' thinking' : '';
|
|
2406
3148
|
const label = state.modelLabel || state.model || '';
|
|
3149
|
+
// Plan / Focus / Swarm are mutually exclusive modes, so at most one badge is
|
|
3150
|
+
// shown. Swarm uses a spring-green (cyan shifted toward green) to stay in the
|
|
3151
|
+
// theme family while reading as its own thing next to Plan's cyan.
|
|
2407
3152
|
const modeBadge = state.plan ? col('Plan', C.cyan + C.bold)
|
|
2408
|
-
: state.focus ? col('Focus', C.
|
|
3153
|
+
: state.focus ? col('Focus', C.blue + C.bold)
|
|
3154
|
+
: state.swarm ? col('Swarm', C.spring + C.bold)
|
|
2409
3155
|
: '';
|
|
2410
3156
|
const parts = [];
|
|
2411
|
-
|
|
2412
|
-
|
|
3157
|
+
// The permission mode and the Plan/Focus badge are both "what mode am I in",
|
|
3158
|
+
// so they read as ONE group: separated by a single space. Everything after the
|
|
3159
|
+
// group gets the normal two-space divider, so a name that already contains a
|
|
3160
|
+
// space is not mistaken for the group boundary:
|
|
3161
|
+
// Auto Plan workbuddy/deepseek-v4.1-flash D:\hncode
|
|
3162
|
+
let modeGroup = state.slMode !== false ? col(modeLabel(state.mode), C.orange + C.bold) : '';
|
|
3163
|
+
if (modeBadge) modeGroup = modeGroup ? modeGroup + ' ' + modeBadge : modeBadge;
|
|
3164
|
+
if (modeGroup) parts.push(modeGroup);
|
|
2413
3165
|
if (state.slModel !== false && label) {
|
|
2414
3166
|
const think = state.slEffort !== false ? thinking : '';
|
|
2415
3167
|
parts.push(col(`${label}${think}`, C.white));
|
|
2416
3168
|
}
|
|
2417
3169
|
if (state.slTasks !== false) {
|
|
3170
|
+
// Two SEPARATE badges, mirroring kimi-code's footer: `bashTasks` counts
|
|
3171
|
+
// background processes and `agentTasks` counts background subagents. They are
|
|
3172
|
+
// semantically different (you wait on a command, you read an agent's report),
|
|
3173
|
+
// so kimi keeps them apart and so do we. Only RUNNING tasks are counted — a
|
|
3174
|
+
// finished task leaves the badge, which is what makes it a live indicator
|
|
3175
|
+
// rather than a running total. `bg N→M` used to conflate both kinds and also
|
|
3176
|
+
// showed a stale count after everything finished.
|
|
2418
3177
|
const ts = Object.values(state.tasks || {});
|
|
2419
|
-
const running = ts.filter((t) => t.status === 'running')
|
|
2420
|
-
const
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
}
|
|
3178
|
+
const running = ts.filter((t) => t.status === 'running');
|
|
3179
|
+
const bashTasks = running.filter((t) => t.kind !== 'agent').length;
|
|
3180
|
+
const agentTasks = running.filter((t) => t.kind === 'agent').length;
|
|
3181
|
+
if (bashTasks > 0) parts.push(col(`[${bashTasks} task${bashTasks === 1 ? '' : 's'} running]`, C.cyan));
|
|
3182
|
+
if (agentTasks > 0) parts.push(col(`[${agentTasks} agent${agentTasks === 1 ? '' : 's'} running]`, C.spring));
|
|
2424
3183
|
}
|
|
2425
3184
|
if (state.slCwd !== false && cwd) parts.push(col(cwd, C.gray));
|
|
2426
3185
|
const statusLeft = parts.join(' ');
|
|
2427
3186
|
const statusRight = (state.slTips !== false && state.tip) ? col(state.tip, C.gray) : '';
|
|
2428
3187
|
lines.push(justify(statusLeft, statusRight, w));
|
|
2429
3188
|
|
|
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
3189
|
if (confirmH) lines.push(col(fitAnsi('Press Ctrl+C again to exit the hncode', w), C.yellow));
|
|
2436
3190
|
|
|
2437
3191
|
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)})`;
|
|
3192
|
+
// The short-lived notice ("Expanded tool output", "Copied 1.2k chars", …)
|
|
3193
|
+
// shares the context row, LEFT-aligned, instead of reserving a row of its own
|
|
3194
|
+
// — a whole blank line appeared and disappeared above the context bar on every
|
|
3195
|
+
// Ctrl+O. The gauge keeps its full width (it must never be the part that gets
|
|
3196
|
+
// cut), so the notice is truncated into whatever space is left over. NOTE:
|
|
3197
|
+
// measure the RAW string — fitAnsi() pads to the full width, so using its
|
|
3198
|
+
// result here made the available room always 0 and the notice invisible.
|
|
2438
3199
|
const ctxW = visualCol(ctxRight);
|
|
2439
|
-
|
|
2440
|
-
|
|
3200
|
+
if (noticeH) {
|
|
3201
|
+
const nc = state.noticeKind === 'error' ? C.red : C.gray;
|
|
3202
|
+
// The notice yields first: it gets whatever is left after the gauge keeps
|
|
3203
|
+
// its natural width plus a 1-column gap, and is truncated into it.
|
|
3204
|
+
const room = Math.max(0, w - Math.min(ctxW, w) - 1);
|
|
3205
|
+
const left = room > 0 ? col(fitAnsi(state.notice, room), nc) : '';
|
|
3206
|
+
const lw = visualCol(left);
|
|
3207
|
+
const gap = lw > 0 ? 1 : 0;
|
|
3208
|
+
// The gauge takes the remaining columns exactly, so the row is always
|
|
3209
|
+
// EXACTLY `w` wide (never 1 over, which wrapped the last column onto the
|
|
3210
|
+
// next screen row) and stays visible even on a narrow terminal.
|
|
3211
|
+
const gauge = col(fitAnsi(ctxRight, Math.max(0, w - lw - gap)), C.white);
|
|
3212
|
+
lines.push(left + ' '.repeat(gap) + gauge);
|
|
3213
|
+
} else {
|
|
3214
|
+
lines.push(' '.repeat(Math.max(0, w - ctxW)) + col(fitAnsi(ctxRight, Math.min(ctxW, w)), C.white));
|
|
3215
|
+
}
|
|
2441
3216
|
|
|
2442
3217
|
let topPad = 0;
|
|
2443
3218
|
if (lines.length < h) { topPad = h - lines.length; }
|
|
@@ -2458,7 +3233,14 @@ export function composeFrame(state, cols, rows) {
|
|
|
2458
3233
|
let cursor;
|
|
2459
3234
|
let cursorVisible;
|
|
2460
3235
|
if (dialogCaret) {
|
|
2461
|
-
|
|
3236
|
+
// A dialog is drawn at the TOP of the frame: the filler rows go BELOW it
|
|
3237
|
+
// (topPad) and nothing is trimmed off the top. The caret row is relative to
|
|
3238
|
+
// the dialog body, so it MUST be shifted by that same pad — without it the
|
|
3239
|
+
// block caret landed `topPad` rows too high (the editor's title / rule / hint
|
|
3240
|
+
// sit above the body, which is exactly the one row it was off by on
|
|
3241
|
+
// /personal and /set-system-prompt). The hitboxes below already apply the pad;
|
|
3242
|
+
// the caret did not.
|
|
3243
|
+
cursor = { row: Math.min(dialogCaret.row + topPad, h - 1), col: Math.min(dialogCaret.col, w - 1) };
|
|
2462
3244
|
cursorVisible = true;
|
|
2463
3245
|
} else if (dialog) {
|
|
2464
3246
|
cursor = { row: 0, col: 0 };
|
|
@@ -2466,7 +3248,15 @@ export function composeFrame(state, cols, rows) {
|
|
|
2466
3248
|
} else {
|
|
2467
3249
|
const menuH = (state.menuOpen && state.menuList.length && !dialog)
|
|
2468
3250
|
? Math.min(state.menuList.length, MAX_MENU) + 1 : 0;
|
|
2469
|
-
|
|
3251
|
+
// The `@file` list occupies the same rows as the `/` menu, and it pushes the
|
|
3252
|
+
// composer UP by exactly that many. This formula derives the caret's screen row
|
|
3253
|
+
// from the chrome BELOW the composer, so leaving the list out of `bottomChrome`
|
|
3254
|
+
// kept the caret at its old row while the input box moved up — the caret sat
|
|
3255
|
+
// several rows below the text it was editing. Must mirror `mentionItems` in the
|
|
3256
|
+
// layout budget above.
|
|
3257
|
+
const mentionH = (state.mentionOpen && state.mentionList.length && !dialog)
|
|
3258
|
+
? Math.min(state.mentionList.length, MAX_MENU) + 1 : 0;
|
|
3259
|
+
const bottomChrome = STATUS_H + (state.confirmExit ? 1 : 0) + CTX_H + menuH + mentionH;
|
|
2470
3260
|
const caretScreenRow = h - bottomChrome - 2 - (composer.rows.length - 1 - composer.caretRow);
|
|
2471
3261
|
cursor = {
|
|
2472
3262
|
row: Math.max(0, Math.min(caretScreenRow, h - 1)),
|
|
@@ -2523,26 +3313,23 @@ export function diffFrame(prev, next) {
|
|
|
2523
3313
|
let out = '\x1b[?2026h';
|
|
2524
3314
|
// Only [first, last] changed; rewriting to the bottom of the screen made a
|
|
2525
3315
|
// single appended line repaint the whole viewport every frame.
|
|
3316
|
+
//
|
|
3317
|
+
// Every row is addressed ABSOLUTELY (`ESC[<n>;1H`). This used to write the
|
|
3318
|
+
// first row absolutely and then advance with `\r\n`, which is a RELATIVE move
|
|
3319
|
+
// that SCROLLS the screen once the line feed happens on the last terminal row
|
|
3320
|
+
// (or with the cursor past the bottom margin). The Working/pulse row sits
|
|
3321
|
+
// directly above the composer's `╭` border, so a repaint span reaching it
|
|
3322
|
+
// scrolled everything up by a row and the next `ESC[2K` erased the composer's
|
|
3323
|
+
// top border — "Working… gets written on the next line and overwrites the
|
|
3324
|
+
// input box". Absolute addressing cannot scroll, so the span is now safe.
|
|
2526
3325
|
const reach = Math.min(next.lines.length, last + 1);
|
|
2527
|
-
let pos2 = first;
|
|
2528
3326
|
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`;
|
|
3327
|
+
out += `\x1b[${i + 1};1H\x1b[2K` + next.lines[i];
|
|
2545
3328
|
}
|
|
3329
|
+
// No "clear the trailing rows" pass is needed: composeFrame always returns
|
|
3330
|
+
// EXACTLY `rows` lines (both frames here share the same height, otherwise the
|
|
3331
|
+
// sizeChanged path above would have run), so a shrinking viewport is already
|
|
3332
|
+
// handled by `last` moving up and the freed rows being rewritten as blanks.
|
|
2546
3333
|
out += '\x1b[?2026l';
|
|
2547
3334
|
out += pos + shape;
|
|
2548
3335
|
return out;
|
|
@@ -2625,6 +3412,10 @@ export function makeState({ cfg, session, opts }) {
|
|
|
2625
3412
|
running: false,
|
|
2626
3413
|
spin: 0,
|
|
2627
3414
|
workMsg: WORKING_MESSAGES[0], // chosen once per turn
|
|
3415
|
+
// (swarm state is set below from the session — see the `swarm:` line after
|
|
3416
|
+
// plan/focus, so it is read from ONE place.)
|
|
3417
|
+
// Turn-finish animation: while non-null, the Working row renders the morph
|
|
3418
|
+
// from `from` to `to` (see composeFrame). Cleared ~0.5s after turn end.
|
|
2628
3419
|
// Turn-finish animation: while non-null, the Working row renders the morph
|
|
2629
3420
|
// from `from` to `to` (see composeFrame). Cleared ~0.5s after turn end.
|
|
2630
3421
|
finishAnim: null, // { start, from, to }
|
|
@@ -2633,11 +3424,22 @@ export function makeState({ cfg, session, opts }) {
|
|
|
2633
3424
|
menuList: [],
|
|
2634
3425
|
menuSel: 0,
|
|
2635
3426
|
menuOffset: 0,
|
|
3427
|
+
// Inline `@file` candidate list — the same widget as the `/` menu, rendered in
|
|
3428
|
+
// the same place. Kept separate from menuList so the two can never both claim
|
|
3429
|
+
// the ↑↓ keys.
|
|
3430
|
+
mentionOpen: false,
|
|
3431
|
+
mentionList: [],
|
|
3432
|
+
mentionSel: 0,
|
|
3433
|
+
mentionOffset: 0,
|
|
2636
3434
|
objective: '',
|
|
2637
3435
|
goalPaused: false,
|
|
2638
3436
|
plan: !!(session && session.plan),
|
|
2639
3437
|
planPath: (session && session.planPath) || null,
|
|
2640
3438
|
focus: !!(session && session.focus),
|
|
3439
|
+
// Swarm mode is per-SESSION (like plan/focus), not just per-config: it is
|
|
3440
|
+
// restored here and written by persistState(). Reading only config.toml left
|
|
3441
|
+
// it off after /sessions resume, /fork and a plain restart.
|
|
3442
|
+
swarm: !!(session && session.swarm),
|
|
2641
3443
|
effort: (session && session.effort) || cfg.effort || (cfg.reasoning ? 'on' : 'off'),
|
|
2642
3444
|
// Theme removed - forced dark only
|
|
2643
3445
|
addDirs: [],
|
|
@@ -2648,6 +3450,20 @@ export function makeState({ cfg, session, opts }) {
|
|
|
2648
3450
|
pickerCategory: null, // active filter category (null = "All")
|
|
2649
3451
|
pickerCategories: null,
|
|
2650
3452
|
form: null,
|
|
3453
|
+
// AskUserQuestion dialog: { items, index, sel, picked:Set, multiSelect-going
|
|
3454
|
+
// state, editing, text, resolve }. Non-null while the user is answering.
|
|
3455
|
+
question: null,
|
|
3456
|
+
// Plan approval: { plan, resolve } while the user reviews a <plan> in Plan
|
|
3457
|
+
// mode. Mirrors approvalPending, but for the whole plan rather than one tool.
|
|
3458
|
+
planPending: null,
|
|
3459
|
+
// /tasks full-screen browser: { tasks, filter, selectedIndex, listScroll,
|
|
3460
|
+
// pendingStop, flash, flashTimer } or null when closed.
|
|
3461
|
+
tasksPanel: null,
|
|
3462
|
+
// Output viewer state (makeViewerState result) or null.
|
|
3463
|
+
tasksViewer: null,
|
|
3464
|
+
// Swarm mode (/swarm): when on, the prompt tells the model to decompose work
|
|
3465
|
+
// across parallel subagents via AgentSwarm.
|
|
3466
|
+
swarm: false,
|
|
2651
3467
|
panel: null,
|
|
2652
3468
|
notice: '',
|
|
2653
3469
|
noticeKind: 'info',
|
|
@@ -2775,7 +3591,7 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
2775
3591
|
if (a === 'on') state.plan = true;
|
|
2776
3592
|
else if (a === 'off') state.plan = false;
|
|
2777
3593
|
else state.plan = !state.plan;
|
|
2778
|
-
if (state.plan) state.focus = false;
|
|
3594
|
+
if (state.plan) { state.focus = false; state.swarm = false; }
|
|
2779
3595
|
persist();
|
|
2780
3596
|
app(`Plan mode: ${state.plan ? 'ON (read-only planning)' : 'OFF'}`);
|
|
2781
3597
|
return;
|
|
@@ -2786,7 +3602,7 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
2786
3602
|
if (a === 'on') want = true;
|
|
2787
3603
|
else if (a === 'off') want = false;
|
|
2788
3604
|
state.focus = want;
|
|
2789
|
-
if (state.focus) state.plan = false;
|
|
3605
|
+
if (state.focus) { state.plan = false; state.swarm = false; }
|
|
2790
3606
|
persist();
|
|
2791
3607
|
app(`Focus mode: ${state.focus ? 'ON (Read/Write/Edit/Bash only)' : 'OFF (all tools)'}`);
|
|
2792
3608
|
return;
|
|
@@ -2802,6 +3618,7 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
2802
3618
|
{ label: 'provider', sub: 'manage AI providers' },
|
|
2803
3619
|
{ label: 'statusline', sub: 'configure status line items' },
|
|
2804
3620
|
{ label: 'add-dir', sub: 'add an additional workspace directory' },
|
|
3621
|
+
{ label: 'auto-update', sub: 'check npm for updates at startup + every 30 min' },
|
|
2805
3622
|
],
|
|
2806
3623
|
onPick: (it) => { dispatch(it.label, '', state, cfg, session, h, submit, stdout); return true; },
|
|
2807
3624
|
});
|
|
@@ -3187,16 +4004,92 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
3187
4004
|
return;
|
|
3188
4005
|
}
|
|
3189
4006
|
case 'tasks': {
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
4007
|
+
// Open the full-screen task browser (kimi's TASK BROWSER). Through the host:
|
|
4008
|
+
// dispatch is module-level and the panel lives inside startTUI.
|
|
4009
|
+
h.openTasksPanel();
|
|
4010
|
+
return;
|
|
4011
|
+
}
|
|
4012
|
+
case 'swarm': {
|
|
4013
|
+
// Swarm mode: tell the model to decompose work across parallel subagents.
|
|
4014
|
+
const a = raw.toLowerCase();
|
|
4015
|
+
let want;
|
|
4016
|
+
if (a === 'on') want = true;
|
|
4017
|
+
else if (a === 'off') want = false;
|
|
4018
|
+
else want = !state.swarm;
|
|
4019
|
+
state.swarm = want;
|
|
4020
|
+
// Plan / Focus / Swarm are one mutually exclusive group: turning one on
|
|
4021
|
+
// turns the other two off.
|
|
4022
|
+
if (want) { state.plan = false; state.focus = false; }
|
|
4023
|
+
try { setConfigString('swarm_mode', want ? 'true' : 'false'); } catch {}
|
|
4024
|
+
cfg.raw.swarm_mode = want;
|
|
4025
|
+
app(want
|
|
4026
|
+
? 'Swarm mode ON - the model will decompose work across parallel subagents (AgentSwarm)'
|
|
4027
|
+
: 'Swarm mode OFF');
|
|
4028
|
+
return;
|
|
4029
|
+
return;
|
|
4030
|
+
}
|
|
4031
|
+
case 'swarm-sub-agent': {
|
|
4032
|
+
// Which model subagents run on. Empty/unset = follow this session's model
|
|
4033
|
+
// (the "default"), so a user who never touches this gets the behaviour they
|
|
4034
|
+
// already had. Stored in config.toml as `subagent_model`, and mirrored onto
|
|
4035
|
+
// `cfg.subagentModel` where the Agent/AgentSwarm tools read it.
|
|
4036
|
+
const models = (cfg.raw && cfg.raw.models) || {};
|
|
4037
|
+
const names = Object.keys(models);
|
|
4038
|
+
const desired = raw.trim();
|
|
4039
|
+
|
|
4040
|
+
const showCurrent = () => (cfg.subagentModel
|
|
4041
|
+
? `Subagent model: ${cfg.subagentModel}`
|
|
4042
|
+
: `Subagent model: default (this session's — ${cfg.model || 'unset'})`);
|
|
4043
|
+
|
|
4044
|
+
const setSubagentModel = (value) => {
|
|
4045
|
+
const v = value || '';
|
|
4046
|
+
try { setConfigString('subagent_model', v); }
|
|
4047
|
+
catch (e) { appErr('Could not write config.toml: ' + e.message); return; }
|
|
4048
|
+
cfg.subagentModel = v;
|
|
4049
|
+
cfg.raw.subagent_model = v;
|
|
4050
|
+
app(v ? `Subagent model → ${v}` : `Subagent model → default (this session's model)`);
|
|
4051
|
+
};
|
|
4052
|
+
|
|
4053
|
+
// `/swarm-sub-agent default` (or `none`/`reset`) clears the override.
|
|
4054
|
+
if (desired) {
|
|
4055
|
+
const d = desired.toLowerCase();
|
|
4056
|
+
if (d === 'default' || d === 'none' || d === 'reset' || d === 'clear') { setSubagentModel(''); return; }
|
|
4057
|
+
if (!models[desired]) { appErr(`Unknown model alias: ${desired}. Run /swarm-sub-agent to pick one.`); return; }
|
|
4058
|
+
setSubagentModel(desired);
|
|
4059
|
+
return;
|
|
4060
|
+
}
|
|
4061
|
+
if (!names.length) {
|
|
4062
|
+
appErr('No models configured. Add one first: /provider');
|
|
4063
|
+
return;
|
|
4064
|
+
}
|
|
4065
|
+
// The same model menu as /model, with a subagent-scoped title and a
|
|
4066
|
+
// "default" row that clears the override.
|
|
4067
|
+
const providers = Object.keys(cfg.raw.providers || {});
|
|
4068
|
+
openPicker({
|
|
4069
|
+
title: 'Select a subagent model',
|
|
4070
|
+
items: [
|
|
4071
|
+
{
|
|
4072
|
+
label: 'default',
|
|
4073
|
+
sub: `follow this session's model (${cfg.model || 'unset'})`,
|
|
4074
|
+
action: 'default',
|
|
4075
|
+
current: !cfg.subagentModel,
|
|
4076
|
+
},
|
|
4077
|
+
...names.map((n) => ({
|
|
4078
|
+
label: n,
|
|
4079
|
+
sub: models[n].provider || cfg.provider || '',
|
|
4080
|
+
current: n === cfg.subagentModel,
|
|
4081
|
+
category: models[n].provider || '',
|
|
4082
|
+
})),
|
|
4083
|
+
],
|
|
4084
|
+
categories: ['All', ...providers],
|
|
4085
|
+
category: null,
|
|
4086
|
+
hint: '↑↓ navigate · Tab switch category · Enter select · Esc cancel',
|
|
4087
|
+
onPick: (it) => {
|
|
4088
|
+
if (it.action === 'default') { setSubagentModel(''); return true; }
|
|
4089
|
+
setSubagentModel(it.label);
|
|
4090
|
+
return true;
|
|
4091
|
+
},
|
|
4092
|
+
});
|
|
3200
4093
|
return;
|
|
3201
4094
|
}
|
|
3202
4095
|
case 'fork': {
|
|
@@ -3294,33 +4187,162 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
3294
4187
|
return;
|
|
3295
4188
|
}
|
|
3296
4189
|
case 'set-system-prompt': {
|
|
3297
|
-
//
|
|
3298
|
-
//
|
|
3299
|
-
// override and restores the built-in.
|
|
4190
|
+
// Two steps: pick a source, then edit it. Showing the editor straight away
|
|
4191
|
+
// (the old behaviour) left no way to start from a preset.
|
|
3300
4192
|
const builtin = SYSTEM_PROMPT;
|
|
3301
4193
|
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
|
-
|
|
4194
|
+
|
|
4195
|
+
// Open the editor on `text`. Saving writes it to config.toml; an EMPTY value
|
|
4196
|
+
// clears the override and restores the built-in. `presetName` is only used
|
|
4197
|
+
// for the title, so the user can see which preset they started from.
|
|
4198
|
+
const edit = (text, presetName) => {
|
|
4199
|
+
openEditor({
|
|
4200
|
+
title: presetName
|
|
4201
|
+
? `System prompt — preset "${presetName}" (edit, then Ctrl+S to save)`
|
|
4202
|
+
: (custom
|
|
4203
|
+
? 'System prompt (custom — saving replaces it; clear it to restore the built-in)'
|
|
4204
|
+
: 'System prompt (built-in — saving overrides it)'),
|
|
4205
|
+
text,
|
|
4206
|
+
caretRow: 0,
|
|
4207
|
+
caretCol: 0,
|
|
4208
|
+
onSave: (value) => {
|
|
4209
|
+
const trimmed = String(value).trim();
|
|
4210
|
+
try {
|
|
4211
|
+
setConfigString('system_prompt', trimmed);
|
|
4212
|
+
} catch (e) { appErr('Could not write config.toml: ' + e.message); return; }
|
|
4213
|
+
cfg.systemPrompt = trimmed;
|
|
4214
|
+
cfg.raw.system_prompt = trimmed;
|
|
4215
|
+
if (trimmed) app(`System prompt saved (${trimmed.length} chars) → ${hncodeConfigFile()}`);
|
|
4216
|
+
else app('System prompt override cleared; the built-in prompt is in use again.');
|
|
4217
|
+
},
|
|
4218
|
+
});
|
|
4219
|
+
};
|
|
4220
|
+
|
|
4221
|
+
// A preset only lands in the editor — never written straight to config.toml.
|
|
4222
|
+
// No preset fits every workflow, and the user should be free to adjust it
|
|
4223
|
+
// before it takes effect.
|
|
4224
|
+
const items = [
|
|
4225
|
+
{
|
|
4226
|
+
label: 'Edit current prompt',
|
|
4227
|
+
sub: custom ? 'edit the custom prompt in use' : 'edit the built-in prompt',
|
|
4228
|
+
action: 'edit-current',
|
|
4229
|
+
},
|
|
4230
|
+
{
|
|
4231
|
+
label: 'Start from a preset…',
|
|
4232
|
+
sub: `${FAMILIES.length} model families × ${TASKS.length} tasks`,
|
|
4233
|
+
action: 'presets',
|
|
4234
|
+
},
|
|
4235
|
+
];
|
|
4236
|
+
if (custom) {
|
|
4237
|
+
items.push({
|
|
4238
|
+
label: 'Restore built-in',
|
|
4239
|
+
sub: 'clear the override and go back to the shipped prompt',
|
|
4240
|
+
action: 'restore',
|
|
4241
|
+
});
|
|
4242
|
+
}
|
|
4243
|
+
openPicker({
|
|
4244
|
+
title: 'System prompt',
|
|
4245
|
+
items,
|
|
4246
|
+
searchable: false,
|
|
4247
|
+
hint: '↑↓ navigate · Enter select · Esc cancel',
|
|
4248
|
+
onPick: (it) => {
|
|
4249
|
+
if (it.action === 'edit-current') {
|
|
4250
|
+
edit(custom || builtin);
|
|
4251
|
+
return true;
|
|
4252
|
+
}
|
|
4253
|
+
if (it.action === 'restore') {
|
|
4254
|
+
try { setConfigString('system_prompt', ''); }
|
|
4255
|
+
catch (e) { appErr('Could not write config.toml: ' + e.message); return true; }
|
|
4256
|
+
cfg.systemPrompt = '';
|
|
4257
|
+
cfg.raw.system_prompt = '';
|
|
4258
|
+
app('System prompt override cleared; the built-in prompt is in use again.');
|
|
4259
|
+
return true;
|
|
4260
|
+
}
|
|
4261
|
+
// Preset picker: the FAMILY is the list (↑↓), the TASK is a footer row
|
|
4262
|
+
// (Tab to focus, ←→ to change) — the same shape /model uses for its
|
|
4263
|
+
// Thinking row. `presetState` remembers the chosen task across the
|
|
4264
|
+
// list selection.
|
|
4265
|
+
const presetState = { task: 'general' };
|
|
4266
|
+
const taskLabel = () => {
|
|
4267
|
+
const t = TASKS.find((x) => x.id === presetState.task) || TASKS[0];
|
|
4268
|
+
return t.label;
|
|
4269
|
+
};
|
|
4270
|
+
openPicker({
|
|
4271
|
+
title: 'System prompt presets',
|
|
4272
|
+
items: FAMILIES.map((f) => ({
|
|
4273
|
+
label: f.label,
|
|
4274
|
+
sub: f.sub,
|
|
4275
|
+
presetId: f.id,
|
|
4276
|
+
})),
|
|
4277
|
+
hint: '↑↓ family · Tab focus task · Enter load into editor · Esc cancel',
|
|
4278
|
+
footer: {
|
|
4279
|
+
label: 'Task',
|
|
4280
|
+
options: TASKS.map((t) => t.label),
|
|
4281
|
+
value: taskLabel(),
|
|
4282
|
+
focused: false,
|
|
4283
|
+
},
|
|
4284
|
+
onPick: (it) => {
|
|
4285
|
+
// Tab/←→ moved the footer; Enter on a list row applies (family, task).
|
|
4286
|
+
const task = TASKS.find((t) => t.label === state.picker.footer.value) || TASKS[0];
|
|
4287
|
+
const text = buildPreset(it.presetId, task.id);
|
|
4288
|
+
// An empty result means the preset could not be built (unknown family,
|
|
4289
|
+
// or a build() that failed). Say so instead of closing the menu with
|
|
4290
|
+
// nothing happening, which reads as a broken Enter key.
|
|
4291
|
+
if (!text) { appErr(`Could not build the "${it.presetId}" preset.`); return false; }
|
|
4292
|
+
edit(text, presetLabel(it.presetId, task.id));
|
|
4293
|
+
return true;
|
|
4294
|
+
},
|
|
4295
|
+
});
|
|
4296
|
+
return true;
|
|
3319
4297
|
},
|
|
3320
4298
|
});
|
|
3321
4299
|
return;
|
|
3322
4300
|
}
|
|
3323
4301
|
|
|
4302
|
+
case 'personal': {
|
|
4303
|
+
// Personal preferences: free-form notes injected into the system prompt on
|
|
4304
|
+
// EVERY turn (see /personal's handler comment in config.js). Two scopes —
|
|
4305
|
+
// global (~/.hncode/PERSONAL.md, all workspaces) and project
|
|
4306
|
+
// (<workspace>/.hncode/PERSONAL.md, this workspace only). Bare /personal
|
|
4307
|
+
// asks which scope; saving an EMPTY body deletes that scope's file.
|
|
4308
|
+
const workspace = state.workspace || cfg.workspace || process.cwd();
|
|
4309
|
+
const openScope = (scope) => {
|
|
4310
|
+
const file = personalPromptFile(scope, workspace);
|
|
4311
|
+
openEditor({
|
|
4312
|
+
title: `Personal preferences (${scope}) — ${file}`,
|
|
4313
|
+
text: readPersonalPromptRaw(scope, workspace),
|
|
4314
|
+
caretRow: 0,
|
|
4315
|
+
caretCol: 0,
|
|
4316
|
+
onSave: (value) => {
|
|
4317
|
+
try { writePersonalPrompt(scope, workspace, value); }
|
|
4318
|
+
catch (e) { appErr('Could not write ' + file + ': ' + e.message); return; }
|
|
4319
|
+
if (String(value).trim()) app(`Personal preferences saved (${scope}) → ${file}`);
|
|
4320
|
+
else app(`Personal preferences cleared (${scope}); nothing will be injected.`);
|
|
4321
|
+
},
|
|
4322
|
+
});
|
|
4323
|
+
};
|
|
4324
|
+
const firstLine = (scope) => {
|
|
4325
|
+
const t = readPersonalPromptRaw(scope, workspace).trim();
|
|
4326
|
+
return t ? t.split('\n')[0].slice(0, 48) : '(empty)';
|
|
4327
|
+
};
|
|
4328
|
+
const a = raw.toLowerCase();
|
|
4329
|
+
if (a === 'global' || a === 'g' || a === 'user') { openScope('global'); return; }
|
|
4330
|
+
if (a === 'project' || a === 'p' || a === 'local') { openScope('project'); return; }
|
|
4331
|
+
if (a === '') {
|
|
4332
|
+
openPicker({
|
|
4333
|
+
title: 'Personal preferences — choose a scope',
|
|
4334
|
+
items: [
|
|
4335
|
+
{ label: 'project', sub: `this workspace only — ${firstLine('project')}` },
|
|
4336
|
+
{ label: 'global', sub: `all workspaces — ${firstLine('global')}` },
|
|
4337
|
+
],
|
|
4338
|
+
onPick: (it) => { openScope(it.label); return true; },
|
|
4339
|
+
});
|
|
4340
|
+
return;
|
|
4341
|
+
}
|
|
4342
|
+
appErr('Usage: /personal [global|project]');
|
|
4343
|
+
return;
|
|
4344
|
+
}
|
|
4345
|
+
|
|
3324
4346
|
case 'calm-mode': {
|
|
3325
4347
|
// Terse-output mode: while ON, an instruction is injected with each request
|
|
3326
4348
|
// telling the model not to narrate what it is about to do or why, unless
|
|
@@ -3341,8 +4363,29 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
3341
4363
|
|
|
3342
4364
|
case 'init': {
|
|
3343
4365
|
if (!cfg.model) { appErr('LLM not set. Configure a provider with /provider first.'); return; }
|
|
3344
|
-
|
|
3345
|
-
|
|
4366
|
+
// Optional free-text instructions, like /compact's optional ratio: whatever
|
|
4367
|
+
// the user types after `/init` steers what goes into the file (e.g.
|
|
4368
|
+
// `/init focus on the tRPC routers and our migration rules`). With no
|
|
4369
|
+
// argument the built-in checklist is used on its own.
|
|
4370
|
+
const instructions = raw.trim();
|
|
4371
|
+
// Already have one? Then this is likely an UPDATE, and overwriting silently
|
|
4372
|
+
// would throw away whatever the user hand-edited in there.
|
|
4373
|
+
const existing = agentsFilesFor(state.cwd || cfg.workspace, cfg.workspace || state.cwd || process.cwd())
|
|
4374
|
+
.filter((f) => /(^|[\\/])agents?\.md$/i.test(f.path) && !/override/i.test(f.path));
|
|
4375
|
+
const base = 'Analyze this codebase and write an AGENTS.md file at the workspace root. '
|
|
4376
|
+
+ 'Keep it concise instructions for future agents: project layout, build/test commands, '
|
|
4377
|
+
+ 'and conventions. Prefer concrete commands and paths over prose.';
|
|
4378
|
+
const update = existing.length
|
|
4379
|
+
? `\n\nThere is already an AGENTS.md at ${existing[0].path}. Read it first, then revise it `
|
|
4380
|
+
+ 'in place: keep what is still accurate, correct what is stale, and add what is missing. '
|
|
4381
|
+
+ 'Do not discard existing instructions unless they are wrong.'
|
|
4382
|
+
: '';
|
|
4383
|
+
const extra = instructions ? `\n\nThe user asked you to focus on this:\n${instructions}` : '';
|
|
4384
|
+
say(instructions
|
|
4385
|
+
? '/init — generating AGENTS.md, guided by your instructions…'
|
|
4386
|
+
: '/init — analyzing the workspace to generate AGENTS.md…');
|
|
4387
|
+
sendPrompt(base + update + extra);
|
|
4388
|
+
return;
|
|
3346
4389
|
return;
|
|
3347
4390
|
}
|
|
3348
4391
|
|
|
@@ -3419,6 +4462,39 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
|
|
|
3419
4462
|
].join('\n'));
|
|
3420
4463
|
return;
|
|
3421
4464
|
}
|
|
4465
|
+
case 'auto-update': {
|
|
4466
|
+
// Toggle /auto-update. Persists to config.toml so it survives restarts.
|
|
4467
|
+
const a = raw.toLowerCase();
|
|
4468
|
+
let want;
|
|
4469
|
+
if (a === 'on') want = true;
|
|
4470
|
+
else if (a === 'off') want = false;
|
|
4471
|
+
else want = !cfg.autoUpdate;
|
|
4472
|
+
try { setConfigString('auto_update', want ? 'true' : 'false'); }
|
|
4473
|
+
catch (e) { appErr('Could not write config.toml: ' + e.message); return; }
|
|
4474
|
+
cfg.autoUpdate = want;
|
|
4475
|
+
cfg.raw.auto_update = want;
|
|
4476
|
+
app(want
|
|
4477
|
+
? 'Auto-update ON — checks npm at startup and every 30 min, installs in the background'
|
|
4478
|
+
: 'Auto-update OFF');
|
|
4479
|
+
return;
|
|
4480
|
+
}
|
|
4481
|
+
case 'update': {
|
|
4482
|
+
// Manual update check. This is the ONLY place an update is ever reported;
|
|
4483
|
+
// the automatic background check stays completely silent.
|
|
4484
|
+
app('Checking for updates...');
|
|
4485
|
+
cfg.updateRunning = true;
|
|
4486
|
+
try {
|
|
4487
|
+
const r = await upd.checkAndUpdate();
|
|
4488
|
+
if (r.status === 'updated') app(r.message);
|
|
4489
|
+
else if (r.status === 'uptodate') app(r.message);
|
|
4490
|
+
else app('Update check: ' + (r.message || 'failed'));
|
|
4491
|
+
} catch (e) {
|
|
4492
|
+
appErr('Update check failed: ' + (e && e.message || e));
|
|
4493
|
+
} finally {
|
|
4494
|
+
cfg.updateRunning = false;
|
|
4495
|
+
}
|
|
4496
|
+
return;
|
|
4497
|
+
}
|
|
3422
4498
|
case 'version': app(`hncode v${VERSION}`); return;
|
|
3423
4499
|
|
|
3424
4500
|
case 'mcp': {
|
|
@@ -3619,6 +4695,7 @@ case 'statusline':
|
|
|
3619
4695
|
plan: session.plan,
|
|
3620
4696
|
planPath: session.planPath,
|
|
3621
4697
|
focus: session.focus,
|
|
4698
|
+
swarm: session.swarm,
|
|
3622
4699
|
effort: session.effort,
|
|
3623
4700
|
theme: session.theme,
|
|
3624
4701
|
todos: (session.todos || []).slice(),
|
|
@@ -3874,6 +4951,20 @@ function tokenize(str) {
|
|
|
3874
4951
|
out.push({ ch: String.fromCodePoint(cp) });
|
|
3875
4952
|
i += ku[0].length; continue;
|
|
3876
4953
|
}
|
|
4954
|
+
// Shift+Arrow / Shift+Home / Shift+End: the caret MOVES while extending a
|
|
4955
|
+
// selection. Emitted as their own tokens so the composer can tell them
|
|
4956
|
+
// apart from the plain arrows (which scroll the transcript).
|
|
4957
|
+
if (shift && !ctrl && !alt && cp >= 0xE000 && cp < 0xE100) {
|
|
4958
|
+
const arrows = { 0xE000: 'up', 0xE001: 'down', 0xE003: 'left', 0xE002: 'right' };
|
|
4959
|
+
const a = arrows[cp];
|
|
4960
|
+
if (a) { out.push({ key: 'shift-' + a }); i += ku[0].length; continue; }
|
|
4961
|
+
}
|
|
4962
|
+
if (shift && !ctrl && !alt && (cp === 0xE014 || cp === 0xE015)) {
|
|
4963
|
+
out.push({ key: cp === 0xE014 ? 'shift-home' : 'shift-end' });
|
|
4964
|
+
i += ku[0].length; continue;
|
|
4965
|
+
}
|
|
4966
|
+
// Fall back for terminals that send the legacy CSI form with a `2` modifier
|
|
4967
|
+
// (handled in csiName) — nothing to do here.
|
|
3877
4968
|
if (ctrl && shift && !alt) {
|
|
3878
4969
|
// Ctrl+Shift+<letter> needs its own token. Collapsing it into 'c-<x>'
|
|
3879
4970
|
// made Ctrl+Shift+C indistinguishable from Ctrl+C, so "copy" ran the
|
|
@@ -3916,8 +5007,21 @@ function tokenize(str) {
|
|
|
3916
5007
|
if (str[j] === '?') { j++; while (j < str.length && str[j] >= '0' && str[j] <= '9') j++; }
|
|
3917
5008
|
const fin = str[j];
|
|
3918
5009
|
if (fin === undefined || !/[A-Za-z~]/.test(fin)) break;
|
|
3919
|
-
const
|
|
3920
|
-
|
|
5010
|
+
const rawParams = str.slice(i + 2, j);
|
|
5011
|
+
const params = (rawParams.match(/^\d+/) || [''])[0];
|
|
5012
|
+
// Modifier: the 2nd `;`-separated parameter, 1-based (`;2` = Shift, `;5`
|
|
5013
|
+
// = Ctrl, `;6` = Ctrl+Shift). Legacy terminals send Shift+Arrow this way
|
|
5014
|
+
// rather than as a CSI-u sequence.
|
|
5015
|
+
const modStr = (rawParams.match(/;(\d+)/) || [])[1];
|
|
5016
|
+
const mod = modStr ? parseInt(modStr, 10) - 1 : 0;
|
|
5017
|
+
const shift = (mod & 1) !== 0, ctrl = (mod & 4) !== 0;
|
|
5018
|
+
const base = csiName(params, fin);
|
|
5019
|
+
if (shift && !ctrl && ['up', 'down', 'left', 'right', 'home', 'end'].includes(base)) {
|
|
5020
|
+
out.push({ key: 'shift-' + base });
|
|
5021
|
+
i = j + 1;
|
|
5022
|
+
continue;
|
|
5023
|
+
}
|
|
5024
|
+
out.push({ key: base });
|
|
3921
5025
|
i = j + 1;
|
|
3922
5026
|
} else if (str[i + 1] === 'O') {
|
|
3923
5027
|
const f = str[i + 2];
|
|
@@ -3945,6 +5049,9 @@ function tokenize(str) {
|
|
|
3945
5049
|
// CSI-u sequence unless the Kitty protocol maps it), so it needs an explicit
|
|
3946
5050
|
// entry here — otherwise it fell through to { ch: '\x16' } and was dropped.
|
|
3947
5051
|
else if (c === '\x16') { out.push({ key: 'c-v' }); i++; }
|
|
5052
|
+
// Ctrl+X is the same story: raw 0x18. Ctrl+Shift+X comes through the CSI-u
|
|
5053
|
+
// path above as `c-s-x` on terminals that report modified keys.
|
|
5054
|
+
else if (c === '\x18') { out.push({ key: 'c-x' }); i++; }
|
|
3948
5055
|
else if (c === '\x7f' || c === '\x08') { out.push({ key: 'backspace' }); i++; }
|
|
3949
5056
|
else { out.push({ ch: c }); i++; }
|
|
3950
5057
|
}
|
|
@@ -4014,7 +5121,27 @@ export async function startTUI(opts) {
|
|
|
4014
5121
|
// Only auto-follow when the view is ALREADY pinned to the bottom. Forcing
|
|
4015
5122
|
// scroll = 0 unconditionally yanked the user back down whenever new output
|
|
4016
5123
|
// arrived — so reading history mid-turn was impossible.
|
|
4017
|
-
|
|
5124
|
+
//
|
|
5125
|
+
// This used to be `if ((state.scroll||0) === 0) state.scroll = 0;`, i.e. a
|
|
5126
|
+
// NO-OP. The comment promised "auto-follow" but nothing re-anchored the view,
|
|
5127
|
+
// so appending a tall message (a long Read/Bash result) pushed the whole
|
|
5128
|
+
// transcript up by its height in a single frame instead of scrolling by one
|
|
5129
|
+
// row — the "everything jumps" report. When the user IS scrolled up we leave
|
|
5130
|
+
// the offset alone, which is the other half of the original intent.
|
|
5131
|
+
if ((state.scroll || 0) === 0) {
|
|
5132
|
+
state.scroll = 0; // pinned: keep following the newest message
|
|
5133
|
+
} else {
|
|
5134
|
+
// Scrolled up: grow the offset by exactly the number of rows this message
|
|
5135
|
+
// added, so the content the user is reading does not move.
|
|
5136
|
+
try {
|
|
5137
|
+
const rows = renderChatLines(state, dims().cols).length;
|
|
5138
|
+
if (state._addChatRows != null) {
|
|
5139
|
+
const grew = Math.max(0, rows - state._addChatRows);
|
|
5140
|
+
if (grew) state.scroll = (state.scroll || 0) + grew;
|
|
5141
|
+
}
|
|
5142
|
+
state._addChatRows = rows;
|
|
5143
|
+
} catch { /* rendering is best-effort here; never break a message append */ }
|
|
5144
|
+
}
|
|
4018
5145
|
renderFrame();
|
|
4019
5146
|
};
|
|
4020
5147
|
let noticeTimer = null;
|
|
@@ -4088,6 +5215,121 @@ export async function startTUI(opts) {
|
|
|
4088
5215
|
renderFrame();
|
|
4089
5216
|
}
|
|
4090
5217
|
|
|
5218
|
+
// ---- @ file completion ----------------------------------------------------
|
|
5219
|
+
// Typing `@` shows an INLINE candidate list above the composer — the same widget
|
|
5220
|
+
// `/` uses (state.mention*) — rather than taking over the screen. It used to be a
|
|
5221
|
+
// silent rewrite of the input to the FIRST match of a single-directory listing:
|
|
5222
|
+
// you could not see the candidates, could not descend into a subdirectory
|
|
5223
|
+
// (`@src/` matched nothing), and hidden files were dropped.
|
|
5224
|
+
//
|
|
5225
|
+
// `walk` is bounded (depth + entry count) because it runs on a keystroke.
|
|
5226
|
+
function collectWorkspaceFiles(root, { limit = 4000, maxDepth = 8 } = {}) {
|
|
5227
|
+
const out = [];
|
|
5228
|
+
const skip = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'target', '__pycache__']);
|
|
5229
|
+
const walk = (dir, rel, depth) => {
|
|
5230
|
+
if (out.length >= limit || depth > maxDepth) return;
|
|
5231
|
+
let entries;
|
|
5232
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
5233
|
+
// Deterministic order: directories first, then files, each alphabetical, so
|
|
5234
|
+
// the list does not jump around between keystrokes.
|
|
5235
|
+
entries.sort((a, b) => (b.isDirectory() - a.isDirectory()) || a.name.localeCompare(b.name));
|
|
5236
|
+
for (const e of entries) {
|
|
5237
|
+
if (out.length >= limit) return;
|
|
5238
|
+
if (skip.has(e.name)) continue;
|
|
5239
|
+
const relPath = rel ? rel + '/' + e.name : e.name;
|
|
5240
|
+
if (e.isDirectory()) {
|
|
5241
|
+
out.push({ label: relPath + '/', isDir: true, rel: relPath + '/' });
|
|
5242
|
+
walk(path.join(dir, e.name), relPath, depth + 1);
|
|
5243
|
+
} else {
|
|
5244
|
+
out.push({ label: relPath, isDir: false, rel: relPath });
|
|
5245
|
+
}
|
|
5246
|
+
}
|
|
5247
|
+
};
|
|
5248
|
+
walk(root, '', 0);
|
|
5249
|
+
return out;
|
|
5250
|
+
}
|
|
5251
|
+
|
|
5252
|
+
// Everything after the last `@` that has no whitespace in it is the query.
|
|
5253
|
+
function atQueryAt(input) {
|
|
5254
|
+
const s = String(input || '');
|
|
5255
|
+
const at = s.lastIndexOf('@');
|
|
5256
|
+
if (at < 0) return null;
|
|
5257
|
+
const after = s.slice(at + 1);
|
|
5258
|
+
if (/\s/.test(after)) return null; // an email-ish "@foo bar" is not a path
|
|
5259
|
+
// Only treat it as a mention when `@` starts a word (start of input or after space).
|
|
5260
|
+
if (at > 0 && !/\s/.test(s[at - 1])) return null;
|
|
5261
|
+
return { at, query: after };
|
|
5262
|
+
}
|
|
5263
|
+
|
|
5264
|
+
// The file index is CACHED per workspace root. Walking a real project on every
|
|
5265
|
+
// keystroke measured 82 ms on a 3k-entry tree — a visible stall while typing —
|
|
5266
|
+
// and a monorepo with node_modules is far worse. The cache is refreshed when it
|
|
5267
|
+
// is older than MENTION_CACHE_MS or the root changes, so the list still tracks
|
|
5268
|
+
// files created during the session without paying the walk on every character.
|
|
5269
|
+
let mentionCache = null; // { root, at, files }
|
|
5270
|
+
const MENTION_CACHE_MS = 5000;
|
|
5271
|
+
|
|
5272
|
+
function mentionFiles(root) {
|
|
5273
|
+
const now = Date.now();
|
|
5274
|
+
if (mentionCache && mentionCache.root === root && now - mentionCache.at < MENTION_CACHE_MS) {
|
|
5275
|
+
return mentionCache.files;
|
|
5276
|
+
}
|
|
5277
|
+
const files = collectWorkspaceFiles(root);
|
|
5278
|
+
mentionCache = { root, at: now, files };
|
|
5279
|
+
return files;
|
|
5280
|
+
}
|
|
5281
|
+
|
|
5282
|
+
// Refresh the inline @ list from the current input. Called on every keystroke
|
|
5283
|
+
// that touches the mention, and on Tab. Closes the list when the caret leaves the
|
|
5284
|
+
// mention (e.g. a space was typed after it).
|
|
5285
|
+
function refreshMention() {
|
|
5286
|
+
const hit = atQueryAt(state.input || '');
|
|
5287
|
+
if (!hit) {
|
|
5288
|
+
state.mentionOpen = false;
|
|
5289
|
+
state.mentionList = [];
|
|
5290
|
+
state.mentionSel = 0;
|
|
5291
|
+
state.mentionOffset = 0;
|
|
5292
|
+
return;
|
|
5293
|
+
}
|
|
5294
|
+
const root = state.cwd || state.workspace || process.cwd();
|
|
5295
|
+
const q = hit.query.toLowerCase();
|
|
5296
|
+
const all = mentionFiles(root);
|
|
5297
|
+
// Precompute the lowercase label + basename once per entry instead of inside
|
|
5298
|
+
// the comparator: they were recomputed on every comparison of every sort.
|
|
5299
|
+
const ranked = [];
|
|
5300
|
+
for (const f of all) {
|
|
5301
|
+
const label = f.label.toLowerCase();
|
|
5302
|
+
if (q && !label.includes(q)) continue;
|
|
5303
|
+
const base = label.slice(label.lastIndexOf('/') + 1);
|
|
5304
|
+
ranked.push({ f, nameHit: q && base.startsWith(q) ? 0 : 1 });
|
|
5305
|
+
}
|
|
5306
|
+
ranked.sort((a, b) => {
|
|
5307
|
+
if (a.nameHit !== b.nameHit) return a.nameHit - b.nameHit;
|
|
5308
|
+
if (a.f.isDir !== b.f.isDir) return a.f.isDir ? -1 : 1;
|
|
5309
|
+
return a.f.label.length - b.f.label.length || (a.f.label < b.f.label ? -1 : a.f.label > b.f.label ? 1 : 0);
|
|
5310
|
+
});
|
|
5311
|
+
state.mentionList = ranked.slice(0, 200).map((r) => r.f);
|
|
5312
|
+
state.mentionSel = Math.min(state.mentionSel || 0, Math.max(0, state.mentionList.length - 1));
|
|
5313
|
+
// Hitting Tab again after the list closed should start from the top.
|
|
5314
|
+
state.mentionOffset = 0;
|
|
5315
|
+
state.mentionOpen = state.mentionList.length > 0;
|
|
5316
|
+
}
|
|
5317
|
+
|
|
5318
|
+
// Accept the highlighted candidate: replace the whole `@query` token.
|
|
5319
|
+
function acceptMention() {
|
|
5320
|
+
const it = (state.mentionList || [])[state.mentionSel || 0];
|
|
5321
|
+
if (!it) return false;
|
|
5322
|
+
const hit = atQueryAt(state.input || '');
|
|
5323
|
+
if (!hit) return false;
|
|
5324
|
+
state.input = String(state.input || '').slice(0, hit.at) + '@' + it.rel;
|
|
5325
|
+
state.caret = state.input.length;
|
|
5326
|
+
state.mentionOpen = false;
|
|
5327
|
+
state.mentionList = [];
|
|
5328
|
+
state.mentionSel = 0;
|
|
5329
|
+
renderFrame();
|
|
5330
|
+
return true;
|
|
5331
|
+
}
|
|
5332
|
+
|
|
4091
5333
|
function openForm(spec) {
|
|
4092
5334
|
const fields = (spec.fields || []).map((f) => ({
|
|
4093
5335
|
key: f.key, label: f.label || f.key || '', value: f.value || '',
|
|
@@ -4140,6 +5382,145 @@ export async function startTUI(opts) {
|
|
|
4140
5382
|
renderFrame();
|
|
4141
5383
|
}
|
|
4142
5384
|
|
|
5385
|
+
// ---- /tasks panel (kimi's TASK BROWSER) ----------------------------------
|
|
5386
|
+
function refreshTasksPanel() {
|
|
5387
|
+
const p = state.tasksPanel;
|
|
5388
|
+
if (!p) return;
|
|
5389
|
+
p.tasks = sortedTasks(state.agent ? state.agent.ctx : { tasks: state.tasks });
|
|
5390
|
+
const vis = visibleTasks(p.tasks, p.filter);
|
|
5391
|
+
if (p.selectedIndex >= vis.length) p.selectedIndex = Math.max(0, vis.length - 1);
|
|
5392
|
+
}
|
|
5393
|
+
|
|
5394
|
+
function openTasksPanel() {
|
|
5395
|
+
const tasks = sortedTasks(state.tasks ? { tasks: state.tasks } : {});
|
|
5396
|
+
state.tasksPanel = {
|
|
5397
|
+
tasks,
|
|
5398
|
+
filter: 'all',
|
|
5399
|
+
selectedIndex: 0,
|
|
5400
|
+
listScroll: 0,
|
|
5401
|
+
pendingStop: null,
|
|
5402
|
+
flash: '',
|
|
5403
|
+
flashTimer: null,
|
|
5404
|
+
};
|
|
5405
|
+
const vis = visibleTasks(tasks, 'all');
|
|
5406
|
+
const runningIdx = vis.findIndex((x) => x.status === 'running');
|
|
5407
|
+
state.tasksPanel.selectedIndex = runningIdx >= 0 ? runningIdx : 0;
|
|
5408
|
+
state.menuOpen = false; state.menuList = []; state.menuSel = 0;
|
|
5409
|
+
state.picker = null; state.form = null; state.panel = null;
|
|
5410
|
+
renderFrame();
|
|
5411
|
+
}
|
|
5412
|
+
|
|
5413
|
+
function closeTasksPanel() {
|
|
5414
|
+
const p = state.tasksPanel;
|
|
5415
|
+
if (!p) return;
|
|
5416
|
+
if (p.flashTimer) clearTimeout(p.flashTimer);
|
|
5417
|
+
if (p.pollTimer) clearInterval(p.pollTimer);
|
|
5418
|
+
state.tasksPanel = null;
|
|
5419
|
+
renderFrame();
|
|
5420
|
+
}
|
|
5421
|
+
|
|
5422
|
+
function tasksFlash(msg) {
|
|
5423
|
+
const p = state.tasksPanel;
|
|
5424
|
+
if (!p) return;
|
|
5425
|
+
p.flash = msg;
|
|
5426
|
+
if (p.flashTimer) clearTimeout(p.flashTimer);
|
|
5427
|
+
p.flashTimer = setTimeout(() => { p.flash = ''; renderFrame(); }, 2500);
|
|
5428
|
+
}
|
|
5429
|
+
|
|
5430
|
+
function openTaskOutputViewer(taskId) {
|
|
5431
|
+
const ctxLike = state.agent && state.agent.ctx ? state.agent.ctx : { tasks: state.tasks };
|
|
5432
|
+
const task = getTask(ctxLike, taskId) || (state.tasks || {})[taskId];
|
|
5433
|
+
if (!task) { tasksFlash('No such task: ' + taskId); return; }
|
|
5434
|
+
state.tasksViewer = makeViewerState(task);
|
|
5435
|
+
renderFrame();
|
|
5436
|
+
}
|
|
5437
|
+
|
|
5438
|
+
function closeTaskOutputViewer() {
|
|
5439
|
+
state.tasksViewer = null;
|
|
5440
|
+
renderFrame();
|
|
5441
|
+
}
|
|
5442
|
+
|
|
5443
|
+
// Returns true when the key was consumed by the panel / viewer.
|
|
5444
|
+
function handleTasksPanelKey(t) {
|
|
5445
|
+
const p = state.tasksPanel;
|
|
5446
|
+
if (!p) return false;
|
|
5447
|
+
const ctxLike = state.agent && state.agent.ctx ? state.agent.ctx : { tasks: state.tasks };
|
|
5448
|
+
// Snapshot the STOP target BEFORE the key handler clears pendingStop on 'y'.
|
|
5449
|
+
const stopTarget = p.pendingStop;
|
|
5450
|
+
const action = handleTasksBrowserKey(p, t);
|
|
5451
|
+
p.tasks = sortedTasks(ctxLike);
|
|
5452
|
+
const vis = visibleTasks(p.tasks, p.filter);
|
|
5453
|
+
const sel = vis[p.selectedIndex];
|
|
5454
|
+
|
|
5455
|
+
switch (action) {
|
|
5456
|
+
case 'close': closeTasksPanel(); return true;
|
|
5457
|
+
case 'toggleFilter':
|
|
5458
|
+
p.filter = p.filter === 'all' ? 'active' : 'all';
|
|
5459
|
+
p.selectedIndex = 0;
|
|
5460
|
+
break;
|
|
5461
|
+
case 'refresh':
|
|
5462
|
+
p.tasks = sortedTasks(ctxLike);
|
|
5463
|
+
tasksFlash('Refreshed');
|
|
5464
|
+
break;
|
|
5465
|
+
case 'stopIgnored':
|
|
5466
|
+
tasksFlash((sel ? sel.taskId : '') + ' is already terminal - nothing to stop.');
|
|
5467
|
+
break;
|
|
5468
|
+
case 'requestStop':
|
|
5469
|
+
// Nothing to do yet: the footer now asks for Y to confirm.
|
|
5470
|
+
break;
|
|
5471
|
+
case 'cancelStop':
|
|
5472
|
+
tasksFlash('Stop cancelled');
|
|
5473
|
+
break;
|
|
5474
|
+
case 'confirmStop': {
|
|
5475
|
+
// 'y' was pressed: stop the task that was pending.
|
|
5476
|
+
stopTask(ctxLike, stopTarget);
|
|
5477
|
+
p.tasks = sortedTasks(ctxLike);
|
|
5478
|
+
break;
|
|
5479
|
+
}
|
|
5480
|
+
case 'openOutput':
|
|
5481
|
+
if (sel) openTaskOutputViewer(sel.taskId);
|
|
5482
|
+
return true;
|
|
5483
|
+
default:
|
|
5484
|
+
break;
|
|
5485
|
+
}
|
|
5486
|
+
renderFrame();
|
|
5487
|
+
return true;
|
|
5488
|
+
}
|
|
5489
|
+
|
|
5490
|
+
// Kill / abort one task by id. An agent task aborts through its controller; a
|
|
5491
|
+
// process is killed as a tree (Windows) or by signal.
|
|
5492
|
+
function stopTask(ctxLike, id) {
|
|
5493
|
+
if (!id) return;
|
|
5494
|
+
const tk = getTask(ctxLike, id) || (state.tasks || {})[id];
|
|
5495
|
+
if (!tk || tk.status !== 'running') return;
|
|
5496
|
+
try {
|
|
5497
|
+
if (tk.kind === 'agent' && typeof tk._abort === 'function') {
|
|
5498
|
+
tk._abort();
|
|
5499
|
+
} else if (tk.pid) {
|
|
5500
|
+
if (process.platform === 'win32') {
|
|
5501
|
+
try { cp.spawnSync('taskkill', ['/pid', String(tk.pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
|
|
5502
|
+
} else {
|
|
5503
|
+
try { process.kill(tk.pid, 'SIGTERM'); } catch {}
|
|
5504
|
+
}
|
|
5505
|
+
}
|
|
5506
|
+
} catch {}
|
|
5507
|
+
settleTask(tk, 'killed', { stopReason: 'User initiated stop' });
|
|
5508
|
+
tasksFlash('Stopped ' + id);
|
|
5509
|
+
}
|
|
5510
|
+
|
|
5511
|
+
function handleTasksViewerKey(t) {
|
|
5512
|
+
const v = state.tasksViewer;
|
|
5513
|
+
if (!v) return false;
|
|
5514
|
+
// Refresh from the live store so a running task's tail keeps moving.
|
|
5515
|
+
const ctxLike = state.agent && state.agent.ctx ? state.agent.ctx : { tasks: state.tasks };
|
|
5516
|
+
const fresh = getTask(ctxLike, v.task.taskId);
|
|
5517
|
+
if (fresh) v.task = fresh;
|
|
5518
|
+
const action = handleViewerKey(v, t, dims().rows);
|
|
5519
|
+
if (action === 'close') closeTaskOutputViewer();
|
|
5520
|
+
else renderFrame();
|
|
5521
|
+
return true;
|
|
5522
|
+
}
|
|
5523
|
+
|
|
4143
5524
|
if (!stdin.isTTY) {
|
|
4144
5525
|
return 1;
|
|
4145
5526
|
}
|
|
@@ -4184,6 +5565,26 @@ export async function startTUI(opts) {
|
|
|
4184
5565
|
if (rate !== state.tokRate) { state.tokRate = rate; renderFrame(); }
|
|
4185
5566
|
}, 50);
|
|
4186
5567
|
|
|
5568
|
+
// ---- auto-update (background + SILENT, never blocks the TUI) --------------
|
|
5569
|
+
// If autoUpdate is on, check npm at startup and then every 30 minutes. The
|
|
5570
|
+
// check and the install both run detached, and NOTHING is ever printed: the
|
|
5571
|
+
// whole point is that the user never notices. Version notices appear only for
|
|
5572
|
+
// the explicit /update command (see its dispatch case).
|
|
5573
|
+
let autoCheckTimer = null;
|
|
5574
|
+
let autoCheckInflight = false;
|
|
5575
|
+
const runAutoCheck = async () => {
|
|
5576
|
+
if (!cfg.autoUpdate || autoCheckInflight) return;
|
|
5577
|
+
autoCheckInflight = true;
|
|
5578
|
+
try { await upd.checkAndUpdate(); } catch { /* silent */ }
|
|
5579
|
+
finally { autoCheckInflight = false; }
|
|
5580
|
+
};
|
|
5581
|
+
if (cfg.autoUpdate) {
|
|
5582
|
+
setTimeout(() => { void runAutoCheck(); }, 800);
|
|
5583
|
+
autoCheckTimer = setInterval(() => { void runAutoCheck(); }, upd.AUTO_UPDATE_INTERVAL_MS);
|
|
5584
|
+
if (autoCheckTimer.unref) autoCheckTimer.unref();
|
|
5585
|
+
}
|
|
5586
|
+
|
|
5587
|
+
|
|
4187
5588
|
let keyBuf = '';
|
|
4188
5589
|
let escTimer = null;
|
|
4189
5590
|
let escRetries = 0;
|
|
@@ -4248,12 +5649,13 @@ export async function startTUI(opts) {
|
|
|
4248
5649
|
const { cols } = dims();
|
|
4249
5650
|
return (t.col || 1) === cols;
|
|
4250
5651
|
}
|
|
4251
|
-
|
|
5652
|
+
// Drag the thumb. `rowF` may be fractional (the pointer reports sub-cell
|
|
5653
|
+
// resolution), and `grabOffset` remembers where inside the thumb the press
|
|
5654
|
+
// landed so the thumb does not jump under the cursor on the first move.
|
|
5655
|
+
function scrollFromThumb(rowF) {
|
|
4252
5656
|
const sb = state._sb;
|
|
4253
5657
|
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);
|
|
5658
|
+
state.scroll = scrollFromThumbPos(sb, rowF, state.sbDragOffset || 0);
|
|
4257
5659
|
renderFrame();
|
|
4258
5660
|
}
|
|
4259
5661
|
function hitAt(t) {
|
|
@@ -4361,8 +5763,12 @@ export async function startTUI(opts) {
|
|
|
4361
5763
|
const sb = state._sb;
|
|
4362
5764
|
const { rowInBody } = mouseToCell(t);
|
|
4363
5765
|
state.sbDrag = true;
|
|
5766
|
+
// Grab INSIDE the thumb (so it does not jump under the pointer); off the
|
|
5767
|
+
// thumb, centre it on the pointer. Uses the FRACTIONAL length so the grab
|
|
5768
|
+
// point is not quantised to a whole row.
|
|
5769
|
+
const len = sb.thumbLenF != null ? sb.thumbLenF : sb.thumbLen;
|
|
4364
5770
|
state.sbDragOffset = (rowInBody >= sb.thumbStart && rowInBody < sb.thumbStart + sb.thumbLen)
|
|
4365
|
-
? rowInBody - sb.thumbStart :
|
|
5771
|
+
? rowInBody - sb.thumbStart : len / 2;
|
|
4366
5772
|
scrollFromThumb(rowInBody);
|
|
4367
5773
|
return;
|
|
4368
5774
|
}
|
|
@@ -4668,6 +6074,7 @@ export async function startTUI(opts) {
|
|
|
4668
6074
|
const items = [];
|
|
4669
6075
|
if (hasSel) items.push({ label: 'Copy', sub: 'copy the selected text', action: 'copy', primary: true });
|
|
4670
6076
|
if (hasComposerSel) items.push({ label: 'Copy', sub: 'copy the selected composer text', action: 'copy-composer', primary: true });
|
|
6077
|
+
if (hasComposerSel) items.push({ label: 'Cut', sub: 'cut the selected composer text to the clipboard', action: 'cut-composer' });
|
|
4671
6078
|
items.push({ label: 'Copy last answer', sub: 'copy the last assistant message', action: 'copy-last' });
|
|
4672
6079
|
items.push({ label: 'Clear selection', sub: 'drop the current selection', action: 'clear' });
|
|
4673
6080
|
if (hasComposerSel) items.push({ label: 'Clear composer selection', sub: 'drop the composer selection', action: 'clear-composer' });
|
|
@@ -4686,6 +6093,17 @@ export async function startTUI(opts) {
|
|
|
4686
6093
|
const h = Math.max(sel.anchor, sel.head);
|
|
4687
6094
|
copyToClipboard((state.input || '').slice(a, h));
|
|
4688
6095
|
}
|
|
6096
|
+
else if (it.action === 'cut-composer') {
|
|
6097
|
+
// Same operation as Ctrl+X: to the clipboard, then out of the composer.
|
|
6098
|
+
const sel = state.composerSel;
|
|
6099
|
+
const a = Math.min(sel.anchor, sel.head);
|
|
6100
|
+
const h = Math.max(sel.anchor, sel.head);
|
|
6101
|
+
copyToClipboard((state.input || '').slice(a, h));
|
|
6102
|
+
state.input = (state.input || '').slice(0, a) + (state.input || '').slice(h);
|
|
6103
|
+
state.caret = a;
|
|
6104
|
+
state.composerSel = null;
|
|
6105
|
+
refreshMenu(state);
|
|
6106
|
+
}
|
|
4689
6107
|
else if (it.action === 'copy-last') {
|
|
4690
6108
|
const last = [...state.chat].reverse().find((m) => m.role === 'assistant' && (m.text || '').trim());
|
|
4691
6109
|
if (last) copyToClipboard(last.text); else notice('No assistant message to copy', 'error');
|
|
@@ -4705,6 +6123,9 @@ export async function startTUI(opts) {
|
|
|
4705
6123
|
}
|
|
4706
6124
|
|
|
4707
6125
|
function handleKey(t) {
|
|
6126
|
+
// Full-screen takeovers own EVERY key while open.
|
|
6127
|
+
if (state.tasksViewer) { handleTasksViewerKey(t); return; }
|
|
6128
|
+
if (state.tasksPanel) { handleTasksPanelKey(t); return; }
|
|
4708
6129
|
if (t.key === 'mousedown' || t.key === 'mousemove' || t.key === 'mouseup' || t.key === 'rightclick' || t.key === 'mousehover') {
|
|
4709
6130
|
handleMouse(t);
|
|
4710
6131
|
return;
|
|
@@ -4730,13 +6151,40 @@ export async function startTUI(opts) {
|
|
|
4730
6151
|
// keep drafting while deciding.
|
|
4731
6152
|
if (t.key === 'c-c') return;
|
|
4732
6153
|
}
|
|
6154
|
+
// Plan approval: the model produced a <plan> and Plan mode is waiting.
|
|
6155
|
+
// Enter = approve (Plan turns off, the plan is sent back to be executed).
|
|
6156
|
+
// Esc = keep planning, do NOT execute.
|
|
6157
|
+
if (state.planPending) {
|
|
6158
|
+
if (t.key === 'enter' || t.key === ' ') {
|
|
6159
|
+
const resolve = state.planPending.resolve;
|
|
6160
|
+
state.planPending = null;
|
|
6161
|
+
resolve(true);
|
|
6162
|
+
renderFrame();
|
|
6163
|
+
return;
|
|
6164
|
+
}
|
|
6165
|
+
if (t.key === 'escape') {
|
|
6166
|
+
const resolve = state.planPending.resolve;
|
|
6167
|
+
state.planPending = null;
|
|
6168
|
+
resolve(false);
|
|
6169
|
+
renderFrame();
|
|
6170
|
+
return;
|
|
6171
|
+
}
|
|
6172
|
+
if (t.key === 'c-c') return; // do not exit while a plan is under review
|
|
6173
|
+
}
|
|
4733
6174
|
if (t.key === 'c-c') {
|
|
4734
6175
|
// An open overlay consumes Ctrl+C: close it instead of interrupting the
|
|
4735
|
-
// turn that is still running behind it.
|
|
4736
|
-
|
|
6176
|
+
// turn that is still running behind it. The modal editor belongs here too:
|
|
6177
|
+
// without it Ctrl+C fell through to the exit path below, armed the
|
|
6178
|
+
// "Press Ctrl+C again to exit" prompt behind the editor (which stays on
|
|
6179
|
+
// screen), and a second Ctrl+C — often the very key the user pressed again
|
|
6180
|
+
// to dismiss the prompt — quit the app and discarded the draft.
|
|
6181
|
+
if (state.menuOpen || state.picker || state.form || state.panel || state.question || state.editor) {
|
|
4737
6182
|
if (state.picker) { const cb = state.picker.onCancel; state.picker = null; if (cb) cb(); }
|
|
4738
6183
|
if (state.form) { const cb = state.form.onCancel; state.form = null; if (cb) cb(); }
|
|
6184
|
+
// Ctrl+C on the question dialog = dismiss, the same as Esc.
|
|
6185
|
+
if (state.question) { const r = state.question.resolve; state.question = null; r({}); }
|
|
4739
6186
|
state.panel = null;
|
|
6187
|
+
state.editor = null; // Ctrl+C cancels the editor, like Esc
|
|
4740
6188
|
state.menuOpen = false; state.menuList = []; state.menuSel = 0; state.menuOffset = 0;
|
|
4741
6189
|
renderFrame();
|
|
4742
6190
|
return;
|
|
@@ -4751,8 +6199,131 @@ export async function startTUI(opts) {
|
|
|
4751
6199
|
confirmTimer = setTimeout(() => { state.confirmExit = false; renderFrame(); }, 3000);
|
|
4752
6200
|
return;
|
|
4753
6201
|
}
|
|
6202
|
+
if (state.question) {
|
|
6203
|
+
// AskUserQuestion dialog. Owns ALL input while open — unlike the approval
|
|
6204
|
+
// prompt, the user is here to answer, and the free-text "Other" row needs
|
|
6205
|
+
// the character keys for itself.
|
|
6206
|
+
const qs = state.question;
|
|
6207
|
+
const cur = qs.items[qs.index] || { options: [] };
|
|
6208
|
+
const isLast = qs.index === qs.items.length - 1;
|
|
6209
|
+
// The list is: options, then "Other", then (last question only) the global
|
|
6210
|
+
// supplement row.
|
|
6211
|
+
const otherIdx = (cur.options || []).length;
|
|
6212
|
+
const suppIdx = isLast ? otherIdx + 1 : -1;
|
|
6213
|
+
const optCount = otherIdx + 1 + (isLast ? 1 : 0);
|
|
6214
|
+
// Resolve the call. `dismissed` distinguishes "Esc out of everything" from
|
|
6215
|
+
// "answered, and maybe left a note" so the tool can emit kimi's dismissal
|
|
6216
|
+
// contract instead of a half-filled result.
|
|
6217
|
+
const finish = (dismissed) => {
|
|
6218
|
+
const r = qs.resolve; state.question = null;
|
|
6219
|
+
r(dismissed ? {} : { answers: qs.answers, additional: (qs.supplement || '').trim() });
|
|
6220
|
+
renderFrame();
|
|
6221
|
+
};
|
|
6222
|
+
|
|
6223
|
+
// Move on after an answer was recorded.
|
|
6224
|
+
// * a further question -> ask it, cursor at the top
|
|
6225
|
+
// * the LAST question -> drop the cursor on the supplement row so the
|
|
6226
|
+
// very next Enter opens the note editor (or Esc/Enter twice skips it).
|
|
6227
|
+
// Jumping straight to `finish` made the row unreachable: `sel` stayed on
|
|
6228
|
+
// the option just picked, so a second Enter re-picked that option.
|
|
6229
|
+
const advance = () => {
|
|
6230
|
+
if (qs.index < qs.items.length - 1) {
|
|
6231
|
+
qs.index++;
|
|
6232
|
+
qs.sel = 0;
|
|
6233
|
+
qs.picked = new Set();
|
|
6234
|
+
renderFrame();
|
|
6235
|
+
return;
|
|
6236
|
+
}
|
|
6237
|
+
qs.sel = (cur.options || []).length + 1; // the supplement row
|
|
6238
|
+
renderFrame();
|
|
6239
|
+
};
|
|
6240
|
+
|
|
6241
|
+
if (qs.editing) {
|
|
6242
|
+
// Free-text entry, shared by the per-question "Other" and the global
|
|
6243
|
+
// supplement (qs.editing says which). Esc backs out WITHOUT losing what
|
|
6244
|
+
// was typed, so a half-written note survives a look at the options.
|
|
6245
|
+
if (t.key === 'escape') { qs.editing = null; renderFrame(); return; }
|
|
6246
|
+
if (t.key === 'backspace') { qs.editingText = (qs.editingText || '').slice(0, -1); renderFrame(); return; }
|
|
6247
|
+
if (t.paste !== undefined) { qs.editingText += String(t.paste).replace(/[\r\n]+/g, ' '); renderFrame(); return; }
|
|
6248
|
+
if (t.key === 'enter') {
|
|
6249
|
+
const answer = (qs.editingText || '').trim();
|
|
6250
|
+
if (qs.editing === 'supplement') {
|
|
6251
|
+
// The supplement is optional: Enter commits it (empty is fine) and
|
|
6252
|
+
// ends the call — it is the last row, so nothing follows it.
|
|
6253
|
+
qs.supplement = answer;
|
|
6254
|
+
qs.editing = null;
|
|
6255
|
+
finish(false);
|
|
6256
|
+
return;
|
|
6257
|
+
}
|
|
6258
|
+
if (!answer) { renderFrame(); return; } // nothing typed: stay
|
|
6259
|
+
qs.answers[cur.question] = answer;
|
|
6260
|
+
qs.otherText = '';
|
|
6261
|
+
qs.editing = null;
|
|
6262
|
+
advance();
|
|
6263
|
+
return;
|
|
6264
|
+
}
|
|
6265
|
+
if (t.ch) { qs.editingText = (qs.editingText || '') + t.ch; renderFrame(); return; }
|
|
6266
|
+
return;
|
|
6267
|
+
}
|
|
6268
|
+
|
|
6269
|
+
if (t.key === 'escape') { finish(true); return; } // dismiss: empty answers
|
|
6270
|
+
if (t.key === 'up') { qs.sel = (qs.sel - 1 + optCount) % optCount; renderFrame(); return; }
|
|
6271
|
+
if (t.key === 'down') { qs.sel = (qs.sel + 1) % optCount; renderFrame(); return; }
|
|
6272
|
+
|
|
6273
|
+
// Open a free-text editor, seeding it with whatever was typed before.
|
|
6274
|
+
const openEditor = (which) => {
|
|
6275
|
+
qs.editing = which;
|
|
6276
|
+
qs.editingText = which === 'supplement' ? (qs.supplement || '') : (qs.otherText || '');
|
|
6277
|
+
renderFrame();
|
|
6278
|
+
};
|
|
6279
|
+
|
|
6280
|
+
if (cur.multiSelect && t.key === ' ') {
|
|
6281
|
+
if (qs.sel >= otherIdx) { openEditor(qs.sel === suppIdx ? 'supplement' : 'other'); return; }
|
|
6282
|
+
if (qs.picked.has(qs.sel)) qs.picked.delete(qs.sel); else qs.picked.add(qs.sel);
|
|
6283
|
+
renderFrame(); return;
|
|
6284
|
+
}
|
|
6285
|
+
if (t.key === 'enter') {
|
|
6286
|
+
if (qs.sel >= otherIdx) { openEditor(qs.sel === suppIdx ? 'supplement' : 'other'); return; }
|
|
6287
|
+
if (cur.multiSelect) {
|
|
6288
|
+
// Enter CONFIRMS the multi-select; an empty selection is allowed (the
|
|
6289
|
+
// user may genuinely want none of them).
|
|
6290
|
+
const labels = [...qs.picked].sort((a, b) => a - b).map((i) => cur.options[i].label);
|
|
6291
|
+
if (labels.length) qs.answers[cur.question] = labels.join(', ');
|
|
6292
|
+
advance();
|
|
6293
|
+
return;
|
|
6294
|
+
}
|
|
6295
|
+
qs.answers[cur.question] = cur.options[qs.sel].label;
|
|
6296
|
+
advance();
|
|
6297
|
+
return;
|
|
6298
|
+
}
|
|
6299
|
+
return;
|
|
6300
|
+
}
|
|
6301
|
+
|
|
4754
6302
|
if (state.confirmExit) { state.confirmExit = false; if (confirmTimer) clearTimeout(confirmTimer); }
|
|
4755
6303
|
|
|
6304
|
+
// Ctrl+X / Ctrl+Shift+X — CUT. Removes the composer's selected text (or the
|
|
6305
|
+
// whole line when nothing is selected) and puts it on the clipboard, like a
|
|
6306
|
+
// normal editor. Ctrl+Shift+X is accepted as an alias because some terminals
|
|
6307
|
+
// only deliver the modified form.
|
|
6308
|
+
if (t.key === 'c-x' || t.key === 'c-s-x') {
|
|
6309
|
+
const cur = state.input || '';
|
|
6310
|
+
const sel = state.composerSel;
|
|
6311
|
+
if (sel && sel.anchor !== sel.head) {
|
|
6312
|
+
const a = Math.min(sel.anchor, sel.head);
|
|
6313
|
+
const h = Math.max(sel.anchor, sel.head);
|
|
6314
|
+
const removed = cur.slice(a, h);
|
|
6315
|
+
state.input = cur.slice(0, a) + cur.slice(h);
|
|
6316
|
+
state.caret = a;
|
|
6317
|
+
state.composerSel = null;
|
|
6318
|
+
copyToClipboard(removed);
|
|
6319
|
+
refreshMenu(state);
|
|
6320
|
+
} else {
|
|
6321
|
+
notice('Nothing selected to cut', 'error');
|
|
6322
|
+
}
|
|
6323
|
+
renderFrame();
|
|
6324
|
+
return;
|
|
6325
|
+
}
|
|
6326
|
+
|
|
4756
6327
|
if (t.key === 'c-s-c') {
|
|
4757
6328
|
// Ctrl+Shift+C: copy the mouse selection, else the last answer — the same
|
|
4758
6329
|
// two actions as the right-click menu's Copy / Copy last answer. Works
|
|
@@ -4967,6 +6538,7 @@ export async function startTUI(opts) {
|
|
|
4967
6538
|
return;
|
|
4968
6539
|
}
|
|
4969
6540
|
|
|
6541
|
+
|
|
4970
6542
|
if (state.form) {
|
|
4971
6543
|
const f = state.form;
|
|
4972
6544
|
const nRows = f.fields.length + (f.hideType ? 0 : 1);
|
|
@@ -5128,6 +6700,8 @@ export async function startTUI(opts) {
|
|
|
5128
6700
|
if (t.key === 'escape') {
|
|
5129
6701
|
state.menuOpen = false; state.menuList = []; state.menuSel = 0;
|
|
5130
6702
|
state.menuOffset = 0;
|
|
6703
|
+
state.mentionOpen = false; state.mentionList = []; state.mentionSel = 0;
|
|
6704
|
+
state.mentionOffset = 0;
|
|
5131
6705
|
state.input = ''; state.caret = 0;
|
|
5132
6706
|
state.pastes.clear(); state.pasteCounter = 0;
|
|
5133
6707
|
state.composerSel = null;
|
|
@@ -5144,50 +6718,28 @@ export async function startTUI(opts) {
|
|
|
5144
6718
|
return;
|
|
5145
6719
|
}
|
|
5146
6720
|
}
|
|
5147
|
-
|
|
5148
|
-
//
|
|
5149
|
-
if (
|
|
5150
|
-
const
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
const cwd = state.cwd || state.workspace || process.cwd();
|
|
5158
|
-
let items = [];
|
|
5159
|
-
try {
|
|
5160
|
-
const entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
5161
|
-
for (const entry of entries) {
|
|
5162
|
-
const name = entry.name;
|
|
5163
|
-
// Skip hidden files by default
|
|
5164
|
-
if (name.startsWith('.')) continue;
|
|
5165
|
-
|
|
5166
|
-
const fullPath = path.join(cwd, name);
|
|
5167
|
-
if (entry.isDirectory()) {
|
|
5168
|
-
items.push({ label: name + '/', type: 'dir', path: fullPath });
|
|
5169
|
-
} else {
|
|
5170
|
-
items.push({ label: name, type: 'file', path: fullPath });
|
|
5171
|
-
}
|
|
5172
|
-
}
|
|
5173
|
-
} catch (e) {
|
|
5174
|
-
// If we can't read the directory, just ignore
|
|
5175
|
-
}
|
|
5176
|
-
|
|
5177
|
-
// Filter by suffix
|
|
5178
|
-
if (suffix) {
|
|
5179
|
-
const lowerSuffix = suffix.toLowerCase();
|
|
5180
|
-
items = items.filter((it) => it.label.toLowerCase().startsWith(lowerSuffix));
|
|
5181
|
-
}
|
|
5182
|
-
|
|
5183
|
-
if (items.length > 0) {
|
|
5184
|
-
// Auto-complete to first match
|
|
5185
|
-
const match = items[0];
|
|
5186
|
-
state.input = prefix + match.label;
|
|
5187
|
-
state.caret = state.input.length;
|
|
6721
|
+
// The inline `@file` list owns ↑↓/Tab/Enter/Esc while it is open, exactly like
|
|
6722
|
+
// the `/` menu above. Enter would otherwise SUBMIT the half-typed prompt.
|
|
6723
|
+
if (state.mentionOpen) {
|
|
6724
|
+
const n = state.mentionList.length;
|
|
6725
|
+
if (t.key === 'up' || t.key === 'down') {
|
|
6726
|
+
if (n) {
|
|
6727
|
+
state.mentionSel = (state.mentionSel + (t.key === 'down' ? 1 : n - 1)) % n;
|
|
6728
|
+
// Keep the selection inside the visible window.
|
|
6729
|
+
if (state.mentionSel < state.mentionOffset) state.mentionOffset = state.mentionSel;
|
|
6730
|
+
else if (state.mentionSel >= state.mentionOffset + MAX_MENU) state.mentionOffset = state.mentionSel - MAX_MENU + 1;
|
|
5188
6731
|
renderFrame();
|
|
5189
|
-
return;
|
|
5190
6732
|
}
|
|
6733
|
+
return;
|
|
6734
|
+
}
|
|
6735
|
+
if (t.key === 'tab' || t.key === 'enter') { acceptMention(); return; }
|
|
6736
|
+
if (t.key === 'escape') {
|
|
6737
|
+
state.mentionOpen = false;
|
|
6738
|
+
state.mentionList = [];
|
|
6739
|
+
state.mentionSel = 0;
|
|
6740
|
+
state.mentionOffset = 0;
|
|
6741
|
+
renderFrame();
|
|
6742
|
+
return;
|
|
5191
6743
|
}
|
|
5192
6744
|
}
|
|
5193
6745
|
if (t.key === 'up' || t.key === 'down') {
|
|
@@ -5218,7 +6770,12 @@ export async function startTUI(opts) {
|
|
|
5218
6770
|
renderFrame(); return;
|
|
5219
6771
|
}
|
|
5220
6772
|
if (t.key === 'wheelup' || t.key === 'wheeldown') {
|
|
5221
|
-
|
|
6773
|
+
// With the pointer ON the scrollbar, scroll one line per notch: the bar is
|
|
6774
|
+
// the precision control, and 3-line steps there feel jumpy on a long
|
|
6775
|
+
// transcript. Everywhere else keeps the usual 3-line step.
|
|
6776
|
+
const onBar = !!(state._lastMouse && isOnScrollbar(state._lastMouse));
|
|
6777
|
+
const step = onBar ? 1 : 3;
|
|
6778
|
+
const d = t.key === 'wheelup' ? step : -step;
|
|
5222
6779
|
if (state.panel) {
|
|
5223
6780
|
const p = state.panel;
|
|
5224
6781
|
p.top = Math.max(0, (p.top || 0) + (t.key === 'wheelup' ? -3 : 3));
|
|
@@ -5262,6 +6819,41 @@ export async function startTUI(opts) {
|
|
|
5262
6819
|
scrollChat(state, t.key === 'pageup' ? 10 : -10);
|
|
5263
6820
|
renderFrame(); return;
|
|
5264
6821
|
}
|
|
6822
|
+
// Shift+arrows EXTEND a composer selection instead of moving the caret. The
|
|
6823
|
+
// anchor is remembered on the first shift-move so moving back shrinks it.
|
|
6824
|
+
if (t.key && t.key.startsWith('shift-') && !state.editor && !state.picker && !state.form && !state.panel) {
|
|
6825
|
+
const dirName = t.key.slice(6); // left | right | up | down | home | end
|
|
6826
|
+
const cur = state.input || '';
|
|
6827
|
+
const caret = state.caret || 0;
|
|
6828
|
+
const anchor = state.composerSel ? state.composerSel.anchor : caret;
|
|
6829
|
+
let next = caret;
|
|
6830
|
+
if (dirName === 'left') {
|
|
6831
|
+
const mk = adjacentPasteMarker(cur, caret, -1);
|
|
6832
|
+
next = mk ? mk.start : Math.max(0, caret - 1);
|
|
6833
|
+
} else if (dirName === 'right') {
|
|
6834
|
+
const mk = adjacentPasteMarker(cur, caret, 1);
|
|
6835
|
+
next = mk ? mk.end : Math.min(cur.length, caret + 1);
|
|
6836
|
+
} else if (dirName === 'home') {
|
|
6837
|
+
next = 0;
|
|
6838
|
+
} else if (dirName === 'end') {
|
|
6839
|
+
next = cur.length;
|
|
6840
|
+
} else {
|
|
6841
|
+
// up/down: move a visual row keeping the column, like the plain arrows,
|
|
6842
|
+
// so a multi-line prompt can be selected across lines.
|
|
6843
|
+
const insideW = Math.max(0, dims().cols - 2);
|
|
6844
|
+
const layout = composerLayout(state, insideW - 3);
|
|
6845
|
+
const targetRow = layout.caretRow + (dirName === 'down' ? 1 : -1);
|
|
6846
|
+
if (targetRow < 0 || targetRow >= layout.rows.length) { renderFrame(); return; }
|
|
6847
|
+
const starts = rowStartOffsets(cur, layout.rows.length, insideW);
|
|
6848
|
+
const colInRow = Math.max(0, layout.caretCol - 1);
|
|
6849
|
+
const targetStart = starts[targetRow] != null ? starts[targetRow] : 0;
|
|
6850
|
+
next = Math.min(cur.length, targetStart + colInRow);
|
|
6851
|
+
}
|
|
6852
|
+
state.caret = next;
|
|
6853
|
+
state.composerSel = (anchor === next) ? null : { anchor, head: next };
|
|
6854
|
+
renderFrame(); return;
|
|
6855
|
+
}
|
|
6856
|
+
|
|
5265
6857
|
if (t.key === 'left' || t.key === 'right') {
|
|
5266
6858
|
const dir = t.key === 'left' ? -1 : 1;
|
|
5267
6859
|
const mk = adjacentPasteMarker(state.input, state.caret || 0, dir);
|
|
@@ -5339,7 +6931,11 @@ export async function startTUI(opts) {
|
|
|
5339
6931
|
state.caret--;
|
|
5340
6932
|
}
|
|
5341
6933
|
}
|
|
5342
|
-
|
|
6934
|
+
// Deleting can also change the mention (or delete the `@` itself), so the
|
|
6935
|
+
// inline list is refreshed the same way as on a printable key.
|
|
6936
|
+
refreshMention();
|
|
6937
|
+
if (!state.mentionOpen) refreshMenu(state);
|
|
6938
|
+
renderFrame(); state.composerSel = null; return;
|
|
5343
6939
|
}
|
|
5344
6940
|
if (t.key === 'delete') {
|
|
5345
6941
|
const sel = state.composerSel;
|
|
@@ -5369,10 +6965,16 @@ export async function startTUI(opts) {
|
|
|
5369
6965
|
state.input = state.input.slice(0, state.caret) + t.ch + state.input.slice(state.caret);
|
|
5370
6966
|
state.caret++;
|
|
5371
6967
|
state.composerSel = null;
|
|
5372
|
-
|
|
6968
|
+
// Any printable character can extend or start an `@mention`, so refresh the
|
|
6969
|
+
// inline list on every keystroke: typing `@` opens it, and typing after it
|
|
6970
|
+
// narrows it — the same live-feedback the `/` menu gives.
|
|
6971
|
+
refreshMention();
|
|
6972
|
+
if (!state.mentionOpen) refreshMenu(state);
|
|
6973
|
+
renderFrame();
|
|
5373
6974
|
}
|
|
5374
6975
|
}
|
|
5375
6976
|
|
|
6977
|
+
|
|
5376
6978
|
function rowStartOffsets(text, rowCount, insideW) {
|
|
5377
6979
|
const starts = [];
|
|
5378
6980
|
const prefix = ' > ';
|
|
@@ -5391,8 +6993,9 @@ export async function startTUI(opts) {
|
|
|
5391
6993
|
}
|
|
5392
6994
|
|
|
5393
6995
|
function statusExtra(state) {
|
|
6996
|
+
// No `notice` term: the notice shares the context row instead of taking a
|
|
6997
|
+
// row of its own, so it must not shrink the calculated chat viewport either.
|
|
5394
6998
|
return (state.confirmExit ? 1 : 0)
|
|
5395
|
-
+ (state.notice ? 1 : 0)
|
|
5396
6999
|
+ (state.running ? 1 : 0)
|
|
5397
7000
|
+ ((state.menuOpen && state.menuList.length) ? Math.min(state.menuList.length, MAX_MENU) + 1 : 0);
|
|
5398
7001
|
}
|
|
@@ -5419,12 +7022,18 @@ export async function startTUI(opts) {
|
|
|
5419
7022
|
state.pastes.clear(); state.pasteCounter = 0;
|
|
5420
7023
|
}
|
|
5421
7024
|
state.menuOpen = false; state.menuList = []; state.menuSel = 0;
|
|
7025
|
+
state.mentionOpen = false; state.mentionList = []; state.mentionSel = 0;
|
|
7026
|
+
state.mentionOffset = 0;
|
|
5422
7027
|
state.historyIdx = -1;
|
|
5423
7028
|
if (!text) { renderFrame(); return; }
|
|
5424
7029
|
if (!state.running) {
|
|
5425
7030
|
state.turnStart = Date.now();
|
|
5426
7031
|
// Start animation: 0.5s type out + 0.5s fill with orange = 1s total
|
|
5427
7032
|
state.startAnim = { start: Date.now() };
|
|
7033
|
+
// Marks the tick the steady pulse begins on, so the pulse can start its
|
|
7034
|
+
// own orange -> yellow sweep instead of inheriting whatever phase the
|
|
7035
|
+
// spinner happens to be in when the start animation ends.
|
|
7036
|
+
state.pulseStart = null;
|
|
5428
7037
|
}
|
|
5429
7038
|
|
|
5430
7039
|
if (state.history[state.history.length - 1] !== text) state.history.push(text);
|
|
@@ -5452,8 +7061,14 @@ export async function startTUI(opts) {
|
|
|
5452
7061
|
await runAgent(text);
|
|
5453
7062
|
}
|
|
5454
7063
|
|
|
5455
|
-
|
|
5456
|
-
|
|
7064
|
+
// `asSystem` injects the text as a SYSTEM message instead of a user message and
|
|
7065
|
+
// skips the user bubble. Used by the approved-plan handoff: the plan is not
|
|
7066
|
+
// something the user typed, so it must not appear (or persist) as their message.
|
|
7067
|
+
async function runAgent(text, opts = {}) {
|
|
7068
|
+
const asSystem = !!opts.asSystem;
|
|
7069
|
+
if (!asSystem) addChat({ role: 'user', text });
|
|
7070
|
+
// Fresh buffer for this turn's <plan> split (see appendAssistant).
|
|
7071
|
+
state._planBuf = '';
|
|
5457
7072
|
// A message the user JUST SENT should always bring the newest content into
|
|
5458
7073
|
// view, even if they were scrolled up reading history. (Queue/steer do NOT
|
|
5459
7074
|
// come through here, so they keep the view exactly where the user left it.)
|
|
@@ -5483,6 +7098,28 @@ export async function startTUI(opts) {
|
|
|
5483
7098
|
if (cfg.calmMode) {
|
|
5484
7099
|
sysText += '\n\n' + CALM_MODE_INSTRUCTION;
|
|
5485
7100
|
}
|
|
7101
|
+
// PLAN MODE (/plan): tell the model what to PRODUCE. The tool filter alone
|
|
7102
|
+
// only removed its write tools — this is what makes it emit a <plan> block.
|
|
7103
|
+
if (state.plan) {
|
|
7104
|
+
sysText += '\n\n' + PLAN_MODE_INSTRUCTION;
|
|
7105
|
+
}
|
|
7106
|
+
// SWARM MODE (/swarm): push the model toward parallel decomposition.
|
|
7107
|
+
if (state.swarm) {
|
|
7108
|
+
sysText += '\n\n' + SWARM_MODE_INSTRUCTION;
|
|
7109
|
+
}
|
|
7110
|
+
const personal = readPersonalPrompt(state.workspace || cfg.workspace);
|
|
7111
|
+
if (personal) {
|
|
7112
|
+
sysText += '\n\n' + personal;
|
|
7113
|
+
}
|
|
7114
|
+
// AGENTS.md: the PROJECT's own instructions, read fresh each turn like the
|
|
7115
|
+
// personal notes above, so an edit (or a fresh /init) takes effect on the next
|
|
7116
|
+
// prompt. Injected LAST and deepest-last so a nested file outranks the root one,
|
|
7117
|
+
// which is the precedence order codex documents. Previously /init wrote this
|
|
7118
|
+
// file and nothing ever loaded it.
|
|
7119
|
+
const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state.workspace || cfg.workspace);
|
|
7120
|
+
if (agents) {
|
|
7121
|
+
sysText += '\n\n' + agents;
|
|
7122
|
+
}
|
|
5486
7123
|
let messages = [{ role: 'system', content: sysText }];
|
|
5487
7124
|
if (session.messages && session.messages.length) {
|
|
5488
7125
|
for (const m of session.messages) {
|
|
@@ -5496,7 +7133,10 @@ export async function startTUI(opts) {
|
|
|
5496
7133
|
messages.push(m);
|
|
5497
7134
|
}
|
|
5498
7135
|
}
|
|
5499
|
-
|
|
7136
|
+
// The approved-plan handoff arrives as a SYSTEM message: it is an instruction
|
|
7137
|
+
// from the harness, not something the user typed, and it must not show up as a
|
|
7138
|
+
// user bubble or be persisted as one.
|
|
7139
|
+
messages.push(asSystem ? { role: 'system', content: text } : { role: 'user', content: text });
|
|
5500
7140
|
// 只持久化对话本身,system 不进 session。
|
|
5501
7141
|
Object.assign(session, { model: cfg.model, messages: messages.filter((m) => m.role !== 'system') });
|
|
5502
7142
|
sess.saveSession(session);
|
|
@@ -5509,24 +7149,61 @@ export async function startTUI(opts) {
|
|
|
5509
7149
|
cfg.toolFilter = undefined;
|
|
5510
7150
|
}
|
|
5511
7151
|
|
|
5512
|
-
|
|
5513
|
-
|
|
7152
|
+
// AUTO ("Never Ask") implies unrestricted paths: the mode already auto-approves
|
|
7153
|
+
// every tool, so ALSO lifting the tool-layer workspace check keeps behaviour
|
|
7154
|
+
// consistent. Without this the approval callback said "yes" while
|
|
7155
|
+
// resolvePath() still threw `Path outside workspace` — the mode looked broken.
|
|
7156
|
+
//
|
|
7157
|
+
// YOLO gets the same treatment for BASH only: its approval no longer runs the
|
|
7158
|
+
// workspace test (see onApproval), so the tool layer must not re-impose it.
|
|
7159
|
+
// Other tools keep the check, because for them "inside the workspace" is still
|
|
7160
|
+
// what decides approve-vs-ask.
|
|
7161
|
+
const mode = state.mode || 'ask';
|
|
7162
|
+
cfg.allowExternal = (mode === 'auto' || mode === 'yolo') ? true : cfg.allowExternal;
|
|
7163
|
+
// AskUserQuestion reads this to refuse in auto mode. Set here so the FIRST
|
|
7164
|
+
// tool call of the turn already sees it (onApproval re-syncs it live after).
|
|
7165
|
+
cfg.permissionMode = mode;
|
|
7166
|
+
|
|
5514
7167
|
// Approval callback: called before every tool execution
|
|
5515
7168
|
const onApproval = async (toolName, args) => {
|
|
5516
7169
|
// Read the mode LIVE: /permission, /yolo and /auto can change while the
|
|
5517
7170
|
// turn runs, and the decision must follow the CURRENT setting.
|
|
5518
7171
|
const cur = state.mode || 'ask';
|
|
7172
|
+
// Keep the TOOL LAYER in step with the LIVE mode. Agent snapshots cfg into
|
|
7173
|
+
// its own ctx at construction, so mutating cfg alone would not reach a turn
|
|
7174
|
+
// that is already running. Two things depend on this:
|
|
7175
|
+
// * allowExternal — resolvePath()'s workspace guard
|
|
7176
|
+
// * permissionMode — AskUserQuestion refuses to ask while auto is on
|
|
7177
|
+
if (state.agent && state.agent.ctx) {
|
|
7178
|
+
state.agent.ctx.allowExternal = (cur === 'auto') || !!cfg.allowExternal;
|
|
7179
|
+
state.agent.ctx.permissionMode = cur;
|
|
7180
|
+
}
|
|
5519
7181
|
if (cur === 'auto') return true;
|
|
5520
|
-
|
|
5521
|
-
//
|
|
5522
|
-
|
|
7182
|
+
// Read-only tools never need permission in any mode. AskUserQuestion is on
|
|
7183
|
+
// this list because ASKING the user is not a side effect — gating it behind
|
|
7184
|
+
// an approval prompt would mean answering two prompts to ask one question.
|
|
7185
|
+
// (In auto mode the TOOL refuses itself before any UI; see ctx.permissionMode.)
|
|
7186
|
+
const safeTools = ['Read', 'Grep', 'Glob', 'FetchURL', 'WebSearch', 'TaskOutput', 'TaskList', 'TaskStop', 'TaskWait', 'TodoList', 'FileLines', 'AskUserQuestion'];
|
|
5523
7187
|
if (safeTools.includes(toolName)) return true;
|
|
5524
7188
|
|
|
5525
7189
|
// YOLO ("Ask When Needed"): anything that stays INSIDE the workspace runs
|
|
5526
7190
|
// without asking — edits, writes and commands alike. Only work that touches
|
|
5527
7191
|
// a path OUTSIDE the workspace (or a command whose target we cannot prove is
|
|
5528
7192
|
// inside) needs the user.
|
|
5529
|
-
|
|
7193
|
+
//
|
|
7194
|
+
// BASH is the exception: a shell command's paths cannot be proven either way
|
|
7195
|
+
// (variables, pipes, subshells), so the old path heuristic flagged ordinary
|
|
7196
|
+
// commands like `ls /tmp` and asked about them. In yolo the user has already
|
|
7197
|
+
// said "run routine work", so Bash runs without the workspace test — only an
|
|
7198
|
+
// obviously destructive command still asks.
|
|
7199
|
+
if (cur === 'yolo') {
|
|
7200
|
+
if (toolName === 'Bash') {
|
|
7201
|
+
const cmd = String((args && (args.command || args.cmd)) || '');
|
|
7202
|
+
if (!isDestructiveCommand(cmd)) return true;
|
|
7203
|
+
} else if (isInsideWorkspace(state, toolName, args)) {
|
|
7204
|
+
return true;
|
|
7205
|
+
}
|
|
7206
|
+
}
|
|
5530
7207
|
|
|
5531
7208
|
return new Promise((resolve) => {
|
|
5532
7209
|
let desc = '';
|
|
@@ -5554,6 +7231,40 @@ export async function startTUI(opts) {
|
|
|
5554
7231
|
// Seed the agent with the persisted list: Agent() otherwise starts from an
|
|
5555
7232
|
// empty todoState, which wiped the panel on every new turn.
|
|
5556
7233
|
cfg.todoState = state.todos || [];
|
|
7234
|
+
// AskUserQuestion bridge: the tool calls ctx.askQuestion(questions, ctx) and
|
|
7235
|
+
// awaits the answers. Same promise-handshake the approval prompt uses, so the
|
|
7236
|
+
// agent simply blocks while the user reads and chooses.
|
|
7237
|
+
cfg.askQuestion = (questions, ctx) => new Promise((resolve) => {
|
|
7238
|
+
state.question = {
|
|
7239
|
+
items: questions,
|
|
7240
|
+
index: 0,
|
|
7241
|
+
sel: 0,
|
|
7242
|
+
picked: new Set(),
|
|
7243
|
+
answers: {},
|
|
7244
|
+
// Free-text state. `editing` is null | 'other' | 'supplement' and says
|
|
7245
|
+
// WHICH row is open; `editingText` is its live buffer. The committed
|
|
7246
|
+
// values live in `otherText` / `supplement` so an Esc back-out keeps them.
|
|
7247
|
+
editing: null,
|
|
7248
|
+
editingText: '',
|
|
7249
|
+
otherText: '',
|
|
7250
|
+
supplement: '',
|
|
7251
|
+
resolve: (answers) => resolve(answers),
|
|
7252
|
+
};
|
|
7253
|
+
// An aborted turn must not leave the dialog stranded on screen.
|
|
7254
|
+
const sig = ctx && ctx.signal;
|
|
7255
|
+
const onAbort = () => {
|
|
7256
|
+
if (state.question && state.question.resolve === resolve) {
|
|
7257
|
+
state.question = null;
|
|
7258
|
+
resolve({});
|
|
7259
|
+
renderFrame();
|
|
7260
|
+
}
|
|
7261
|
+
};
|
|
7262
|
+
if (sig) {
|
|
7263
|
+
if (sig.aborted) { onAbort(); return; }
|
|
7264
|
+
sig.addEventListener('abort', onAbort, { once: true });
|
|
7265
|
+
}
|
|
7266
|
+
renderFrame();
|
|
7267
|
+
});
|
|
5557
7268
|
const agent = new Agent({
|
|
5558
7269
|
// maxSteps omitted: the agent loop is uncapped (see agent.js).
|
|
5559
7270
|
cfg, messages, onApproval,
|
|
@@ -5627,10 +7338,18 @@ export async function startTUI(opts) {
|
|
|
5627
7338
|
return;
|
|
5628
7339
|
}
|
|
5629
7340
|
if (e.type === 'tool_start') {
|
|
7341
|
+
// A tool call means the model finished this step's reasoning and is now
|
|
7342
|
+
// acting on it. Settle the still-pending thinking block so its spinner
|
|
7343
|
+
// stops instead of turning forever while the tool runs, and so the block
|
|
7344
|
+
// reads as "reasoned, then acted" rather than as one endless thought.
|
|
7345
|
+
// The NEXT `think` event opens a new block under the tool output.
|
|
7346
|
+
const t = [...state.chat].reverse().find((m) => m.role === 'thinking' && m.pending);
|
|
7347
|
+
if (t) t.pending = false;
|
|
5630
7348
|
const dup = state.chat.some((m) => m.role === 'tool' && m.pending && m.id === e.id);
|
|
5631
7349
|
if (!dup) {
|
|
5632
|
-
state.chat.push({ role: 'tool', toolName: e.name, toolArgs: {}, pending: true, id: e.id, streamContent: '' });
|
|
7350
|
+
state.chat.push({ role: 'tool', toolName: e.name, toolArgs: {}, pending: true, id: e.id, streamContent: '', startAt: Date.now() });
|
|
5633
7351
|
}
|
|
7352
|
+
} else if (e.type === 'tool_args') {
|
|
5634
7353
|
} else if (e.type === 'tool_args') {
|
|
5635
7354
|
const entry = [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending && m.id === e.id);
|
|
5636
7355
|
if (entry) {
|
|
@@ -5648,6 +7367,15 @@ export async function startTUI(opts) {
|
|
|
5648
7367
|
|| [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending);
|
|
5649
7368
|
if (entry) {
|
|
5650
7369
|
entry.liveOutput = (entry.liveOutput || '') + e.chunk;
|
|
7370
|
+
// A swarm streams `[n/m] finished` lines as subagents land: advance the
|
|
7371
|
+
// matching cells from queued -> completed so the grid fills in live.
|
|
7372
|
+
if (entry.toolName === 'AgentSwarm' && Array.isArray(entry.swarmMembers)) {
|
|
7373
|
+
const done = (String(entry.liveOutput).match(/^\[\d+\/\d+\] finished$/gm) || []).length;
|
|
7374
|
+
entry.swarmMembers.forEach((mem, i) => {
|
|
7375
|
+
if (i < done) { mem.phase = 'completed'; mem.ratio = 1; }
|
|
7376
|
+
else if (i === done) { mem.phase = 'working'; mem.ratio = 0.5; }
|
|
7377
|
+
});
|
|
7378
|
+
}
|
|
5651
7379
|
renderSoon();
|
|
5652
7380
|
}
|
|
5653
7381
|
return;
|
|
@@ -5656,6 +7384,22 @@ export async function startTUI(opts) {
|
|
|
5656
7384
|
|| [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending && m.toolName === e.name);
|
|
5657
7385
|
if (entry) {
|
|
5658
7386
|
entry.toolArgs = { ...(entry.toolArgs || {}), ...(e.args || {}) };
|
|
7387
|
+
// AgentSwarm: turn the args into a live member list so the progress
|
|
7388
|
+
// block renders from the moment the swarm is announced. Every item
|
|
7389
|
+
// starts "queued" and flips as the tool reports progress.
|
|
7390
|
+
if (e.name === 'AgentSwarm') {
|
|
7391
|
+
const a = entry.toolArgs || {};
|
|
7392
|
+
entry.swarmDescription = a.description || '';
|
|
7393
|
+
entry.swarmModel = a.subagent_type || '';
|
|
7394
|
+
if (!entry.swarmMembers && Array.isArray(a.items)) {
|
|
7395
|
+
entry.swarmMembers = a.items.map(() => ({ phase: 'queued', ratio: 0, latestText: '' }));
|
|
7396
|
+
}
|
|
7397
|
+
}
|
|
7398
|
+
// Edit: build the +/- diff. The tool now reports the text it actually
|
|
7399
|
+
// replaced on the tool_result (`editDiff`), which is the only way a
|
|
7400
|
+
// LINE-RANGE edit (start_line/end_line/new_content) can produce a
|
|
7401
|
+
// diff — it has no `old_string` in its args. Args still seed a
|
|
7402
|
+
// provisional diff so the rows appear while the edit is still running.
|
|
5659
7403
|
if (e.name === 'Edit' && e.args && typeof e.args.old_string === 'string') {
|
|
5660
7404
|
let startLine = 1;
|
|
5661
7405
|
try {
|
|
@@ -5676,16 +7420,52 @@ export async function startTUI(opts) {
|
|
|
5676
7420
|
// A failed run must be visible on the TOOL line too: the status bullet
|
|
5677
7421
|
// turns red (see messageLines).
|
|
5678
7422
|
const failedRun = isFailureResult(e.content, e.name);
|
|
5679
|
-
if (entry) {
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
//
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
7423
|
+
if (entry) {
|
|
7424
|
+
entry.pending = false; entry.id = undefined; entry.failed = failedRun;
|
|
7425
|
+
// The AUTHORITATIVE diff comes from the tool itself (`editDiff`): it
|
|
7426
|
+
// knows the text it really replaced, so this is the only source that
|
|
7427
|
+
// covers line-range edits. Fall back to the provisional diff built
|
|
7428
|
+
// from the args for older/other callers.
|
|
7429
|
+
if (e.editDiff && typeof e.editDiff.old === 'string') {
|
|
7430
|
+
entry.diff = lineDiff(e.editDiff.old, e.editDiff.new, e.editDiff.startLine || 1);
|
|
7431
|
+
}
|
|
7432
|
+
// Hand the counts to the TOOL row (which keeps `+N -N` next to its
|
|
7433
|
+
// name) and the diff to the RESULT row (rendered beneath the `↳`).
|
|
7434
|
+
if (entry.diff) entry._diffCounts = entry.diff;
|
|
7435
|
+
// A finished swarm has no `↳` dump: the progress block IS the result.
|
|
7436
|
+
// Settle every member so the grid and the pip bar read as final.
|
|
7437
|
+
if (entry.toolName === 'AgentSwarm' && Array.isArray(entry.swarmMembers)) {
|
|
7438
|
+
entry.swarmFailed = failedRun;
|
|
7439
|
+
entry.swarmMembers.forEach((mem) => {
|
|
7440
|
+
if (mem.phase === 'queued' || mem.phase === 'working' || mem.phase === 'prompting') {
|
|
7441
|
+
mem.phase = failedRun ? 'failed' : 'completed';
|
|
7442
|
+
}
|
|
7443
|
+
mem.ratio = mem.phase === 'completed' ? 1 : mem.ratio;
|
|
7444
|
+
});
|
|
7445
|
+
}
|
|
7446
|
+
}
|
|
7447
|
+
// An AgentSwarm is the ONE exception: it has already rendered its own
|
|
7448
|
+
// result as the live progress block above (cells + status pip bar), so a
|
|
7449
|
+
// `↳ Swarm finished: …` dump underneath would print the same thing twice.
|
|
7450
|
+
const isSwarm = e.name === 'AgentSwarm' && entry && Array.isArray(entry.swarmMembers);
|
|
7451
|
+
const resultMsg = normalizeMsg({
|
|
7452
|
+
role: 'tool_result', text: e.content, failed: failedRun,
|
|
7453
|
+
diff: e.name === 'Write' ? (entry && entry.diff || []) : (entry && entry.diff),
|
|
7454
|
+
});
|
|
7455
|
+
if (entry && !isSwarm) {
|
|
7456
|
+
const at = state.chat.indexOf(entry);
|
|
7457
|
+
// Skip any rows already sitting between the call and the transcript
|
|
7458
|
+
// tail (the result rows of EARLIER calls in this same step), so each
|
|
7459
|
+
// block reads call -> its result -> call -> its result.
|
|
7460
|
+
let ins = at + 1;
|
|
7461
|
+
while (ins < state.chat.length && state.chat[ins].role === 'tool_result') ins++;
|
|
7462
|
+
state.chat.splice(ins, 0, resultMsg);
|
|
7463
|
+
renderFrame();
|
|
7464
|
+
if ((state.scroll || 0) === 0) state.scroll = 0;
|
|
7465
|
+
} else if (!isSwarm) {
|
|
7466
|
+
addChat(resultMsg);
|
|
7467
|
+
} else {
|
|
7468
|
+
renderFrame();
|
|
5689
7469
|
}
|
|
5690
7470
|
// NOTE: queued input is deliberately NOT drained into the running turn
|
|
5691
7471
|
// here. A message typed while the agent works is a NEW turn, so it waits
|
|
@@ -5698,12 +7478,66 @@ export async function startTUI(opts) {
|
|
|
5698
7478
|
});
|
|
5699
7479
|
state.agent = agent;
|
|
5700
7480
|
if (agent.ctx && agent.ctx.tasks) state.tasks = agent.ctx.tasks;
|
|
7481
|
+
// A background subagent (Agent {run_in_background:true}, or an AgentSwarm)
|
|
7482
|
+
// finishes AFTER the turn that launched it returned. The tools call
|
|
7483
|
+
// `ctx._onBackgroundTaskDone`, which nothing installed, so the parent was
|
|
7484
|
+
// never told: its result sat in the task store until the user opened /tasks.
|
|
7485
|
+
// Post it into the transcript when it lands (mirroring how a queued message
|
|
7486
|
+
// arrives after the turn), so the parent model sees it on its next turn.
|
|
7487
|
+
if (agent.ctx) {
|
|
7488
|
+
agent.ctx._onBackgroundTaskDone = (task) => {
|
|
7489
|
+
if (!task) return;
|
|
7490
|
+
const status = task.status || 'completed';
|
|
7491
|
+
const head = status === 'completed'
|
|
7492
|
+
? `[background agent finished: ${task.taskId}]`
|
|
7493
|
+
: `[background agent ${status}: ${task.taskId}${task.stopReason ? ' - ' + task.stopReason : ''}]`;
|
|
7494
|
+
const body = String(task.output || '').trim();
|
|
7495
|
+
addChat({ role: 'system', text: body ? `${head}\n${body}` : head });
|
|
7496
|
+
};
|
|
7497
|
+
}
|
|
5701
7498
|
await agent.run();
|
|
5702
7499
|
messages = agent.messages;
|
|
5703
7500
|
if (agent.ctx && agent.ctx.tasks) state.tasks = agent.ctx.tasks;
|
|
5704
7501
|
state.agent = null;
|
|
5705
7502
|
const liveThink = [...state.chat].reverse().find((m) => m.role === 'thinking' && m.pending);
|
|
5706
7503
|
if (liveThink) liveThink.pending = false;
|
|
7504
|
+
|
|
7505
|
+
// ---- PLAN MODE: finalize the plan and ask to proceed --------------------
|
|
7506
|
+
// The plan bubble is ALREADY on screen (appendAssistant streams the text after
|
|
7507
|
+
// `<plan>` straight into it), so this only: closes the tags out of the prose,
|
|
7508
|
+
// asks for approval, and clears the streaming buffer.
|
|
7509
|
+
let approvedPlan = null;
|
|
7510
|
+
if (state.plan) {
|
|
7511
|
+
const lastAssistant = [...state.chat].reverse().find((m) => m.role === 'assistant' && (m.text || '').trim());
|
|
7512
|
+
// Prefer what arrived on the wire; fall back to scanning the prose for the
|
|
7513
|
+
// case where the tags were split across bubbles.
|
|
7514
|
+
const streamedPlan = [...state.chat].reverse().find((m) => m.role === 'plan');
|
|
7515
|
+
const planText = (streamedPlan && String(streamedPlan.text).trim())
|
|
7516
|
+
|| extractPlan(lastAssistant ? lastAssistant.text : '');
|
|
7517
|
+
if (planText) {
|
|
7518
|
+
// Make sure the bubble holds the final, complete text, and drop any
|
|
7519
|
+
// leftover tag remnants from the prose.
|
|
7520
|
+
if (streamedPlan) streamedPlan.text = planText;
|
|
7521
|
+
else addChat({ role: 'plan', text: planText });
|
|
7522
|
+
if (lastAssistant) {
|
|
7523
|
+
lastAssistant.text = String(lastAssistant.text).replace(/<\/?plan>/gi, '').trim();
|
|
7524
|
+
if (!lastAssistant.text.trim()) {
|
|
7525
|
+
const i = state.chat.indexOf(lastAssistant);
|
|
7526
|
+
if (i >= 0) state.chat.splice(i, 1);
|
|
7527
|
+
}
|
|
7528
|
+
}
|
|
7529
|
+
// ask / yolo -> the user decides; auto -> approved without asking, which
|
|
7530
|
+
// is the whole point of "Never Ask".
|
|
7531
|
+
const cur = state.mode || 'ask';
|
|
7532
|
+
const ok = cur === 'auto' ? true : await new Promise((resolve) => {
|
|
7533
|
+
state.planPending = { plan: planText, resolve };
|
|
7534
|
+
renderFrame();
|
|
7535
|
+
});
|
|
7536
|
+
state.planPending = null;
|
|
7537
|
+
if (ok) approvedPlan = planText;
|
|
7538
|
+
}
|
|
7539
|
+
}
|
|
7540
|
+
|
|
5707
7541
|
const approx = estimateMessagesTokens(messages, cfg);
|
|
5708
7542
|
state.ctxTokens = approx;
|
|
5709
7543
|
state.ctxMax = cfg.maxContextTokens || state.ctxMax;
|
|
@@ -5719,6 +7553,7 @@ export async function startTUI(opts) {
|
|
|
5719
7553
|
session.plan = !!state.plan;
|
|
5720
7554
|
session.planPath = state.planPath || null;
|
|
5721
7555
|
session.focus = !!state.focus;
|
|
7556
|
+
session.swarm = !!state.swarm;
|
|
5722
7557
|
sess.saveSession(session);
|
|
5723
7558
|
const turnMs = state.turnStart ? Date.now() - state.turnStart : 0;
|
|
5724
7559
|
const dur = fmtDuration(turnMs);
|
|
@@ -5758,6 +7593,19 @@ export async function startTUI(opts) {
|
|
|
5758
7593
|
const next = state.queued.shift();
|
|
5759
7594
|
if (next) { void submit(next); }
|
|
5760
7595
|
}
|
|
7596
|
+
// Approved plan: Plan mode OFF (write tools come back), then re-enter the agent
|
|
7597
|
+
// with the plan as a SYSTEM message. Deliberately NOT via submit(): that path
|
|
7598
|
+
// posts a user bubble and re-runs the whole submit flow, which is what produced
|
|
7599
|
+
// a stray user message and an extra turn teardown before the work started.
|
|
7600
|
+
// Done LAST so it cannot race the queue drain above.
|
|
7601
|
+
if (approvedPlan) {
|
|
7602
|
+
state.plan = false;
|
|
7603
|
+
session.plan = false;
|
|
7604
|
+
persistState();
|
|
7605
|
+
addChat({ role: 'system', text: 'Plan approved — Plan mode is off, executing.' });
|
|
7606
|
+
renderFrame();
|
|
7607
|
+
void runAgent(PLAN_EXECUTE_PREFIX, { asSystem: true });
|
|
7608
|
+
}
|
|
5761
7609
|
}
|
|
5762
7610
|
|
|
5763
7611
|
// Remember the session's working mode (permission / plan / focus / effort)
|
|
@@ -5769,6 +7617,7 @@ export async function startTUI(opts) {
|
|
|
5769
7617
|
session.plan = !!state.plan;
|
|
5770
7618
|
session.planPath = state.planPath || null;
|
|
5771
7619
|
session.focus = !!state.focus;
|
|
7620
|
+
session.swarm = !!state.swarm;
|
|
5772
7621
|
session.effort = state.effort || '';
|
|
5773
7622
|
// Theme removed - forced dark only
|
|
5774
7623
|
session.steps = state.steps || 0;
|
|
@@ -5778,7 +7627,7 @@ export async function startTUI(opts) {
|
|
|
5778
7627
|
}
|
|
5779
7628
|
|
|
5780
7629
|
const host = {
|
|
5781
|
-
addChat, openPicker, openForm, notice, openPanel, openEditor,
|
|
7630
|
+
addChat, openPicker, openForm, notice, openPanel, openEditor, openTasksPanel,
|
|
5782
7631
|
sendPrompt: (text) => { void runAgent(text); },
|
|
5783
7632
|
quit: () => { state._quit = true; },
|
|
5784
7633
|
saveSession: (s) => sess.saveSession(s),
|
|
@@ -5802,7 +7651,78 @@ export async function startTUI(opts) {
|
|
|
5802
7651
|
state._anchorRows = rows;
|
|
5803
7652
|
}
|
|
5804
7653
|
|
|
7654
|
+
// Live split of the streamed reply into prose vs. <plan> block.
|
|
7655
|
+
// While Plan mode is on and the model is inside <plan>…</plan>, the text is
|
|
7656
|
+
// routed into a `plan` bubble so it renders in its frame AS IT ARRIVES — the old
|
|
7657
|
+
// behaviour buffered the whole reply and only showed the plan at turn end, so
|
|
7658
|
+
// the user watched prose and then the plan popped in.
|
|
5805
7659
|
function appendAssistant(text) {
|
|
7660
|
+
if (state.plan && typeof text === 'string' && text) {
|
|
7661
|
+
state._planBuf = (state._planBuf || '') + text;
|
|
7662
|
+
const buf = state._planBuf;
|
|
7663
|
+
const open = buf.toLowerCase().indexOf('<plan>');
|
|
7664
|
+
if (open === -1) {
|
|
7665
|
+
// Still in the prose part (or the tag has not finished arriving).
|
|
7666
|
+
const last = state.chat[state.chat.length - 1];
|
|
7667
|
+
if (last && last.role === 'assistant') last.text += text;
|
|
7668
|
+
else state.chat.push({ role: 'assistant', text });
|
|
7669
|
+
anchorScroll();
|
|
7670
|
+
return;
|
|
7671
|
+
}
|
|
7672
|
+
// Everything before the tag is prose; everything after it is the plan body.
|
|
7673
|
+
const prose = buf.slice(0, open);
|
|
7674
|
+
const body = buf.slice(open + 6);
|
|
7675
|
+
const close = body.toLowerCase().indexOf('</plan>');
|
|
7676
|
+
const planSoFar = close === -1 ? body : body.slice(0, close);
|
|
7677
|
+
setStreamedProse(prose);
|
|
7678
|
+
setStreamedPlan(planSoFar);
|
|
7679
|
+
anchorScroll();
|
|
7680
|
+
return;
|
|
7681
|
+
}
|
|
7682
|
+
appendAssistantRaw(text);
|
|
7683
|
+
}
|
|
7684
|
+
|
|
7685
|
+
// Replace the trailing assistant bubble with exactly `prose`.
|
|
7686
|
+
// Order is FIXED: prose bubbles are BEFORE the plan bubble (the plan is last).
|
|
7687
|
+
// Pushing an assistant AFTER the plan put `plan` mid-list on the next update, so
|
|
7688
|
+
// setStreamedPlan saw a non-plan tail, pushed ANOTHER plan, and repeated — every
|
|
7689
|
+
// stream chunk created a new plan bubble (the "1, 12, 123, 1234" spam the user
|
|
7690
|
+
// reported). Find the prose bubble that PRECEDES the plan and update it.
|
|
7691
|
+
function setStreamedProse(prose) {
|
|
7692
|
+
const trimmed = String(prose).replace(/^\s*\n/, '');
|
|
7693
|
+
// Find the FIRST plan bubble; prose must be updated BEFORE it, never after.
|
|
7694
|
+
let planIdx = -1;
|
|
7695
|
+
for (let i = 0; i < state.chat.length; i++) {
|
|
7696
|
+
if (state.chat[i].role === 'plan') { planIdx = i; break; }
|
|
7697
|
+
}
|
|
7698
|
+
// Look for the assistant bubble right BEFORE the plan; with no plan, take the
|
|
7699
|
+
// LAST assistant. This loop indexes UP TO planIdx so it can never miss one —
|
|
7700
|
+
// the old code broke at the first plan it hit from the tail, so it never saw
|
|
7701
|
+
// the assistant in front of it and duplicated the prose on every chunk.
|
|
7702
|
+
const bound = planIdx >= 0 ? planIdx : state.chat.length;
|
|
7703
|
+
let proseIdx = -1;
|
|
7704
|
+
for (let i = bound - 1; i >= 0; i--) {
|
|
7705
|
+
if (state.chat[i].role === 'assistant') { proseIdx = i; break; }
|
|
7706
|
+
}
|
|
7707
|
+
if (proseIdx >= 0) {
|
|
7708
|
+
state.chat[proseIdx].text = trimmed;
|
|
7709
|
+
if (!trimmed.trim()) state.chat.splice(proseIdx, 1); // nothing left: drop it
|
|
7710
|
+
} else if (trimmed.trim()) {
|
|
7711
|
+
state.chat.unshift({ role: 'assistant', text: trimmed });
|
|
7712
|
+
}
|
|
7713
|
+
}
|
|
7714
|
+
|
|
7715
|
+
// Create/update the live plan bubble, always at the END of the transcript.
|
|
7716
|
+
function setStreamedPlan(planSoFar) {
|
|
7717
|
+
let last = state.chat[state.chat.length - 1];
|
|
7718
|
+
if (last && last.role === 'plan') {
|
|
7719
|
+
last.text = planSoFar;
|
|
7720
|
+
} else {
|
|
7721
|
+
state.chat.push({ role: 'plan', text: planSoFar });
|
|
7722
|
+
}
|
|
7723
|
+
}
|
|
7724
|
+
|
|
7725
|
+
function appendAssistantRaw(text) {
|
|
5806
7726
|
const last = state.chat[state.chat.length - 1];
|
|
5807
7727
|
if (last && last.role === 'assistant') {
|
|
5808
7728
|
last.text += text;
|
|
@@ -5844,11 +7764,15 @@ export async function startTUI(opts) {
|
|
|
5844
7764
|
function normalizeMsg(m) {
|
|
5845
7765
|
// Keep `failed`: a failed tool result must render in RED. Dropping it here
|
|
5846
7766
|
// made every failure look like ordinary gray output.
|
|
5847
|
-
|
|
7767
|
+
// Keep `diff`: an Edit's diff travels ON the tool_result message so it renders
|
|
7768
|
+
// below the `↳` receipt (see messageLines). Dropping it removed the diff.
|
|
7769
|
+
const out = {
|
|
5848
7770
|
role: m && m.role ? m.role : 'system',
|
|
5849
7771
|
text: m && m.text != null ? String(m.text) : '',
|
|
5850
7772
|
failed: !!(m && m.failed),
|
|
5851
7773
|
};
|
|
7774
|
+
if (m && Array.isArray(m.diff)) out.diff = m.diff;
|
|
7775
|
+
return out;
|
|
5852
7776
|
}
|
|
5853
7777
|
|
|
5854
7778
|
let stopped = false;
|
|
@@ -5861,6 +7785,9 @@ export async function startTUI(opts) {
|
|
|
5861
7785
|
if (spinTimer) clearInterval(spinTimer);
|
|
5862
7786
|
if (tokTimer) clearInterval(tokTimer);
|
|
5863
7787
|
if (confirmTimer) clearTimeout(confirmTimer);
|
|
7788
|
+
// The /tasks panel owns a flash timer; leaving it armed keeps the process
|
|
7789
|
+
// alive until it fires after exit.
|
|
7790
|
+
if (state.tasksPanel && state.tasksPanel.flashTimer) clearTimeout(state.tasksPanel.flashTimer);
|
|
5864
7791
|
try { sess.saveSession(session); } catch {}
|
|
5865
7792
|
stdout.write('\x1b[<u\x1b[?2004l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l');
|
|
5866
7793
|
stdout.write(alternateScreen(false));
|
|
@@ -5888,4 +7815,4 @@ export async function startTUI(opts) {
|
|
|
5888
7815
|
// as soon as the TUI is wired up; without a pending promise, main()'s await
|
|
5889
7816
|
// resolves immediately and Node exits (the "flash of UI then quit" bug).
|
|
5890
7817
|
return new Promise(() => {});
|
|
5891
|
-
}
|
|
7818
|
+
}
|