@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/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 = '0.1.0';
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: 'Analyze the codebase and generate AGENTS.md', priority: 70 },
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 and save the system prompt (persisted to config.toml)', priority: 60 },
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 thinking blocks',
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
- 'Thinking...', 'Working...', 'Crunching...', 'Processing...', 'Analyzing...', 'Pondering...', 'Mulling...', 'Considering...', 'Reasoning...', 'Deliberating...',
212
- 'Reflecting...', 'Ruminating...', 'Planning...', 'Mapping...', 'Sketching...', 'Outlining...', 'Structuring...', 'Organizing...', 'Sorting...', 'Ordering...',
213
- 'Prioritizing...', 'Focusing...', 'Concentrating...', 'Diving...', 'Digging...', 'Delving...', 'Probing...', 'Examining...', 'Inspecting...', 'Studying...',
214
- 'Reviewing...', 'Checking...', 'Verifying...', 'Validating...', 'Confirming...', 'Building...', 'Assembling...', 'Constructing...', 'Composing...', 'Crafting...',
215
- 'Creating...', 'Shaping...', 'Forming...', 'Forging...', 'Welding...', 'Stitching...', 'Weaving...', 'Knitting...', 'Threading...', 'Patching...',
216
- 'Fixing...', 'Repairing...', 'Rebuilding...', 'Reworking...', 'Refactoring...', 'Restructuring...', 'Solving...', 'Untangling...', 'Unraveling...', 'Detangling...',
217
- 'Deciphering...', 'Decoding...', 'Cracking...', 'Piecing...', 'Fitting...', 'Hunting...', 'Chasing...', 'Tracking...', 'Tracing...', 'Sniffing...',
218
- 'Writing...', 'Drafting...', 'Editing...', 'Revising...', 'Rewriting...', 'Proofreading...', 'Polishing...', 'Refining...', 'Honing...', 'Tuning...',
219
- 'Sharpening...', 'Tightening...', 'Trimming...', 'Pruning...', 'Cutting...', 'Merging...', 'Splicing...', 'Searching...', 'Scanning...', 'Sifting...',
220
- 'Filtering...', 'Collecting...', 'Gathering...', 'Compiling...', 'Indexing...', 'Fetching...', 'Retrieving...', 'Loading...', 'Unpacking...', 'Computing...',
221
- 'Calculating...', 'Measuring...', 'Estimating...', 'Calibrating...', 'Aligning...', 'Balancing...', 'Optimizing...', 'Streamlining...', 'Smoothing...', 'Cleaning...',
222
- 'Sweeping...', 'Tidying...', 'Clearing...', 'Expanding...', 'Growing...', 'Blooming...', 'Walking...', 'Strolling...', 'Marching...', 'Hiking...',
223
- 'Trekking...', 'Journeying...', 'Sailing...', 'Cruising...', 'Gliding...', 'Flying...', 'Soaring...', 'Floating...', 'Drifting...', 'Wandering...',
224
- 'Exploring...', 'Roaming...', 'Venturing...', 'Discovering...', 'Uncovering...', 'Unveiling...', 'Revealing...', 'Exposing...', 'Spinning...', 'Whirling...',
225
- 'Twirling...', 'Rotating...', 'Swirling...', 'Churning...', 'Turning...', 'Rolling...', 'Rocking...', 'Swaying...', 'Flowing...', 'Streaming...',
226
- 'Cascading...', 'Rippling...', 'Bubbling...', 'Sparkling...', 'Cooking...', 'Simmering...', 'Boiling...', 'Stewing...', 'Roasting...', 'Baking...',
227
- 'Grilling...', 'Frying...', 'Whisking...', 'Beating...', 'Stirring...', 'Mixing...', 'Blending...', 'Kneading...', 'Proofing...', 'Rising...',
228
- 'Fermenting...', 'Brewing...', 'Steeping...', 'Infusing...', 'Seasoning...', 'Garnishing...', 'Plating...', 'Serving...', 'Tasting...', 'Adjusting...',
229
- 'Perfecting...', 'Glazing...', 'Painting...', 'Drawing...', 'Designing...', 'Orchestrating...', 'Conducting...', 'Dancing...', 'Jigging...', 'Jiving...',
230
- 'Boogying...', 'Shimmying...', 'Grooving...', 'Bouncing...', 'Hopping...', 'Skipping...', 'Nurturing...', 'Tending...', 'Gardening...', 'Planting...',
231
- 'Seeding...', 'Sprouting...', 'Rooting...', 'Branching...', 'Watering...', 'Flourishing...', 'Thriving...', 'Sneaking...', 'Tiptoeing...', 'Creeping...',
232
- 'Charging...', 'Gunning...', 'Ramping...', 'Revving...', 'Warming...', 'Powering...', 'Igniting...', 'Kindling...', 'Sparking...', 'Blazing...',
233
- 'Burning...', 'Glowing...', 'Illuminating...', 'Enlightening...', 'Brightening...', 'Dawning...', 'Breaking...', 'Leaping...', 'Jumping...', 'Bounding...',
234
- 'Springing...', 'Launching...', 'Propelling...', 'Accelerating...', 'Waiting...', 'Holding...', 'Steadying...', 'Progressing...', 'Advancing...', 'Continuing...',
235
- 'Persisting...', 'Persevering...', 'Standing...', 'Readying...', 'Preparing...', 'Sussing...', 'Sleuthing...', 'Orienting...', 'Locating...', 'Targeting...',
236
- 'Acquiring...', 'Syncing...', 'Harmonizing...', 'Lining...', 'Wrapping...', 'Finalizing...', 'Landing...', 'Nailing...', 'Sealing...', 'Rethinking...',
237
- 'Reassessing...', 'Revisiting...', 'Retracing...', 'Replaying...', 'Rehearsing...', 'Simulating...', 'Modeling...', 'Prototyping...', 'Testing...', 'Iterating...',
238
- 'Rendering...', 'Repainting...', 'Compositing...', 'Wiring...', 'Linking...', 'Bundling...', 'Packing...', 'Shipping...', 'Deploying...', 'Juggling...',
239
- 'Weighing...', 'Distilling...', 'Extracting...', 'Parsing...', 'Formatting...', 'Normalizing...', 'Sanitizing...', 'Locking...', 'Securing...', 'Guarding...',
240
- 'Watching...', 'Monitoring...', 'Surveying...', 'Scouting...', 'Reconnoitering...', 'Concocting...', 'Devising...', 'Inventing...', 'Imagining...', 'Envisioning...',
241
- 'Visualizing...', 'Picturing...', 'Dreaming...', 'Wondering...', 'Marveling...', 'Admiring...', 'Appreciating...', 'Enjoying...', 'Savoring...', 'Relishing...',
242
- 'Loving...', 'Cherishing...', 'Treasure-hunting...', 'Questing...', 'Seeking...', 'Striving...', 'Endeavoring...', 'Attempting...', 'Trying...', 'Experimenting...',
243
- 'Innovating...', 'Pioneering...', 'Trailblazing...', 'Pathfinding...', 'Navigating...', 'Steering...', 'Guiding...', 'Directing...', 'Captaining...', 'Piloting...',
244
- 'Commanding...', 'Leading...', 'Spearheading...', 'Championing...', 'Absorbing...', 'Accenting...', 'Acclaiming...', 'Accommodating...', 'Accounting...', 'Accrediting...',
245
- 'Accumulating...', 'Achieving...', 'Acknowledging...', 'Activating...', 'Adapting...', 'Adding...', 'Addressing...', 'Adhering...', 'Administering...', 'Adopting...',
246
- 'Adorning...', 'Advising...', 'Affirming...', 'Aggregating...', 'Agreing...', 'Aiming...', 'Airbrushing...', 'Alerting...', 'Allocating...', 'Allowing...',
247
- 'Altering...', 'Amalgamating...', 'Amending...', 'Amplifying...', 'Amusing...', 'Anchoring...', 'Animating...', 'Annexing...', 'Announcing...', 'Answering...',
248
- 'Anticipating...', 'Appealing...', 'Appending...', 'Applying...', 'Appointing...', 'Appraising...', 'Approaching...', 'Approving...', 'Archiving...', 'Arguing...',
249
- 'Arming...', 'Arranging...', 'Arraying...', 'Arresting...', 'Arriving...', 'Articulating...', 'Ascending...', 'Asserting...', 'Assessing...', 'Assigning...',
250
- 'Assimilating...', 'Assisting...', 'Assuring...', 'Astonishing...', 'Attaching...', 'Attacking...', 'Attaining...', 'Attending...', 'Attesting...', 'Attracting...',
251
- 'Auditing...', 'Augmenting...', 'Authenticating...', 'Authoring...', 'Authorizing...', 'Automating...', 'Averting...', 'Awakening...', 'Awarding...', 'Babbling...',
252
- 'Backing...', 'Backtracking...', 'Badging...', 'Baging...', 'Baiting...', 'Ballooning...', 'Banding...', 'Banking...', 'Bartering...', 'Basing...',
253
- 'Batching...', 'Bearing...', 'Bedazzling...', 'Befriending...', 'Begining...', 'Beholding...', 'Believing...', 'Belonging...', 'Bending...', 'Benefiting...',
254
- 'Beseeching...', 'Bestowing...', 'Beting...', 'Biding...', 'Billowing...', 'Binding...', 'Blasting...', 'Bleaching...', 'Bleeding...', 'Bleeping...',
255
- 'Blessing...', 'Blinking...', 'Blistering...', 'Blocking...', 'Bloting...', 'Blowing...', 'Bluring...', 'Boarding...', 'Boasting...', 'Bobing...',
256
- 'Bolstering...', 'Bombarding...', 'Bonding...', 'Booming...', 'Boosting...', 'Bootstraping...', 'Bordering...', 'Borrowing...', 'Botching...', 'Bottling...',
257
- 'Bowing...', 'Bowling...', 'Boxing...', 'Bracing...', 'Brainstorming...', 'Braising...', 'Branding...', 'Braving...', 'Breaching...', 'Breathing...',
258
- 'Breezing...', 'Bricking...', 'Bridging...', 'Briefing...', 'Bringing...', 'Bristling...', 'Broadening...', 'Brokering...', 'Bronzing...', 'Brooding...',
259
- 'Browsing...', 'Brushing...', 'Buckling...', 'Budgeting...', 'Buffering...', 'Buffing...', 'Bulking...', 'Bulletproofing...', 'Bumping...', 'Bunching...',
260
- 'Buoying...', 'Bursting...', 'Burying...', 'Busting...', 'Buttering...', 'Buzzing...', 'Caching...', 'Cadencing...', 'Cajoling...', 'Caking...',
261
- 'Calling...', 'Calming...', 'Camping...', 'Canceling...', 'Canoodling...', 'Canvasing...', 'Capitalizing...', 'Captioning...', 'Capturing...', 'Carbonizing...',
262
- 'Caring...', 'Carving...', 'Cashing...', 'Casting...', 'Cataloging...', 'Catapulting...', 'Catching...', 'Categorizing...', 'Catering...', 'Cautioning...',
263
- 'Ceasing...', 'Celebrating...', 'Cementing...', 'Censoring...', 'Centralizing...', 'Certifying...', 'Chaining...', 'Chairing...', 'Chalking...', 'Challenging...',
264
- 'Channeling...', 'Chanting...', 'Charting...', 'Chating...', 'Cheering...', 'Chewing...', 'Chilling...', 'Chiming...', 'Chiping...', 'Chirping...',
265
- 'Chiseling...', 'Choosing...', 'Choping...', 'Choreographing...', 'Chronicling...', 'Chuging...', 'Ciphering...', 'Circling...', 'Circulating...', 'Citing...',
266
- 'Civilizing...', 'Clamping...', 'Clanging...', 'Claping...', 'Clarifying...', 'Clashing...', 'Classifying...', 'Clawing...', 'Cleansing...', 'Cleaving...',
267
- 'Climbing...', 'Clinching...', 'Cliping...', 'Cloaking...', 'Clocking...', 'Cloning...', 'Closing...', 'Clouding...', 'Clustering...', 'Clutching...',
268
- 'Coaching...', 'Coalescing...', 'Coating...', 'Coaxing...', 'Cocooning...', 'Codifying...', 'Coercing...', 'Coexisting...', 'Cogitating...', 'Cohering...',
269
- 'Coiling...', 'Coinciding...', 'Collaborating...', 'Collapsing...', 'Collating...', 'Colliding...', 'Colonizing...', 'Coloring...', 'Combing...', 'Combining...',
270
- 'Comforting...', 'Commemorating...', 'Commencing...', 'Commenting...', 'Commissioning...', 'Commiting...', 'Communing...', 'Communicating...', 'Commuting...', 'Compacting...',
271
- 'Comparing...', 'Compassing...', 'Compeling...', 'Compensating...', 'Competing...', 'Complementing...', 'Completing...', 'Complicating...', 'Complimenting...', 'Comprehending...',
272
- 'Compressing...', 'Comprising...', 'Compromising...', 'Concealing...', 'Conceding...', 'Conceiving...', 'Conceptualizing...', 'Concerning...', 'Concluding...', 'Concuring...',
273
- 'Condensing...', 'Conditioning...', 'Condoning...', 'Confering...', 'Confessing...', 'Configuring...', 'Confining...', 'Confiscating...', 'Conflating...', 'Confronting...',
274
- 'Confusing...', 'Congealing...', 'Congratulating...', 'Conjuring...', 'Connecting...', 'Conquering...', 'Consecrating...', 'Consenting...', 'Conserving...', 'Consigning...',
275
- 'Consisting...', 'Consoling...', 'Consolidating...', 'Conspiring...', 'Constituting...', 'Constraining...', 'Consulting...', 'Consuming...', 'Contacting...', 'Containing...',
276
- 'Contemplating...', 'Contending...', 'Contenting...', 'Contesting...', 'Contracting...', 'Contrasting...', 'Contributing...', 'Contriving...', 'Controling...', 'Convening...',
277
- 'Converging...', 'Conversing...', 'Converting...', 'Conveying...', 'Convincing...', 'Convoying...', 'Cooling...', 'Cooperating...', 'Coordinating...', 'Copying...',
278
- 'Coring...', 'Corking...', 'Correlating...', 'Corresponding...', 'Corroborating...', 'Corraling...', 'Correcting...', 'Corrugating...', 'Cosseting...', 'Counseling...',
279
- 'Counting...', 'Coupling...', 'Coursing...', 'Covering...', 'Coveting...', 'Cradling...', 'Cranking...', 'Crashing...', 'Crawling...', 'Creasing...',
280
- 'Crediting...', 'Cresting...', 'Critiquing...', 'Crocheting...', 'Crooning...', 'Crossing...', 'Crowding...', 'Crowning...', 'Crumbing...', 'Crusading...',
281
- 'Crystallizing...', 'Cubing...', 'Cuddling...', 'Culling...', 'Cultivating...', 'Curbing...', 'Curating...', 'Curling...', 'Currying...', 'Cushioning...',
282
- 'Customizing...', 'Cuting...', 'Cycling...', 'Dabbling...', 'Daming...', 'Dampening...', 'Daring...', 'Darting...', 'Dashing...', 'Dating...',
283
- 'Dawdling...', 'Dazzling...', 'Deactivating...', 'Debuging...', 'Debuting...', 'Decanting...', 'Decelerating...', 'Decentralizing...', 'Decking...', 'Declaring...',
284
- 'Declining...', 'Decomposing...', 'Decorating...', 'Decoupling...', 'Decreasing...', 'Dedicating...', 'Deducing...', 'Deepening...', 'Defeating...', 'Defending...',
285
- 'Defering...', 'Defining...', 'Deflecting...', 'Deforming...', 'Defragmenting...', 'Defusing...', 'Degreasing...', 'Dehydrating...', 'Delegating...', 'Deleting...',
286
- 'Delivering...', 'Demanding...', 'Demarcating...', 'Demisting...', 'Demystifying...', 'Denoting...', 'Denouncing...', 'Densifying...', 'Departing...', 'Depending...',
287
- 'Depositing...', 'Depressurizing...', 'Deputing...', 'Deriving...', 'Descending...', 'Describing...', 'Deserting...', 'Desiring...', 'Despatching...', 'Detecting...',
288
- 'Detering...', 'Detoxing...', 'Devaluing...', 'Developing...', 'Deviating...', 'Devoting...', 'Diping...', 'Disabling...', 'Disarming...', 'Disassembling...',
289
- 'Disbursing...', 'Discarding...', 'Discerning...', 'Discharging...', 'Disciplining...', 'Disclosing...', 'Disconnecting...', 'Discontinuing...', 'Discounting...', 'Discoursing...',
290
- 'Discrediting...', 'Discriminating...', 'Discussing...', 'Disembarking...', 'Disentangling...', 'Disguising...', 'Disinfecting...', 'Disliking...', 'Dismantling...', 'Dismissing...',
291
- 'Dispatching...', 'Dispeling...', 'Dispensing...', 'Dispersing...', 'Displaying...', 'Disposing...', 'Disproving...', 'Dissecting...', 'Disseminating...', 'Dissipating...',
292
- 'Dissolving...', 'Distinguishing...', 'Distracting...', 'Distributing...', 'Disturbing...', 'Diverting...', 'Divining...', 'Dividing...', 'Divulging...', 'Docking...',
293
- 'Documenting...', 'Dodging...', 'Domesticating...', 'Dominating...', 'Donating...', 'Doodling...', 'Dosing...', 'Doting...', 'Doubling...', 'Doubting...',
294
- 'Downgrading...', 'Downloading...', 'Draging...', 'Draining...',
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
- let text = v.split('\n')[0];
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
- const inCycle = spin % CYCLE;
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
- // Base cyan (c), pale cyan (l = c + 25% toward white), cyan-blue (b = c with reduced green)
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 = [64, 239, 255]; // pale cyan (like YELLOW: c + white)
833
- const b = [0, 180, 255]; // cyan-blue (like RED: c with less green)
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
- if (name === 'Edit' && !msg.pending && msg.diff && msg.diff.length) {
889
- const adds = msg.diff.filter((d) => d.type === 'add').length;
890
- const deletes = msg.diff.filter((d) => d.type === 'del').length;
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: show the diff once the tool has finished, as
1118
- // <line-no> - old text
1119
- // <line-no> + new text
1120
- if (name === 'Edit' && !msg.pending && msg.diff && msg.diff.length) {
1121
- const MAX = 40;
1122
- const changed = msg.diff.filter((d) => d.type !== 'ctx');
1123
- const width = Math.max(1, ...changed.map((d) => String(d.no || 0).length));
1124
- for (const d of changed.slice(0, MAX)) {
1125
- const no = String(d.no || 0).padStart(width);
1126
- const mark = d.type === 'add' ? '+' : '-';
1127
- const color = d.type === 'add' ? C.green : C.red;
1128
- out.push({ text: col(`${no} ${mark} ${d.text}`, color), ind: indent, raw: true });
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
- const rawLines = String(msg.text || '').replace(/\r\n/g, '\n').split('\n');
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
- const out = [];
1161
- shown.forEach((ln, idx) => {
1162
- const isErr = /^\[error:/.test(ln.trim());
1163
- // A failed run is red throughout so it cannot be skimmed past.
1164
- const color = isErr ? C.red : (failed ? C.red : C.gray);
1165
- const wrapped = ln === '' ? [''] : wrapWords(ln, bodyW);
1166
- wrapped.forEach((t, i) => {
1167
- out.push({ text: t, ind: idx === 0 && i === 0 ? pre : cont, color });
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
- if (visible.length > MAX_RESULT_LINES) {
1171
- out.push({ text: `… ${visible.length - MAX_RESULT_LINES} more lines`, ind: cont, color: failed ? C.red : C.gray });
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
- const spin = SPINNER[(msg.spin || 0) % SPINNER.length];
1195
- out.push({ text: spin + ' thinking…', ind: '', color: C.gray });
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
- const key = `${rowW}\u0000${expanded ? 1 : 0}\u0000${msg.pending ? (state.spin || 0) : 0}\u0000${msg.text || ''}\u0000${msg.streamContent || ''}`
1559
- + `\u0000${msg.liveOutput ? msg.liveOutput.length : 0}\u0000${msg.failed ? 1 : 0}\u0000${JSON.stringify(msg.toolArgs || null)}`;
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
- if (mode === 'auto') return 'Never Ask';
1597
- if (mode === 'yolo') return 'Ask When Needed';
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
- // 1year 1mon 1day 1hour 1min 1s / 2years 2mons 2days 2hours 2mins 2s
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' + (year > 1 ? 's' : ''));
1640
- if (mon) parts.push(mon + 'm' + (mon > 1 ? 's' : '') + ' ');
1641
- if (day) parts.push(day + 'd' + (day > 1 ? 's' : '') + ' ');
1642
- if (hour) parts.push(hour + 'h' + (hour > 1 ? 's' : '') + ' ');
1643
- if (min) parts.push(min + 'm' + (min > 1 ? 's' : '') + ' ');
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 thumb = Math.max(1, Math.round(bodyH * (bodyH / Math.max(1, total))));
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 maxThumbStart = Math.max(0, bodyH - thumb);
1685
- const thumbStart = Math.round(fromTop * maxThumbStart);
1686
- return { thumbStart, thumbLen: thumb, bodyH, total, maxScroll };
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
- const dialog = state.picker || state.form || state.panel || null;
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
- const bottomH = todoH + queueH + workingH + composerBoxH + menuItems + STATUS_H + noticeH + confirmH + CTX_H;
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
- const viewH = Math.max(1, bodyH - (ed.notice ? 6 : 5));
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: 3 + Math.max(0, Math.min(viewH - 1, ed.caretRow - top)),
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
- } else if (state.picker) {
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
- } else {
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
- const elapsed = state.turnStart ? ` ${col('[' + fmtDuration(Date.now() - state.turnStart) + ']', C.gray)}` : '';
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], C.gray) : ' ');
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], C.gray) : ' ');
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, C.orange) + ' ' + out.join(''));
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(GREY[0] + (ORANGE[0] - GREY[0]) * tChar);
2174
- const g = Math.round(GREY[1] + (ORANGE[1] - GREY[1]) * tChar);
2175
- const b = Math.round(GREY[2] + (ORANGE[2] - GREY[2]) * tChar);
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, C.orange) + ' ' + out.join('') + elapsed);
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
- const ORANGE = [255, 140, 0];
2211
- const GREY = [150, 150, 150];
2212
- const r = Math.round(ORANGE[0] + (GREY[0] - ORANGE[0]) * t);
2213
- const g = Math.round(ORANGE[1] + (GREY[1] - ORANGE[1]) * t);
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, C.gray));
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
- const inCycle = spin % CYCLE;
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
- const ORANGE = [255, 140, 0];
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
- ORANGE[0] + (target[0] - ORANGE[0]) * k,
2253
- ORANGE[1] + (target[1] - ORANGE[1]) * k,
2254
- ORANGE[2] + (target[2] - ORANGE[2]) * k,
2255
- ORANGE[0] + (target[0] - ORANGE[0]) * k,
2256
- ORANGE[1] + (target[1] - ORANGE[1]) * k,
2257
- ORANGE[2] + (target[2] - ORANGE[2]) * k,
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, C.orange) + ' ' + out.join('') + elapsed);
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.magenta + C.bold)
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
- if (state.slMode !== false) parts.push(col(modeLabel(state.mode), C.yellow));
2412
- if (modeBadge) parts.push(modeBadge);
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').length;
2420
- const done = ts.length - running;
2421
- if (ts.length) {
2422
- parts.push(col(running ? `bg ${running}→${done}` : `bg ${done}`, running ? C.cyan : C.gray));
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
- const ctxPad = Math.max(0, w - ctxW);
2440
- lines.push(' '.repeat(ctxPad) + col(fitAnsi(ctxRight, Math.min(ctxW, w)), C.white));
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
- cursor = { row: Math.min(dialogCaret.row, h - 1), col: Math.min(dialogCaret.col, w - 1) };
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
- const bottomChrome = STATUS_H + (state.notice ? 1 : 0) + (state.confirmExit ? 1 : 0) + CTX_H + menuH;
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
- if (i === first) out += `\x1b[${first + 1};1H`;
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
- const tasks = Object.values(state.tasks || {});
3191
- if (!tasks.length) { app('No background tasks.'); return; }
3192
- const rows = tasks
3193
- .sort((a, b) => (b.start || 0) - (a.start || 0))
3194
- .map((t) => {
3195
- const secs = t.end ? Math.round((t.end - (t.start || t.end)) / 1000) : Math.round((Date.now() - (t.start || Date.now())) / 1000);
3196
- const extra = t.stopReason ? ` (${t.stopReason})` : '';
3197
- return ` #${t.id} [${t.status}${extra}] pid=${t.pid} ${secs}s ${t.description || t.command || ''}`;
3198
- });
3199
- say('Background tasks:\n' + rows.join('\n'));
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
- // Show the prompt currently in effect: the custom one if set, else the
3298
- // built-in. Saving writes it to config.toml; an EMPTY value clears the
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
- const current = custom || builtin;
3303
- openEditor({
3304
- title: custom
3305
- ? 'System prompt (custom — saving replaces it; clear it to restore the built-in)'
3306
- : 'System prompt (built-in — saving overrides it)',
3307
- text: current,
3308
- caretRow: 0,
3309
- caretCol: 0,
3310
- onSave: (text) => {
3311
- const value = String(text).trim();
3312
- try {
3313
- setConfigString('system_prompt', value);
3314
- } catch (e) { appErr('Could not write config.toml: ' + e.message); return; }
3315
- cfg.systemPrompt = value;
3316
- cfg.raw.system_prompt = value;
3317
- if (value) app(`System prompt saved (${value.length} chars) → ${hncodeConfigFile()}`);
3318
- else app('System prompt override cleared; the built-in prompt is in use again.');
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
- say('/init — analyzing the workspace to generate AGENTS.md…');
3345
- sendPrompt('Analyze this codebase and write an AGENTS.md file at the workspace root with concise instructions for future agents: project layout, build/test commands, and conventions.');
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 params = (str.slice(i + 2, j).match(/^\d+/) || [''])[0];
3920
- out.push({ key: csiName(params, fin) });
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
- if ((state.scroll || 0) === 0) state.scroll = 0;
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
- function scrollFromThumb(rowInBody) {
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
- const maxThumbStart = Math.max(1, sb.bodyH - sb.thumbLen);
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 : Math.floor(sb.thumbLen / 2);
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
- if (state.menuOpen || state.picker || state.form || state.panel) {
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
- // @ tab-completion for files/folders
5149
- if (t.key === 'tab' && !state.menuOpen) {
5150
- const input = state.input || '';
5151
- const atIdx = input.lastIndexOf('@');
5152
- if (atIdx >= 0) {
5153
- const prefix = input.slice(0, atIdx + 1); // include @
5154
- const suffix = input.slice(atIdx + 1); // text after @
5155
-
5156
- // Collect files and folders from current workspace
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
- const d = t.key === 'wheelup' ? 3 : -3;
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
- refreshMenu(state); renderFrame(); state.composerSel = null; return;
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
- refreshMenu(state); renderFrame();
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
- async function runAgent(text) {
5456
- addChat({ role: 'user', text });
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
- messages.push({ role: 'user', content: text });
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
- const mode = state.mode || 'ask';
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
- // Read-only tools never need permission in any mode.
5522
- const safeTools = ['Read', 'Grep', 'Glob', 'FetchURL', 'WebSearch', 'TaskOutput', 'TaskList', 'TaskStop', 'TaskWait', 'TodoList', 'FileLines'];
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
- if (cur === 'yolo' && isInsideWorkspace(state, toolName, args)) return true;
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) { entry.pending = false; entry.id = undefined; entry.failed = failedRun; }
5680
- const reason = failureReason(e.content, e.name);
5681
- const isEditLike = e.name === 'Edit' || e.name === 'Write';
5682
- if (!isEditLike) {
5683
- // Non-Edit tools show their raw result underneath.
5684
- addChat({ role: 'tool_result', text: e.content, failed: failedRun });
5685
- } else if (reason) {
5686
- // Edit/Write hide their body (the diff already shows the change), so
5687
- // surface a failure reason as its own red line under the tool call.
5688
- addChat({ role: 'tool_result', text: reason, failed: true });
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
- return {
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
+ }