@hncode/hncode 0.1.11 → 0.3.0

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