@hncode/hncode 0.1.8 → 0.1.9

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
@@ -20,7 +20,7 @@ import cp from 'node:child_process';
20
20
  import {
21
21
  C, blockCursor, showCursor, hideCursor, alternateScreen, clearScreen, clearAndSetBg, setTheme, THEME_NAMES, lerpColor,
22
22
  } from './colors.js';
23
- import { copyText, readText, warmClipboard, readImage } from './clipboard.js';
23
+ import { copyText, readText, warmClipboard, readClipboardContentAsync } from './clipboard.js';
24
24
  import { visualWidth, estimateTokens, estimateMessagesTokens, expandTabs } from './term.js';
25
25
  import { Agent, SYSTEM_PROMPT } from './agent.js';
26
26
  import { LLM } from './llm.js';
@@ -60,7 +60,6 @@ export const COMMANDS = [
60
60
  { name: 'usage', desc: 'Show session tokens + context window', priority: 60 },
61
61
  { name: 'mcp', desc: 'Show MCP server status', priority: 60 },
62
62
  { name: 'mcp-config', desc: 'Configure MCP servers (list / add / remove)', priority: 60 },
63
- { name: 'theme', desc: 'Set the terminal UI theme', priority: 60, argumentHint: '[auto|dark|light|<name>]' },
64
63
  { name: 'statusline', desc: 'Configure which items appear in the status line', priority: 60 },
65
64
 
66
65
  { name: 'export-md', aliases: ['export'], desc: 'Export current session as a Markdown file', priority: 40, argumentHint: '[output-path]' },
@@ -140,7 +139,6 @@ export const TIPS = [
140
139
  '/compact [ratio] AI-summarizes older history and keeps the most recent 20%',
141
140
  '/tasks lists background Bash jobs started with run_in_background',
142
141
  '/status prints the current model, provider, endpoint, and session id',
143
- '/theme switches between the auto / dark / light colour palettes',
144
142
  '/statusline toggles which items appear in the bottom status bar',
145
143
  '/add-dir grants the agent access to another directory for this session',
146
144
  '/reload re-reads config.toml without restarting hncode',
@@ -210,74 +208,90 @@ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇',
210
208
  // Rotating status messages for the "Working..." line — all mean "the agent is
211
209
  // thinking / working", but vary the wording so it doesn't look stuck.
212
210
  const WORKING_MESSAGES = [
213
- 'Thinking…', 'Working…', 'Crunching…', 'Processing…', 'Analyzing…',
214
- 'Pondering…', 'Mulling…', 'Considering…', 'Reasoning…', 'Deliberating…',
215
- 'Reflecting…', 'Ruminating…', 'Planning…', 'Mapping…', 'Sketching…',
216
- 'Outlining…', 'Structuring…', 'Organizing…', 'Sorting…', 'Ordering…',
217
- 'Prioritizing…', 'Focusing…', 'Concentrating…', 'Diving…', 'Digging…',
218
- 'Delving…', 'Probing…', 'Examining…', 'Inspecting…', 'Studying…',
219
- 'Reviewing…', 'Checking…', 'Verifying…', 'Validating…', 'Confirming…',
220
- 'Building…', 'Assembling…', 'Constructing…', 'Composing…', 'Crafting…',
221
- 'Creating…', 'Shaping…', 'Forming…', 'Forging…', 'Welding…',
222
- 'Stitching…', 'Weaving…', 'Knitting…', 'Threading…', 'Patching…',
223
- 'Fixing…', 'Repairing…', 'Rebuilding…', 'Reworking…', 'Refactoring…',
224
- 'Restructuring…', 'Solving…', 'Untangling…', 'Unraveling…', 'Detangling…',
225
- 'Deciphering…', 'Decoding…', 'Cracking…', 'Piecing…', 'Fitting…',
226
- 'Hunting…', 'Chasing…', 'Tracking…', 'Tracing…', 'Sniffing…',
227
- 'Writing…', 'Drafting…', 'Editing…', 'Revising…', 'Rewriting…',
228
- 'Proofreading…', 'Polishing…', 'Refining…', 'Honing…', 'Tuning…',
229
- 'Sharpening…', 'Tightening…', 'Trimming…', 'Pruning…', 'Cutting…',
230
- 'Merging…', 'Splicing…', 'Searching…', 'Scanning…', 'Sifting…',
231
- 'Filtering…', 'Collecting…', 'Gathering…', 'Compiling…', 'Indexing…',
232
- 'Fetching…', 'Retrieving…', 'Loading…', 'Unpacking…', 'Computing…',
233
- 'Calculating…', 'Measuring…', 'Estimating…', 'Calibrating…', 'Aligning…',
234
- 'Balancing…', 'Optimizing…', 'Streamlining…', 'Smoothing…', 'Cleaning…',
235
- 'Sweeping…', 'Tidying…', 'Clearing…', 'Expanding…', 'Growing…',
236
- 'Blooming…', 'Walking…', 'Strolling…', 'Marching…', 'Hiking…',
237
- 'Trekking…', 'Journeying…', 'Sailing…', 'Cruising…', 'Gliding…',
238
- 'Flying…', 'Soaring…', 'Floating…', 'Drifting…', 'Wandering…',
239
- 'Exploring…', 'Roaming…', 'Venturing…', 'Discovering…', 'Uncovering…',
240
- 'Unveiling…', 'Revealing…', 'Exposing…', 'Spinning…', 'Whirling…',
241
- 'Twirling…', 'Rotating…', 'Swirling…', 'Churning…', 'Turning…',
242
- 'Rolling…', 'Rocking…', 'Swaying…', 'Flowing…', 'Streaming…',
243
- 'Cascading…', 'Rippling…', 'Bubbling…', 'Sparkling…', 'Cooking…',
244
- 'Simmering…', 'Boiling…', 'Stewing…', 'Roasting…', 'Baking…',
245
- 'Grilling…', 'Frying…', 'Whisking…', 'Beating…', 'Stirring…',
246
- 'Mixing…', 'Blending…', 'Kneading…', 'Proofing…', 'Rising…',
247
- 'Fermenting…', 'Brewing…', 'Steeping…', 'Infusing…', 'Seasoning…',
248
- 'Garnishing…', 'Plating…', 'Serving…', 'Tasting…', 'Adjusting…',
249
- 'Perfecting…', 'Glazing…', 'Painting…', 'Drawing…', 'Designing…',
250
- 'Orchestrating…', 'Conducting…', 'Dancing…', 'Jigging…', 'Jiving…',
251
- 'Boogying…', 'Shimmying…', 'Grooving…', 'Bouncing…', 'Hopping…',
252
- 'Skipping…', 'Nurturing…', 'Tending…', 'Gardening…', 'Planting…',
253
- 'Seeding…', 'Sprouting…', 'Rooting…', 'Branching…', 'Watering…',
254
- 'Flourishing…', 'Thriving…', 'Sneaking…', 'Tiptoeing…', 'Creeping…',
255
- 'Gliding…', 'Charging…', 'Gunning…', 'Ramping…', 'Revving…',
256
- 'Warming…', 'Powering…', 'Igniting…', 'Kindling…', 'Sparking…',
257
- 'Blazing…', 'Burning…', 'Glowing…', 'Illuminating…', 'Enlightening…',
258
- 'Brightening…', 'Dawning…', 'Breaking…', 'Leaping…', 'Jumping…',
259
- 'Bounding…', 'Springing…', 'Launching…', 'Propelling…', 'Accelerating…',
260
- 'Waiting…', 'Holding…', 'Steadying…', 'Progressing…', 'Advancing…',
261
- 'Continuing…', 'Persisting…', 'Persevering…', 'Standing…', 'Readying…',
262
- 'Preparing…', 'Sussing…', 'Sleuthing…', 'Orienting…', 'Locating…',
263
- 'Targeting…', 'Acquiring…', 'Syncing…', 'Harmonizing…', 'Lining…',
264
- 'Wrapping…', 'Finalizing…', 'Landing…', 'Nailing…', 'Sealing…',
265
- 'Rethinking…', 'Reassessing…', 'Revisiting…', 'Retracing…', 'Replaying…',
266
- 'Rehearsing…', 'Simulating…', 'Modeling…', 'Prototyping…', 'Testing…',
267
- 'Iterating…', 'Rendering…', 'Repainting…', 'Compositing…', 'Wiring…',
268
- 'Linking…', 'Bundling…', 'Packing…', 'Shipping…', 'Deploying…',
269
- 'Calculating…', 'Crunching…', 'Juggling…', 'Balancing…', 'Weighing…',
270
- 'Distilling…', 'Extracting…', 'Parsing…', 'Formatting…', 'Normalizing…',
271
- 'Sanitizing…', 'Validating…', 'Locking…', 'Securing…', 'Guarding…',
272
- 'Watching…', 'Monitoring…', 'Surveying…', 'Scouting…', 'Reconnoitering…',
273
- 'Brewing…', 'Concocting…', 'Devising…', 'Inventing…', 'Imagining…',
274
- 'Envisioning…', 'Visualizing…', 'Picturing…', 'Dreaming…', 'Wondering…',
275
- 'Marveling…', 'Admiring…', 'Appreciating…', 'Enjoying…', 'Savoring…',
276
- 'Relishing…', 'Loving…', 'Cherishing…', 'Treasure-hunting…', 'Questing…',
277
- 'Seeking…', 'Striving…', 'Endeavoring…', 'Attempting…', 'Trying…',
278
- 'Experimenting…', 'Innovating…', 'Pioneering…', 'Trailblazing…', 'Pathfinding…',
279
- 'Navigating…', 'Steering…', 'Guiding…', 'Directing…', 'Captaining…',
280
- 'Piloting…', 'Commanding…', 'Leading…', 'Spearheading…', 'Championing…',
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...',
281
295
  ];
282
296
 
283
297
  // ---- ANSI helpers ----
@@ -696,15 +710,46 @@ export function composerTextIndexAt(layout, rowIdx, colInside) {
696
710
  // A Bash result is a FAILURE when the command exited non-zero or the spawn
697
711
  // itself errored. Detected from the tool-result text (the only place the code
698
712
  // survives for the UI, since the exit-code line is hidden from display).
699
- export function isFailureResult(text) {
713
+ // Failure prefixes that the built-in tools actually return (verified against
714
+ // src/tools/*.js). Any result starting with one of these is a FAILED call:
715
+ // "Error: old_string not found in …" (Edit)
716
+ // "Error reading/writing <path>: …" (Read/Write/Edit)
717
+ // "Cannot read: <path> appears to be …" (Read)
718
+ // "Edit rejected: …" (Edit staleness / read guards)
719
+ // "Command cannot be empty." (Bash)
720
+ // resolvePath throws are surfaced verbatim as `e.message`, e.g.
721
+ // "Path outside workspace: …" / "ENOENT: no such file or directory …".
722
+ const FAIL_PREFIX = /^(Error\b|\[error:|Cannot read:|Edit rejected:|Command cannot be empty\b|Path outside workspace\b|ENOENT\b|EACCES\b|EPERM\b|EISDIR\b|ENOTDIR\b)/i;
723
+
724
+ export function isFailureResult(text, toolName) {
700
725
  const s = String(text == null ? '' : text);
701
- if (/^\[error:/m.test(s.trim())) return true;
726
+ const trimmed = s.trim();
727
+ if (FAIL_PREFIX.test(trimmed)) return true;
728
+ // A non-zero Bash exit code marks the call as failed (red status bullet). The
729
+ // `[exit code: N]` LINE itself is never shown to the user — it is bookkeeping
730
+ // for the model only (see messageLines).
702
731
  const m = /\[exit code:\s*([^\]]+)\]/.exec(s);
703
732
  if (!m) return false;
704
733
  const v = m[1].trim();
705
734
  return v !== '0';
706
735
  }
707
736
 
737
+ // Extract the human-readable failure reason from a tool result, or '' when the
738
+ // result should not render an extra "why it failed" line. The reason is shown
739
+ // in red under the tool call for non-Bash tools (Edit / Read / Write / …); Bash
740
+ // failures are already obvious from the command's own output, so they return ''.
741
+ export function failureReason(text, toolName) {
742
+ const name = String(toolName || '').toLowerCase();
743
+ if (name === 'bash') return '';
744
+ const s = String(text == null ? '' : text);
745
+ const trimmed = s.trim();
746
+ if (!FAIL_PREFIX.test(trimmed)) return '';
747
+ // Collapse to a single line and drop the `[error: …]` wrapper.
748
+ let line = trimmed.split('\n')[0].trim();
749
+ line = line.replace(/^\[error:\s*/i, '').replace(/\]$/, '').trim();
750
+ return line;
751
+ }
752
+
708
753
  function msgColorFor(role, text) {
709
754
  if (role === 'system' && typeof text === 'string' && text.startsWith('[turn took')) return C.gray;
710
755
  if (role === 'system') return C.teal;
@@ -1228,9 +1273,10 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
1228
1273
  let m = /^(\s*)(#{1,6})\s+(.*)$/.exec(t);
1229
1274
  if (m && !isContinuation) {
1230
1275
  const hashCount = m[2].length;
1276
+ // h1 is bold white, h2 is plain white, h3+ is gray. Only h1 carries BOLD.
1231
1277
  const size = hashCount === 1 ? C.white + C.bold : hashCount === 2 ? C.white : C.gray;
1232
1278
  for (const w of wrapWords(m[3], width)) {
1233
- out.push('\x1b[1m' + (hashCount === 1 ? C.bold : '') + w + '\x1b[0m');
1279
+ out.push(col(w, size));
1234
1280
  }
1235
1281
  return out;
1236
1282
  }
@@ -1254,7 +1300,7 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
1254
1300
  if (task && !isContinuation) {
1255
1301
  const checked = task[3] !== ' ';
1256
1302
  const box = checked ? col('✓', C.green) : col('○', C.gray);
1257
- const content = col(' ' + inlineMarkdown(task[4], codeFg), fg);
1303
+ const content = col(' ' + inlineMarkdown(task[4], codeFg, fg), fg);
1258
1304
  for (const w of wrapWords(task[4], Math.max(1, width - 4))) {
1259
1305
  out.push(col(' ', C.gray) + box + (w === task[4] ? content : col(' ' + w, fg)));
1260
1306
  }
@@ -1266,7 +1312,7 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
1266
1312
  const bullet = isOrdered ? col(li[2], C.cyan) : col('•', C.cyan);
1267
1313
  const pad = isOrdered ? ' '.repeat(visualCol(li[2])) + ' ' : ' ';
1268
1314
  let first = true;
1269
- for (const w of wrapWords(inlineMarkdown(li[3], codeFg), Math.max(1, width - 2))) {
1315
+ for (const w of wrapWords(inlineMarkdown(li[3], codeFg, fg), Math.max(1, width - 2))) {
1270
1316
  out.push((first ? col('', C.gray) + bullet + ' ' : col(pad, C.gray)) + col(w, fg));
1271
1317
  first = false;
1272
1318
  }
@@ -1275,7 +1321,7 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
1275
1321
  }
1276
1322
  // Inline styles: **bold**, *italic*, `code`, [text](url)
1277
1323
  if (/[*`\[]/.test(t)) {
1278
- for (const w of wrapWords(t, width)) out.push(col(inlineMarkdown(w, codeFg), fg));
1324
+ for (const w of wrapWords(t, width)) out.push(col(inlineMarkdown(w, codeFg, fg), fg));
1279
1325
  return out;
1280
1326
  }
1281
1327
  // Plain - apply default foreground color
@@ -1285,18 +1331,23 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
1285
1331
 
1286
1332
  // Apply inline markdown to a single line: `code`, **bold**, *italic*, ~~strike~~,
1287
1333
  // [text](url). Order matters so inner tokens are handled safely.
1288
- function inlineMarkdown(s, codeFg) {
1334
+ // `base` is the surrounding foreground colour; every inline style re-asserts it
1335
+ // after its own reset, otherwise the bare `\e[0m` would cancel the base colour
1336
+ // for the REST of the line (plain text after a **bold** run used to lose its
1337
+ // colour and fall back to the terminal default).
1338
+ function inlineMarkdown(s, codeFg, base) {
1339
+ const fb = base || '';
1289
1340
  let r = s;
1290
1341
  // Inline code first so it is not mangled by * [] ~.
1291
- r = r.replace(/`([^`]+)`/g, (_, c) => col(c, codeFg));
1342
+ r = r.replace(/`([^`]+)`/g, (_, c) => col(c, codeFg) + fb);
1292
1343
  // Bold
1293
- r = r.replace(/\*\*([^*]+)\*\*/g, (_, c) => C.bold + c + C.reset);
1344
+ r = r.replace(/\*\*([^*]+)\*\*/g, (_, c) => C.bold + c + C.reset + fb);
1294
1345
  // Strikethrough
1295
1346
  r = r.replace(/~~([^~]+)~~/g, (_, c) => '\x1b[9m' + c + '\x1b[29m');
1296
1347
  // Italic
1297
1348
  r = r.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, (_, pre, c) => pre + '\x1b[3m' + c + '\x1b[23m');
1298
1349
  // Links: [text](url) -> text (cyan); autolink bare http(s) urls
1299
- r = r.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, txt) => col(txt, C.cyan));
1350
+ r = r.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, txt) => col(txt, C.cyan) + fb);
1300
1351
  r = r.replace(/(^|\s)(https?:\/\/[^\s]+)/g, (_, pre, url) => pre + col(url, C.cyan));
1301
1352
  return r;
1302
1353
  }
@@ -1714,6 +1765,8 @@ export function composeFrame(state, cols, rows) {
1714
1765
  const hint = ed.hint || 'Ctrl+S save · Esc cancel · Enter newline';
1715
1766
  lines.push(col(hint, C.gray));
1716
1767
  lines.push('');
1768
+ // Viewport height for the editor body
1769
+ const viewH = Math.max(1, bodyH - (ed.notice ? 6 : 5));
1717
1770
  // Keep the caret row in view.
1718
1771
  let top = Math.max(0, ed.top || 0);
1719
1772
  if (ed.caretRow < top) top = ed.caretRow;
@@ -1723,6 +1776,8 @@ export function composeFrame(state, cols, rows) {
1723
1776
  const idx = top + i;
1724
1777
  const txt = idx < textLines.length ? expandTabs(textLines[idx]) : '';
1725
1778
  lines.push(col(fitAnsi(txt, w), C.white));
1779
+ // Mouse click on this body row moves the caret to that line.
1780
+ addHit(lines.length - 1, 0, w - 1, { kind: 'editor', row: idx });
1726
1781
  }
1727
1782
  lines.push(col(fitAnsi(` line ${ed.caretRow + 1}/${textLines.length} · ${textLines.length} lines`, w), C.gray));
1728
1783
  if (ed.notice) lines.push(col(fitAnsi(' ' + ed.notice, w), ed.noticeKind === 'error' ? C.red : C.green));
@@ -1975,13 +2030,23 @@ export function composeFrame(state, cols, rows) {
1975
2030
  if (state.finishAnim) {
1976
2031
  const DUR = 500;
1977
2032
  const t = Math.min(1, (Date.now() - state.finishAnim.start) / DUR);
1978
- const from = state.finishAnim.from;
1979
- const to = state.finishAnim.to;
1980
- const n = Math.max(from.length, to.length);
1981
- const head = Math.round(t * n); // how many chars are "new"
1982
- let mixStr = '';
1983
- for (let i = 0; i < n; i++) {
1984
- mixStr += (i < head) ? (to[i] ?? '') : (from[i] ?? '');
2033
+ const wordFrom = state.finishAnim.wordFrom || '';
2034
+ const wordTo = state.finishAnim.wordTo || 'turn took';
2035
+ const tail = state.finishAnim.tail || ''; // ` <duration>]`, never changes
2036
+ // SWEEP: start from the LIVE word (`Working...`), pad it on the right to
2037
+ // the wider of the two words, then overwrite it left → right, one
2038
+ // character per step. Characters the sweep has not reached keep their OLD
2039
+ // value, so each `.` disappears on its own turn:
2040
+ // [Working... 59s] → [turn to... 59s] → [turn too.. 59s] → [turn took 59s]
2041
+ // Width = the LONGER word, otherwise a longer old word gets truncated and
2042
+ // its trailing dots vanish at once.
2043
+ const width = Math.max(wordFrom.length, wordTo.length);
2044
+ const from = wordFrom.padEnd(width, '.');
2045
+ const toPadded = wordTo.padEnd(width, '');
2046
+ const done = t >= 1 ? width : Math.floor(t * width); // chars overwritten so far
2047
+ let word = '';
2048
+ for (let i = 0; i < width; i++) {
2049
+ word += (i < done) ? (toPadded[i] || '') : from[i];
1985
2050
  }
1986
2051
  // Colour: orange → grey over the animation.
1987
2052
  const ORANGE = [255, 140, 0];
@@ -1990,7 +2055,7 @@ export function composeFrame(state, cols, rows) {
1990
2055
  const g = Math.round(ORANGE[1] + (GREY[1] - ORANGE[1]) * t);
1991
2056
  const b = Math.round(ORANGE[2] + (GREY[2] - ORANGE[2]) * t);
1992
2057
  const animColor = lerpColor(r, g, b, r, g, b, 0);
1993
- lines.push(col(frame, animColor) + ' ' + col(mixStr, animColor) + elapsed);
2058
+ lines.push(col(frame, animColor) + ' ' + col(`[${word}${tail}`, animColor));
1994
2059
  } else {
1995
2060
 
1996
2061
  // Timing (spinner ticks every 80ms, so 0.5s ≈ 6 ticks):
@@ -2009,7 +2074,7 @@ export function composeFrame(state, cols, rows) {
2009
2074
  // Colours as plain RGB triples (never ANSI strings).
2010
2075
  const ORANGE = [255, 140, 0];
2011
2076
  const YELLOW = [255, 240, 120];
2012
- const RED = [255, 85, 20]; // midway between pure red and the warm orange-red
2077
+ const RED = [255, 0, 0]; // pure red
2013
2078
  const target = phase === 0 ? YELLOW : RED;
2014
2079
 
2015
2080
  const chars = workMsg;
@@ -3210,47 +3275,7 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
3210
3275
  return;
3211
3276
  }
3212
3277
 
3213
- case 'theme': {
3214
- const applyTheme = (name) => {
3215
- if (!setTheme(name)) return false;
3216
- state.theme = name;
3217
- try {
3218
- const f = hncodeConfigFile();
3219
- const txt = fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '';
3220
- const next = /^theme\s*=.*$/m.test(txt)
3221
- ? txt.replace(/^theme\s*=.*$/m, `theme = "${name}"`)
3222
- : `theme = "${name}"\n` + txt;
3223
- fs.writeFileSync(f, next, 'utf8');
3224
- } catch { }
3225
-
3226
- // Force a complete screen refresh with new theme colors
3227
- // We need to access stdout from the host context
3228
- if (host && typeof host.renderFrame === 'function') {
3229
- // Trigger immediate re-render which will use new theme colors
3230
- host.renderFrame();
3231
- } else {
3232
- // Fallback: just show message and let normal flow handle it
3233
- app(`Theme set to "${name}".`);
3234
- return true;
3235
- }
3236
-
3237
- app(`Theme set to "${name}".`);
3238
- return true;
3239
- };
3240
- if (raw) {
3241
- if (!THEME_NAMES.includes(raw)) { appErr(`Theme "${raw}" could not be loaded. Expected: ${THEME_NAMES.join(' / ')}`); return; }
3242
- applyTheme(raw); return;
3243
- }
3244
- openPicker({
3245
- title: 'Set the terminal UI theme',
3246
- items: THEME_NAMES.map((t) => ({ label: t, sub: t === 'auto' ? 'track the terminal' : `${t} palette`, current: (state.theme || 'auto') === t })),
3247
- sel: Math.max(0, THEME_NAMES.indexOf(state.theme || 'auto')),
3248
- searchable: false,
3249
- onPick: (it) => { applyTheme(it.label); return true; },
3250
- });
3251
- return;
3252
- }
3253
- case 'statusline':
3278
+ case 'statusline':
3254
3279
  openPicker({
3255
3280
  title: 'Status line items',
3256
3281
  items: [
@@ -4346,34 +4371,23 @@ export async function startTUI(opts) {
4346
4371
  // PowerShell took ~3s, which read as "the shortcut does nothing".
4347
4372
  async function pasteFromClipboard(fastPathOnly = false) {
4348
4373
  notice('Pasting…', 'info');
4349
- const imgBase64 = readImage();
4350
- if (imgBase64) {
4351
- state.pendingImage = imgBase64;
4352
- insertComposerPaste('[Image pasted - will send as multimodal]');
4353
- notice('Image pasted', 'info');
4374
+ // One probe handles every clipboard shape: copied FILES (→ paths), plain
4375
+ // TEXT (→ text), or an IMAGE (→ a temp .png path). The async version runs it
4376
+ // on the PRE-WARMED helper (~10ms) instead of a cold spawn (~2.9s).
4377
+ const { text, via } = await readClipboardContentAsync();
4378
+ if (text) {
4379
+ insertComposerPaste(text);
4380
+ notice(via === 'text' ? 'Pasted' : `Pasted ${via} as path`, 'info');
4354
4381
  renderFrame();
4355
4382
  return;
4356
4383
  }
4357
-
4358
- // Always try fast path first (readOnce is instant)
4359
- try {
4360
- const text = readOnce();
4361
- if (text) {
4362
- insertComposerPaste(text);
4363
- notice('Pasted instantly!', 'info');
4364
- renderFrame();
4365
- return;
4366
- }
4367
- } catch {}
4368
-
4369
- // Fast path failed - only use slow path for Ctrl+Shift+V, not for right-click
4384
+ // Fast path failed — only fall back to the slow warm-helper reader when this
4385
+ // is a full paste (Ctrl+Shift+V), not a right-click quick paste.
4370
4386
  if (fastPathOnly) {
4371
- notice('Clipboard empty or unavailable (fast path only)', 'error');
4387
+ notice('Clipboard empty or unavailable', 'error');
4372
4388
  renderFrame();
4373
4389
  return;
4374
4390
  }
4375
-
4376
- // Slow path for Ctrl+Shift+V
4377
4391
  const result = await readText();
4378
4392
  if (!result.text) { notice('Clipboard empty or unavailable', 'error'); renderFrame(); return; }
4379
4393
  insertComposerPaste(result.text);
@@ -4564,18 +4578,18 @@ export async function startTUI(opts) {
4564
4578
  }
4565
4579
  }
4566
4580
 
4567
- if (t.key === 'c-s') {
4581
+ if (t.key === 'c-s' && !state.editor) {
4568
4582
  steerAll();
4569
4583
  return;
4570
4584
  }
4571
4585
 
4572
- if (t.key === 'c-t') {
4586
+ if (t.key === 'c-t' && !state.editor) {
4573
4587
  state.todosExpanded = !state.todosExpanded;
4574
4588
  renderFrame();
4575
4589
  return;
4576
4590
  }
4577
4591
 
4578
- if (t.key === 'c-o') {
4592
+ if (t.key === 'c-o' && !state.editor) {
4579
4593
  state.expanded = !state.expanded;
4580
4594
  notice(state.expanded ? 'Expanded tool output' : 'Collapsed tool output', 'info');
4581
4595
  renderFrame();
@@ -4588,7 +4602,7 @@ export async function startTUI(opts) {
4588
4602
  // agent is running, so the idle-only branch that used to hold this was
4589
4603
  // unreachable dead code and ↑ scrolled the chat instead.
4590
4604
  {
4591
- const overlay = state.picker || state.form || state.panel || state.menuOpen;
4605
+ const overlay = state.picker || state.form || state.panel || state.menuOpen || state.editor;
4592
4606
  const cur = state.input || '';
4593
4607
  if (!overlay && cur === '' && t.key === 'up' && (state.queued || []).length) {
4594
4608
  recallQueued();
@@ -4623,7 +4637,7 @@ export async function startTUI(opts) {
4623
4637
  }
4624
4638
  else {
4625
4639
  if (t.key === 'up' || t.key === 'down') {
4626
- const overlay = state.picker || state.form || state.panel || state.menuOpen;
4640
+ const overlay = state.picker || state.form || state.panel || state.menuOpen || state.editor;
4627
4641
  const cur = state.input || '';
4628
4642
  if (!overlay && cur === '' && state.history.length) {
4629
4643
  if (state.historyIdx === -1) state.historyIdx = state.history.length;
@@ -5433,9 +5447,18 @@ export async function startTUI(opts) {
5433
5447
  || [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending && m.toolName === e.name);
5434
5448
  // A failed run must be visible on the TOOL line too: the status bullet
5435
5449
  // turns red (see messageLines).
5436
- const failedRun = isFailureResult(e.content);
5450
+ const failedRun = isFailureResult(e.content, e.name);
5437
5451
  if (entry) { entry.pending = false; entry.id = undefined; entry.failed = failedRun; }
5438
- if (e.name !== 'Edit' && e.name !== 'Write') addChat({ role: 'tool_result', text: e.content, failed: failedRun });
5452
+ const reason = failureReason(e.content, e.name);
5453
+ const isEditLike = e.name === 'Edit' || e.name === 'Write';
5454
+ if (!isEditLike) {
5455
+ // Non-Edit tools show their raw result underneath.
5456
+ addChat({ role: 'tool_result', text: e.content, failed: failedRun });
5457
+ } else if (reason) {
5458
+ // Edit/Write hide their body (the diff already shows the change), so
5459
+ // surface a failure reason as its own red line under the tool call.
5460
+ addChat({ role: 'tool_result', text: reason, failed: true });
5461
+ }
5439
5462
  // NOTE: queued input is deliberately NOT drained into the running turn
5440
5463
  // here. A message typed while the agent works is a NEW turn, so it waits
5441
5464
  // for this turn to finish (see the drain after `agent.run()`). Ctrl-S
@@ -5470,16 +5493,17 @@ export async function startTUI(opts) {
5470
5493
  session.focus = !!state.focus;
5471
5494
  sess.saveSession(session);
5472
5495
  const turnMs = state.turnStart ? Date.now() - state.turnStart : 0;
5473
- const finalText = `[turn took ${fmtDuration(turnMs)}]`;
5474
-
5475
- // Play the 0.5s morph before the Working row disappears. Keep `running`
5476
- // true during the animation so `workingH` stays 1. Repaint every 20ms so
5477
- // the morph is smooth (~25 frames over 500ms) rather than the 80ms spinner
5478
- // tick (which would give only ~6 chunky steps).
5496
+ const dur = fmtDuration(turnMs);
5497
+ // The live row is `<phrase> <gray>[<dur>]`. At the end we keep the same row
5498
+ // shape — `[<word> <dur>]` — and only rewrite the word, so the brackets and
5499
+ // the duration stay put and the time is never repeated. `…` is part of the
5500
+ // phrase and is preserved.
5501
+ const wordFrom = String(state.workMsg || WORKING_MESSAGES[0]);
5479
5502
  state.finishAnim = {
5480
5503
  start: Date.now(),
5481
- from: (state.workMsg || WORKING_MESSAGES[0]),
5482
- to: finalText,
5504
+ wordFrom,
5505
+ wordTo: 'turn took',
5506
+ tail: ` ${dur}]`, // fixed right-hand side, e.g. " 59s]"
5483
5507
  };
5484
5508
  const animStart = Date.now();
5485
5509
  while (Date.now() - animStart < 520) {
@@ -5493,7 +5517,8 @@ export async function startTUI(opts) {
5493
5517
  state.lastTurnMs = turnMs;
5494
5518
  session.lastTurnMs = turnMs;
5495
5519
  sess.saveSession(session);
5496
- addChat({ role: 'system', text: finalText });
5520
+ // Same shape the sweep animated into: `[turn took <dur>]`.
5521
+ addChat({ role: 'system', text: `[turn took ${dur}]` });
5497
5522
  }
5498
5523
  renderFrame();
5499
5524
  if (state.queued.length) {
@@ -5587,7 +5612,13 @@ export async function startTUI(opts) {
5587
5612
  }
5588
5613
 
5589
5614
  function normalizeMsg(m) {
5590
- return { role: m && m.role ? m.role : 'system', text: m && m.text != null ? String(m.text) : '' };
5615
+ // Keep `failed`: a failed tool result must render in RED. Dropping it here
5616
+ // made every failure look like ordinary gray output.
5617
+ return {
5618
+ role: m && m.role ? m.role : 'system',
5619
+ text: m && m.text != null ? String(m.text) : '',
5620
+ failed: !!(m && m.failed),
5621
+ };
5591
5622
  }
5592
5623
 
5593
5624
  let stopped = false;