@hncode/hncode 0.5.2 → 0.5.5

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.
Files changed (45) hide show
  1. package/package.json +38 -38
  2. package/src/agent.js +172 -19
  3. package/src/cache.js +175 -130
  4. package/src/clipboard.js +69 -2
  5. package/src/config.js +10 -3
  6. package/src/index.js +29 -5
  7. package/src/keybindings.js +25 -3
  8. package/src/llm.js +54 -6
  9. package/src/mcp.js +419 -384
  10. package/src/plugin.js +95 -10
  11. package/src/remote.js +307 -289
  12. package/src/session-hub.js +37 -0
  13. package/src/settings-schema.js +2 -0
  14. package/src/shell-safety.js +610 -562
  15. package/src/swarm-progress.js +1 -1
  16. package/src/team/mailbox.js +216 -139
  17. package/src/team/permission.js +121 -0
  18. package/src/team/protocol.js +167 -0
  19. package/src/team/runner.js +127 -12
  20. package/src/team/spawn.js +63 -5
  21. package/src/team/store.js +31 -4
  22. package/src/team/tasks.js +15 -2
  23. package/src/team/team.js +51 -16
  24. package/src/team-panel.js +418 -20
  25. package/src/term-caps.js +457 -410
  26. package/src/term.js +142 -26
  27. package/src/tool-result.js +50 -2
  28. package/src/tools/agent.js +464 -436
  29. package/src/tools/bash.js +53 -14
  30. package/src/tools/diagnostics.js +13 -0
  31. package/src/tools/edit.js +727 -577
  32. package/src/tools/fetch-url.js +118 -5
  33. package/src/tools/glob.js +5 -3
  34. package/src/tools/grep.js +19 -5
  35. package/src/tools/ignore.js +17 -3
  36. package/src/tools/index.js +17 -3
  37. package/src/tools/read.js +13 -2
  38. package/src/tools/shell.js +145 -134
  39. package/src/tools/tasks.js +214 -141
  40. package/src/tools/team.js +13 -0
  41. package/src/tools/utils.js +12 -3
  42. package/src/tools/web-search.js +85 -34
  43. package/src/tui.js +2089 -714
  44. package/src/update.js +38 -3
  45. package/src/web.js +47 -1
package/src/tui.js CHANGED
@@ -20,7 +20,7 @@ import cp from 'node:child_process';
20
20
  import {
21
21
  C, TRUECOLOR, blockCursor, showCursor, hideCursor, alternateScreen, clearScreen, clearAndSetBg, setTheme, setLightBackground, currentTheme, THEME_NAMES, THEME_LABELS, isLightTheme, hasTheme, lerpColor, bgRgb,
22
22
  } from './colors.js';
23
- import { copyText, readText, warmClipboard, readClipboardContentAsync, cleanupPastedImages } from './clipboard.js';
23
+ import { copyText, readText, warmClipboard, readClipboardContentAsync, cleanupPastedImages, stopClipboardHelper } from './clipboard.js';
24
24
  import { visualWidth, estimateTokens, estimateTokensFloat, estimateMessagesTokens, estimateMessageTokens, messageTokens, expandTabs } from './term.js';
25
25
  // The picker is composited onto the frame as a cell layer rather than assigned over
26
26
  // The picker is composited onto the frame as a cell layer rather than assigned over
@@ -55,7 +55,7 @@ import { LLM } from './llm.js';
55
55
  import * as sess from './session.js';
56
56
  import { convRowFor } from './session.js';
57
57
  import { resolveProvider, resolveModelArg, addProvider, removeProvider, addModel, upsertModel, replaceProviderModels, fetchModels, fetchCatalog, modelKey, modelLabel, bareModelId, effortOptions, effortWire, effortsFromReasoning, rememberModel, hncodeConfigFile, resolveConfig, setConfigString, setConfigBool, readPersonalPrompt, readPersonalPromptRaw, writePersonalPrompt, personalPromptFile, readAgentsMd, agentsFilesFor, readMemory, readMemoryRaw, writeMemoryRaw, memoryFile, ensureWebToken, effectiveSnapshot, costFromCatalog, modelCost, usageCost } from './config.js';
58
- import { pluginCommands, renderWidgets, runHooks, runPatch, runPatchSync, runKeybindsSync, pluginKeybinds } from './plugin.js';
58
+ import { pluginCommands, renderWidgets, runHooks, runPatch, runPatchSync, runKeybindsSync, pluginKeybinds, setPluginLoggerTui } from './plugin.js';
59
59
  import { OTHER_LABEL, SUPPLEMENT_LABEL } from './tools/ask-user-question.js';
60
60
  import { render as renderTodos } from './tools/todo.js';
61
61
  import { getTool, toolNames } from './tools/index.js';
@@ -77,10 +77,13 @@ import { tuiView } from './session-view.js';
77
77
  import { SessionCore } from './session-core.js';
78
78
  import { SessionHost } from './session-host.js';
79
79
  import * as teamMod from './team/team.js';
80
+ import * as protocolMod from './team/protocol.js';
81
+ import { stopTeammate } from './team/spawn.js';
80
82
  import * as mailMod from './team/mailbox.js';
81
83
  import { TEAM_LEAD_NAME } from './team/constants.js';
82
- import { makeTeamPanel, renderTeamPanel, handleTeamPanelKey, sendFromPanel } from './team-panel.js';
84
+ import { makeTeamPanel, renderTeamPanel, handleTeamPanelKey, sendFromPanel, noteActivity } from './team-panel.js';
83
85
  import { markAllRead } from './team/mailbox.js';
86
+ import { answerPermission, asPermissionRequest } from './team/permission.js';
84
87
  import { leadTeam, setLeadTeam, clearLeadTeam } from './session-core.js';
85
88
  import * as upd from './update.js';
86
89
  import { renderTasksBrowser, handleTasksBrowserKey, visibleTasks } from './tasks-browser.js';
@@ -100,6 +103,7 @@ import { trimToolResults } from './tool-result.js';
100
103
  import * as gitmod from './git.js';
101
104
  import { reviewLines, savePlan, filesReferenced, planStats, listPlans, plansDir } from './plan.js';
102
105
  import { loadMcpConfig, describeServers, getLiveConnections } from './mcp.js';
106
+ import { killLspClientsSync } from './tools/diagnostics.js';
103
107
  import { startControlServer } from './ci.js';
104
108
  import { attachToDaemon } from './web-daemon-client.js';
105
109
 
@@ -423,6 +427,7 @@ export const TIPS = [
423
427
  'Queued messages wait for the turn to finish',
424
428
  '/steer injects a message into the running turn',
425
429
  'Ctrl+R reloads the current session from disk',
430
+ 'Alt+R re-fetches the provider\'s model list',
426
431
  '/help shows available commands at any time',
427
432
  '/undo rolls back the transcript AND the files',
428
433
  '/fork copies the session so you can branch',
@@ -547,8 +552,8 @@ export function reducedMotionOn(state) {
547
552
  * tool, a thinking preview) gets the same treatment from one place.
548
553
  */
549
554
  export function spinnerFrame(state, tick) {
550
- if (reducedMotionOn(state)) {
551
- const t = tick !== undefined ? tick : (state && state.spin) || 0;
555
+ if (reducedMotionOn(state)) {
556
+ const t = tick !== undefined ? tick : (state && state.spin) || 0;
552
557
  return { glyph: REDUCED_MOTION_DOT, dim: Math.floor(t / (REDUCED_MOTION_CYCLE_MS / 80)) % 2 === 1 };
553
558
  }
554
559
  const n = SPINNER.length;
@@ -588,6 +593,383 @@ export function sweepSettle(dist, half, edge = 'cosine') {
588
593
  return edge === 'linear' ? x : 0.5 * (1 - Math.cos(Math.PI * x));
589
594
  }
590
595
 
596
+ /**
597
+ * A sweep that PASSES: a character lights up under the band and returns to 0 after it.
598
+ *
599
+ * The counterpart to `sweepSettle`, and the difference is the whole point:
600
+ *
601
+ * sweepSettle a character the band has passed STAYS at 1 — paint being laid down
602
+ * sweepPulse a character returns to 0 once the band leaves — a light moving over
603
+ *
604
+ * `half` is the HALF-width of the light, so `2 * half` is its full width, and `flat` is the half-width
605
+ * of its FULLY LIT CORE. The shape is:
606
+ *
607
+ * |dist| <= flat the core, at 1
608
+ * flat < |dist| < half a shoulder, falling from 1 to 0
609
+ *
610
+ * Both outer ends reach 0, which is what makes the light arrive and leave without a seam. `flat = 0`
611
+ * ramps across the whole width and leaves a single bright point in the middle; `flat = half` is a solid
612
+ * block with no fade at all.
613
+ *
614
+ * `dist` is `head - position`, the same convention `sweepSettle` uses.
615
+ */
616
+ export function sweepPulse(dist, half, edge = 'cosine', flat = 0) {
617
+ const h = Math.max(1e-6, half);
618
+ if (dist <= -h || dist >= h) return 0; // outside the light
619
+ // A FLAT CORE: the middle `2 * flat` of the light is fully lit, and the cosine shapes only the
620
+ // shoulders either side of it. `flat` is a half-width, so `flat = h` is the old behaviour — a ramp
621
+ // across the whole light with a single bright point in the middle.
622
+ const core = Math.max(0, Math.min(h, flat));
623
+ const a = Math.abs(dist);
624
+ if (a <= core) return 1;
625
+ const x = (a - core) / (h - core); // 0 where the core ends, 1 at the outer edge
626
+ if (edge === 'linear') return 1 - x;
627
+ return 0.5 * (1 + Math.cos(Math.PI * x)); // raised cosine from 1 down to 0
628
+ }
629
+
630
+ /**
631
+ * The geometry of a passing light, shared by the Working row and the Using row.
632
+ *
633
+ * Both rows show the same gesture — a light running left to right, then a pause — so the numbers live
634
+ * here rather than being repeated at each row and drifting apart.
635
+ *
636
+ * The light is a QUARTER OF THE ROW at full brightness, with a cosine fade either side, and it scales
637
+ * with the text: `Working...` is ten characters, so its core is two; a 60-character line gets fifteen.
638
+ *
639
+ * It crosses in a fixed `CROSS_MS` whatever the row, so a long line is swept faster — see that constant
640
+ * for why, and for what it costs.
641
+ */
642
+
643
+ /**
644
+ * The light's CORE width as a fraction of the row, floored to one character.
645
+ *
646
+ * Claude Code's shimmer has no core at all — a single index is bright. The floor of 1 is the same idea: a
647
+ * glint is a point travelling along the row, not a wash sweeping over it. It stops at one rather than
648
+ * shrinking to zero because a two-character row still needs something to see.
649
+ */
650
+ export const LIGHT_FRACTION = 1 / 4;
651
+
652
+ /**
653
+ * Milliseconds per unit of `state.spin`.
654
+ *
655
+ * The spinner advances `spin` every 80ms (see the spinTimer in startTUI). The light's geometry is in
656
+ * real time, so a caller holding only a tick count needs this to convert — named here so the two
657
+ * cannot drift apart silently: the timer and this constant describe the same interval.
658
+ */
659
+ export const SPIN_TICK_MS = 80;
660
+
661
+ /**
662
+ * The light's FULLY LIT CORE, in characters: a quarter of the row, rounded down.
663
+ *
664
+ * Rounding down is deliberate: `Working...` is ten characters, so its core is two. Rounding up would make
665
+ * it grow on rows where it should not, and a fractional width is something no terminal draws.
666
+ *
667
+ * The floor of 2 keeps a short row visible — a three-character row would otherwise round to one, and a
668
+ * one-character core with a fade either side is a lit cell that barely registers.
669
+ *
670
+ * This is the core, NOT the total extent: `TAIL_CHARS` of cosine fade is added either side of it.
671
+ */
672
+ export function lightWidth(n) {
673
+ return Math.max(2, Math.floor(n * LIGHT_FRACTION));
674
+ }
675
+ /**
676
+ * The fade either side of the core, in characters.
677
+ * TWO, not one: a band of one character is wide enough in truecolor but vanishes under 256 colours, where
678
+ * the light has only two indices and a single lit cell is a blink. Two keeps the sweep legible in both
679
+ * palettes: the peak moves one letter at a time, and the row reads as a lamp travelling rather than as a
680
+ * single pixel flashing.
681
+ */
682
+ export const TAIL_CHARS = 4;
683
+
684
+
685
+ /**
686
+ * The light's half-width, INCLUDING the fade. This is the extent `lightAt` sweeps.
687
+ *
688
+ * THE TAILS ARE CAPPED so the light stays narrower than the row: the total is held to `n - 1`, which
689
+ * keeps at least one dark column at each end. Those dark columns are what make the light read as an
690
+ * object with edges instead of as a row that brightens and dims.
691
+ *
692
+ * The CORE gives way first — `coreHalf` shrinks with the cap — because the fade is what makes the light
693
+ * arrive and leave smoothly, and a short row needs that more than it needs a wide middle.
694
+ */
695
+ function totalHalf(n) {
696
+ const want = lightWidth(n) + 2 * TAIL_CHARS;
697
+ const capped = Math.min(want, Math.max(2, n - 1));
698
+ return capped / 2;
699
+ }
700
+
701
+ /** The half-width of the fully lit core, for `sweepPulse`'s `flat` parameter. */
702
+ function coreHalf(n) {
703
+ // Never wider than the light itself, and never more than half the total: `sweepPulse` requires it.
704
+ return Math.min(lightWidth(n), totalHalf(n) * 2) / 2;
705
+ }
706
+
707
+ /**
708
+ * The animation's frame rate: 30fps, the whole program's rate for animated rows.
709
+ *
710
+ * Rendering one frame measures 0.17ms, so a much higher rate is physically possible — the earlier tick
711
+ * ran at 60fps — but the light's smoothness is not the only cost: every animated row redraws, and the
712
+ * rate is a choice about the whole UI rather than about this one light.
713
+ *
714
+ * At 30fps a frame is 33ms, which is what makes the crossing in `CROSS_MS` six frames long.
715
+ */
716
+ export const ANIM_FPS = 30;
717
+ export const FRAME_MS = 1000 / ANIM_FPS;
718
+
719
+ /**
720
+ * How long one step of the light lasts, in milliseconds.
721
+ *
722
+ * FIXED at 80ms — fast enough that a sweep reads as motion, slow enough that the eye is not chasing it.
723
+ *
724
+ * It followed the output rate for a while. That was tried and dropped: a model writing slowly got a
725
+ * sweep so slow it looked stuck, a fast one got a blur, and the same row changed pace between turns for
726
+ * no reason the reader could see. One rate is calmer and predictable, which is what an ambient
727
+ * indicator needs to be.
728
+ */
729
+ export const STEP_MS = 65;
730
+
731
+ /**
732
+ * The step for a TOOL name, slower than the Working row's.
733
+ *
734
+ * A tool name is short — `Read` is four characters — so at the Working row's rate the light crosses it in
735
+ * 560ms and the row is dark for the rest of the cycle. On screen that reads as a single flash, not as
736
+ * movement. 150ms per character gives a four-character name a 1.4s round: slow enough to follow, still
737
+ * obviously running.
738
+ */
739
+ const TOOL_STEP_MS = 150;
740
+
741
+ /**
742
+ * How long the light stays on one character, from how fast tokens are arriving.
743
+ *
744
+ * `tps` is output tokens per second for the step in flight. A fast stream should look fast — the row
745
+ * keeping a leisurely 50ms step while tokens pour in reads as the animation lagging behind the text it
746
+ * is supposed to accompany.
747
+ *
748
+ * The clamp is the important half. Under about 10 tok/s the model is thinking more than writing and the
749
+ * sweep slows down so it does not look frantic over a trickle; above about 80 tok/s it is a wall of text
750
+ * and the light moves quickly but never strobes. 28ms is just under a 33ms render frame, so a step is
751
+ * always painted at least once; 200ms is slow enough to read as deliberate without looking stuck.
752
+ */
753
+
754
+
755
+
756
+
757
+ /**
758
+ * How far outside the row the light starts and ends, in characters.
759
+ *
760
+ * TEN, exactly as Claude Code's `messageWidth + 20` splits into ten either side. This is what sets the
761
+ * pace of a round: a 10-character row travels 30 cells, so a round is 1500ms — the same as Claude Code.
762
+ *
763
+ * A shorter margin made the sweep finish sooner and read as a twitch. It must not be shorter than the
764
+ * light's own half-width either, or the light would still be lighting the last characters when the round
765
+ * ended; `lightMargin` takes the larger of the two.
766
+ */
767
+ const ENTRY_EXIT_CHARS = 10;
768
+ /**
769
+ * The margin for a row of `n` characters: the target ten, or the light's half-width if that is larger —
770
+ * but CAPPED so the light reaches the text in reasonable time.
771
+ *
772
+ * Ten cells either side is what makes the timing match Claude Code on a long row. On a SHORT row it is
773
+ * most of the cycle: `Edit` is four characters, so the travel is 24 cells and the light spends the first
774
+ * 500ms still outside the text, showing nothing. A row that looks frozen for half a second and then
775
+ * blinks once reads as broken, which is exactly how the Using row was reported.
776
+ *
777
+ * The cap is `n + 4` per side at most, so a short row spends a few steps entering rather than ten, and a
778
+ * long row is unchanged.
779
+ */
780
+ function lightMargin(n) {
781
+ const half = totalHalf(Math.max(1, n));
782
+ return Math.max(1, Math.min(ENTRY_EXIT_CHARS, Math.max(half, Math.ceil(Math.max(1, n) / 4))));
783
+ }
784
+
785
+ /**
786
+ * The crossing time for a row of `count` characters — ONE FULL ROUND, entry and exit included.
787
+ *
788
+ * `(count + 2 * lightMargin(count)) * stepMs`. The light is over the visible text for only `count * stepMs`
789
+ * of it; the rest is the entry and exit runs, which is what keeps the sweep from snapping on and off.
790
+ */
791
+ export function crossMs(count, stepMs = STEP_MS) {
792
+ const cells = Math.max(1, count) + 2 * lightMargin(count);
793
+ return cells * stepMs;
794
+ }
795
+
796
+ /**
797
+ * The rest between rounds, in milliseconds.
798
+ *
799
+ * NOT zero. With `REST_MS = 0` the sweep wrapped straight back to the start the instant it left the right
800
+ * edge, so the row went from "light on the last letter" to "light on nothing" to "light on the first
801
+ * letter" within two frames — which reads as the animation resetting rather than restarting.
802
+ *
803
+ * Claude Code can use zero because its entry run is ten cells: the light is already off the row for a
804
+ * second before it comes back. Ours enters from three cells away, too short to cover the wrap.
805
+ */
806
+ export const REST_MS = 300;
807
+
808
+ /**
809
+ * The full cycle for a row: one crossing, then the rest.
810
+ *
811
+ * `count` enters into it: a longer row has more characters to light, so its cycle is longer. It used to be
812
+ * constant, because a fixed-time crossing spread itself over however many cells there happened to be.
813
+ */
814
+ export function cycleMs(count, stepMs = STEP_MS) {
815
+ return crossMs(count, stepMs) + REST_MS;
816
+ }
817
+
818
+ /**
819
+ * The wall-clock time the light's animation began, for a row anchored at this tick.
820
+ *
821
+ * `spin` advances every SPIN_TICK_MS while the render path redraws far more often, so a phase taken
822
+ * straight from the tick count steps in 80ms jumps. Converting the anchor ONCE to a wall-clock origin
823
+ * lets every later frame measure real elapsed time instead.
824
+ *
825
+ * The store is MODULE-LEVEL, and deliberately not a field on `state`. The caller for a pending tool
826
+ * row is `formatToolLine`, a module-level function with no access to `state` — reading it there threw
827
+ * "state is not defined" and took the whole TUI down through the unhandledRejection handler. The key
828
+ * is the anchor tick, so a new turn gets a new origin.
829
+ */
830
+ const lightOrigins = new Map();
831
+ /**
832
+ * @param {string} key identifies the animation — `tool:12`, `work:12` — so two rows anchored on
833
+ * the same tick keep separate origins.
834
+ * @param {number} anchor the tick the animation began on.
835
+ * @param {number} now the tick it is being drawn at.
836
+ *
837
+ * `key` and `anchor` are separate arguments on purpose. An earlier version took only the anchor and
838
+ * derived the key from it, so a caller passing an already-prefixed `tool:0` produced
839
+ * `(0 - 'tool:0') * 80` — arithmetic on a string, which is NaN. The origin then became NaN, every
840
+ * colour became `38;5;NaN`, and the terminal drew the row in the default colour: a silent failure
841
+ * that looked like the animation simply not running.
842
+ */
843
+ function lightOrigin(key, anchor, now) {
844
+ let origin = lightOrigins.get(key);
845
+ if (origin === undefined) {
846
+ // THE ORIGIN IS "NOW", NOT BACK-DATED FROM THE TICK COUNT.
847
+ //
848
+ // This used to be `Date.now() - (now - anchor) * SPIN_TICK_MS`, so a row that began mid-animation
849
+ // picked up the phase its anchor implied. That is wrong for this row: the first frame it is DRAWN is
850
+ // not the tick its anchor was set on — the start animation runs first, several ticks earlier. Back-
851
+ // dating by that gap made the sweep begin part-way across, so the light appeared to jump forward and
852
+ // then restart from the left.
853
+ //
854
+ // Starting at "now" means a new animation always begins at the left edge.
855
+ //
856
+ // `anchor` and `now` stay in the signature because callers pass them and the shape is part of the
857
+ // contract; the phase no longer depends on their difference.
858
+ void anchor;
859
+ void now;
860
+ origin = Date.now();
861
+ if (!Number.isFinite(origin)) origin = Date.now();
862
+ // EVICT THE OLDEST, never clear the map.
863
+ //
864
+ // This used to be `if (size > 32) clear()`. The anchor changes every turn and for every restored tool
865
+ // row, so the map filled quickly — and clearing it dropped the origin of the row currently on screen,
866
+ // which was then recreated with a FRESH timestamp. The light restarted from the left edge whenever the
867
+ // table filled, roughly once a second: the sweep looked cut off mid-stroke rather than moving.
868
+ //
869
+ // A Map iterates in insertion order, so the first key is the least recently added — exactly the entry
870
+ // no row is asking about any more.
871
+ if (lightOrigins.size > 64) {
872
+ const oldest = lightOrigins.keys().next().value;
873
+ lightOrigins.delete(oldest);
874
+ }
875
+ lightOrigins.set(key, origin);
876
+ }
877
+ return origin;
878
+ }
879
+
880
+ /**
881
+ * How lit one character is, given its index and the row length.
882
+ *
883
+ * Returns 0..1. The position arrives as an INDEX and the row length rather than a pre-divided
884
+ * fraction, because the light's width is itself a fraction of the row — both are then in the same
885
+ * unit of "characters", so the light cannot be scaled twice by accident.
886
+ *
887
+ * `phaseMs` is MILLISECONDS within the cycle, not a tick count.
888
+ *
889
+ * Time is the right unit because the light's smoothness depends on how far it moves per RENDERED
890
+ * frame, and the frame rate is not the caller's 80ms spinner tick. Stepping by that tick means the
891
+ * crossing has to finish in a whole number of 80ms frames, so keeping each step small enough to look
892
+ * smooth forced a many-second crossing on a long row — measured, 12 seconds for 60 characters, and
893
+ * 1.9 seconds for `Working...`. Taking milliseconds decouples the two: the caller may redraw as often
894
+ * as it likes, and the light's speed is stated directly rather than implied by a frame count.
895
+ */
896
+ export function lightAt(phaseMs, index, count, edge = 'cosine', stepMs = STEP_MS) {
897
+ const n = Math.max(1, count);
898
+ // Two widths, and the distinction is the shape of the light: `half` is the OUTER edge (core plus the
899
+ // fade either side) and `core` is the part that stays at full brightness.
900
+ const half = totalHalf(n);
901
+ const core = coreHalf(n);
902
+ // The character's centre, in characters from the start of the row. Measuring against the left edge
903
+ // would make the light peak BETWEEN characters, so each would flash at less than full strength.
904
+ const centre = index + 0.5;
905
+ // The centre crosses from fully off the left of the row to fully off its right, so the light slides
906
+ // in and out rather than popping into existence on the first character.
907
+ //
908
+ // THE REST HALF NEEDS NO SPECIAL CASE. Past the crossing the progress exceeds 1 and the head travels
909
+ // further past the row's right edge — where `sweepPulse` is already 0 — so the light is dark by the
910
+ // same arithmetic that makes it leave. A guard was written for this and then removed after 9450
911
+ // (phase, character) combinations showed it never once changed a value.
912
+ // THE HEAD IS CLAUDE CODE'S `glimmerIndex`, cell for cell, and the run matches its `messageWidth + 20`.
913
+ //
914
+ // Ten cells of margin at each end is what makes the timing match: a 10-character row takes 30 steps, so
915
+ // 1500ms a round — the same as Claude Code. An earlier version used the light's own half-width as the
916
+ // margin, which was tighter and faster, and read as a twitch rather than a sweep.
917
+ //
918
+ // The margin is `max(10, half)`: ten is the target, but the light must clear its own width or it would
919
+ // still be lighting the last characters when the round ended — the failure the half-width version was
920
+ // written to fix, and which a flat ten reintroduces on a wide band.
921
+ const margin = lightMargin(n);
922
+ const travel = n + 2 * margin;
923
+ const steps = Math.floor((phaseMs / crossMs(n, stepMs)) * travel);
924
+ const head = Math.floor((-margin + steps) / STEP_CHARS) * STEP_CHARS;
925
+ // `edge` shapes the shoulder: a raised cosine leaves its outer end gentle, a linear one is sharpest
926
+ // where it meets the core. It arrives as a PARAMETER rather than being read from config, because this
927
+ // is a module-level function and the TUI state lives inside startTUI's closure — the trap that once
928
+ // made a pending tool row throw a ReferenceError and take the whole TUI down with it.
929
+ return sweepPulse(head - centre, half, edge, core);
930
+ }
931
+
932
+ /**
933
+ * Paint `text` with the travelling light already mixed into each glyph.
934
+ *
935
+ * ONE FUNCTION FOR BOTH ANIMATED ROWS — the Working row and a pending tool's "Using <Tool>" — because they
936
+ * were two hand-written copies of the same loop. That is how they drifted: the Working row passed `busy` to
937
+ * `lightAt` and the tool row did not, so one stepped at 50ms/cell inside a 1050ms cycle while the other
938
+ * stepped at 200ms/cell inside a 4050ms cycle. The slow row could not finish before its own modulus
939
+ * wrapped, which is why its light only ever reached the fourth letter before restarting.
940
+ *
941
+ * Everything about the light is decided here — the rate, the shoulder shape, the band width — so a change to
942
+ * the animation lands on both rows at once.
943
+ *
944
+ * @param {string} text the row's text
945
+ * @param {number} phaseMs milliseconds into the cycle
946
+ * @param {number[]} base resting colour, [r, g, b]
947
+ * @param {number[]} glint the colour the light lifts the text toward
948
+ * @param {string} edge shoulder shape: 'cosine' (default) or 'linear'
949
+ * @returns {string} ANSI, ready to splice into a row
950
+ */
951
+ function shimmerText(text, phaseMs, base, glint, edge = 'cosine', stepMs = STEP_MS) {
952
+ const s = String(text == null ? '' : text);
953
+ const n = s.length;
954
+ if (!n) return '';
955
+ const out = [];
956
+ for (let i = 0; i < n; i++) {
957
+ const t = Math.max(0, Math.min(1, lightAt(phaseMs, i, n, edge, stepMs)));
958
+ const r = Math.round(base[0] + (glint[0] - base[0]) * t);
959
+ const g = Math.round(base[1] + (glint[1] - base[1]) * t);
960
+ const b = Math.round(base[2] + (glint[2] - base[2]) * t);
961
+ out.push(col(s[i], lerpColor(r, g, b, r, g, b, 0)));
962
+ }
963
+ return out.join('');
964
+ }
965
+ // How far the light advances in one step, in characters.
966
+ //
967
+ // One character per step is the most literal reading of "move by letters", and it is the right shape:
968
+ // the light lands on a glyph, lights it and its neighbours, then moves to the next. A larger step would
969
+ // move in pairs, which reads as a coarser crawl without looking any more deliberate.
970
+ export const STEP_CHARS = 1;
971
+
972
+
591
973
  // ---------------------------------------------------------------------------
592
974
  // Sweep parameters — ONE set of numbers for every animated row
593
975
  // ---------------------------------------------------------------------------
@@ -636,291 +1018,254 @@ export function framesPerChar(n, ticks = SWEEP_TICKS) {
636
1018
  const DETACH_HINT_DELAY_MS = 10_000;
637
1019
  const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background';
638
1020
 
1021
+ // The phrase the Working row shows while a compaction is running, in place of a
1022
+ // rotating "Working..." one.
1023
+ // Compaction belongs on the Working row rather than in the transcript because it is a
1024
+ // STATE OF THE TURN, not something that happened in it: the agent is busy, it is doing
1025
+ // this one thing, and the row above the composer is where "busy" is already shown. A
1026
+ // separate transcript block duplicated that, and left a row behind that scrolls away
1027
+ // while the turn carries on.
1028
+ //
1029
+ // The ellipsis matches the rotating phrases, so the row does not change shape mid-word.
1030
+ export const COMPACTING_MSG = 'Compacting Context...';
1031
+
639
1032
  // Rotating status messages for the "Working..." line — all mean "the agent is
640
1033
  // thinking / working", but vary the wording so it doesn't look stuck.
641
1034
  const WORKING_MESSAGES = [
642
1035
  // ---- A (100) ----
643
- 'Accelerating...', 'Accessing...', 'Acclaiming...', 'Acclimating...', 'Accommodating...', 'Accompanying...', 'Accomplishing...', 'Accounting...', 'Accrediting...', 'Accruing...',
644
- 'Accumulating...', 'Achieving...', 'Acknowledging...', 'Acquainting...', 'Acquiring...', 'Acting...', 'Activating...', 'Actualizing...', 'Adapting...', 'Adding...',
645
- 'Addressing...', 'Adhering...', 'Adjusting...', 'Administering...', 'Admiring...', 'Adopting...', 'Adorning...', 'Advancing...', 'Advocating...', 'Affirming...',
646
- 'Affording...', 'Aggregating...', 'Agreeing...', 'Aiding...', 'Aiming...', 'Aligning...', 'Alleviating...', 'Allocating...', 'Allowing...', 'Alluring...',
647
- 'Altering...', 'Amalgamating...', 'Amassing...', 'Amending...', 'Amplifying...', 'Analyzing...', 'Anchoring...', 'Animating...', 'Annotating...', 'Announcing...',
648
- 'Answering...', 'Anticipating...', 'Appealing...', 'Applauding...', 'Applying...', 'Appointing...', 'Appraising...', 'Appreciating...', 'Approaching...', 'Approving...',
649
- 'Architecting...', 'Archiving...', 'Arranging...', 'Articulating...', 'Ascending...', 'Ascertaining...', 'Aspiring...', 'Assembling...', 'Asserting...', 'Assessing...',
650
- 'Assigning...', 'Assimilating...', 'Assisting...', 'Assuring...', 'Astonishing...', 'Attaining...', 'Attempting...', 'Attending...', 'Attracting...', 'Auditing...',
651
- 'Augmenting...', 'Authenticating...', 'Authoring...', 'Authorizing...', 'Automating...', 'Awakening...', 'Awarding...', 'Attuning...', 'Absorbing...', 'Accenting...',
652
- 'Acquiescing...', 'Adjudicating...', 'Adventuring...', 'Alerting...', 'Ameliorating...', 'Allying...', 'Annexing...', 'Aping...', 'Availing...', 'Averaging...',
1036
+ 'Accelerating', 'Accessing', 'Acclaiming', 'Acclimating', 'Accommodating', 'Accompanying', 'Accomplishing', 'Accounting', 'Accrediting', 'Accruing',
1037
+ 'Accumulating', 'Achieving', 'Acknowledging', 'Acquainting', 'Acquiring', 'Acting', 'Activating', 'Actualizing', 'Adapting', 'Adding',
1038
+ 'Addressing', 'Adhering', 'Adjusting', 'Administering', 'Admiring', 'Adopting', 'Adorning', 'Advancing', 'Advocating', 'Affirming',
1039
+ 'Affording', 'Aggregating', 'Agreeing', 'Aiding', 'Aiming', 'Aligning', 'Alleviating', 'Allocating', 'Allowing', 'Alluring',
1040
+ 'Altering', 'Amalgamating', 'Amassing', 'Amending', 'Amplifying', 'Analyzing', 'Anchoring', 'Animating', 'Annotating', 'Announcing',
1041
+ 'Answering', 'Anticipating', 'Appealing', 'Applauding', 'Applying', 'Appointing', 'Appraising', 'Appreciating', 'Approaching', 'Approving',
1042
+ 'Architecting', 'Archiving', 'Arranging', 'Articulating', 'Ascending', 'Ascertaining', 'Aspiring', 'Assembling', 'Asserting', 'Assessing',
1043
+ 'Assigning', 'Assimilating', 'Assisting', 'Assuring', 'Astonishing', 'Attaining', 'Attempting', 'Attending', 'Attracting', 'Auditing',
1044
+ 'Augmenting', 'Authenticating', 'Authoring', 'Authorizing', 'Automating', 'Awakening', 'Awarding', 'Attuning', 'Absorbing', 'Accenting',
1045
+ 'Acquiescing', 'Adjudicating', 'Adventuring', 'Alerting', 'Ameliorating', 'Allying', 'Annexing', 'Aping', 'Availing', 'Averaging',
653
1046
  // ---- B (100) ----
654
- 'Babbling...', 'Backing...', 'Backtracking...', 'Balancing...', 'Ballooning...', 'Banking...', 'Bargaining...', 'Bartering...', 'Basing...', 'Basking...',
655
- 'Batching...', 'Bathing...', 'Battling...', 'Beaching...', 'Beaming...', 'Bearing...', 'Beating...', 'Beautifying...', 'Beckoning...', 'Becoming...',
656
- 'Bedazzling...', 'Befriending...', 'Beginning...', 'Behaving...', 'Beholding...', 'Believing...', 'Belonging...', 'Benchmarking...', 'Bending...', 'Benefiting...',
657
- 'Bequeathing...', 'Beseeching...', 'Bestowing...', 'Bettering...', 'Bidding...', 'Biding...', 'Billowing...', 'Binding...', 'Birthing...', 'Biting...',
658
- 'Blasting...', 'Blazing...', 'Bleaching...', 'Bleeding...', 'Blending...', 'Blessing...', 'Blinking...', 'Blooming...', 'Blossoming...', 'Blowing...',
659
- 'Blurring...', 'Blushing...', 'Boarding...', 'Boasting...', 'Boiling...', 'Bolstering...', 'Bonding...', 'Booking...', 'Booming...', 'Boosting...',
660
- 'Bootstrapping...', 'Borrowing...', 'Bottling...', 'Bouncing...', 'Bounding...', 'Bowing...', 'Bowling...', 'Boxing...', 'Bracing...', 'Brainstorming...',
661
- 'Braiding...', 'Branching...', 'Branding...', 'Braving...', 'Breaking...', 'Breathing...', 'Breeding...', 'Breezing...', 'Brewing...', 'Bridging...',
662
- 'Briefing...', 'Brightening...', 'Bringing...', 'Broadcasting...', 'Broadening...', 'Bronzing...', 'Browsing...', 'Brushing...', 'Bubbling...', 'Budding...',
663
- 'Budgeting...', 'Buffering...', 'Building...', 'Bulking...', 'Bundling...', 'Buoying...', 'Burnishing...', 'Bustling...', 'Buying...', 'Buzzing...',
1047
+ 'Babbling', 'Backing', 'Backtracking', 'Balancing', 'Ballooning', 'Banking', 'Bargaining', 'Bartering', 'Basing', 'Basking',
1048
+ 'Batching', 'Bathing', 'Battling', 'Beaching', 'Beaming', 'Bearing', 'Beating', 'Beautifying', 'Beckoning', 'Becoming',
1049
+ 'Bedazzling', 'Befriending', 'Beginning', 'Behaving', 'Beholding', 'Believing', 'Belonging', 'Benchmarking', 'Bending', 'Benefiting',
1050
+ 'Bequeathing', 'Beseeching', 'Bestowing', 'Bettering', 'Bidding', 'Biding', 'Billowing', 'Binding', 'Birthing', 'Biting',
1051
+ 'Blasting', 'Blazing', 'Bleaching', 'Bleeding', 'Blending', 'Blessing', 'Blinking', 'Blooming', 'Blossoming', 'Blowing',
1052
+ 'Blurring', 'Blushing', 'Boarding', 'Boasting', 'Boiling', 'Bolstering', 'Bonding', 'Booking', 'Booming', 'Boosting',
1053
+ 'Bootstrapping', 'Borrowing', 'Bottling', 'Bouncing', 'Bounding', 'Bowing', 'Bowling', 'Boxing', 'Bracing', 'Brainstorming',
1054
+ 'Braiding', 'Branching', 'Branding', 'Braving', 'Breaking', 'Breathing', 'Breeding', 'Breezing', 'Brewing', 'Bridging',
1055
+ 'Briefing', 'Brightening', 'Bringing', 'Broadcasting', 'Broadening', 'Bronzing', 'Browsing', 'Brushing', 'Bubbling', 'Budding',
1056
+ 'Budgeting', 'Buffering', 'Building', 'Bulking', 'Bundling', 'Buoying', 'Burnishing', 'Bustling', 'Buying', 'Buzzing',
664
1057
  // ---- C (100) ----
665
- 'Caching...', 'Cajoling...', 'Caking...', 'Calibrating...', 'Calling...', 'Calming...', 'Campaigning...', 'Camping...', 'Canceling...', 'Canoodling...',
666
- 'Canvasing...', 'Capitalizing...', 'Captioning...', 'Captivating...', 'Capturing...', 'Caring...', 'Carving...', 'Cascading...', 'Cashing...', 'Casting...',
667
- 'Cataloging...', 'Catapulting...', 'Catching...', 'Categorizing...', 'Catering...', 'Cautioning...', 'Ceasing...', 'Celebrating...', 'Cementing...', 'Centralizing...',
668
- 'Certifying...', 'Chaining...', 'Chairing...', 'Chalking...', 'Challenging...', 'Championing...', 'Channeling...', 'Chanting...', 'Chaperoning...', 'Charging...',
669
- 'Charting...', 'Chasing...', 'Chatting...', 'Cheering...', 'Cherishing...', 'Chewing...', 'Chilling...', 'Chiming...', 'Chipping...', 'Chirping...',
670
- 'Chiseling...', 'Choosing...', 'Chopping...', 'Choreographing...', 'Chronicling...', 'Chugging...', 'Ciphering...', 'Circling...', 'Circulating...', 'Citing...',
671
- 'Civilizing...', 'Clamping...', 'Clanging...', 'Clapping...', 'Clarifying...', 'Clashing...', 'Classifying...', 'Clawing...', 'Cleaning...', 'Cleansing...',
672
- 'Clearing...', 'Cleaving...', 'Climbing...', 'Clinching...', 'Clipping...', 'Cloaking...', 'Clocking...', 'Cloning...', 'Closing...', 'Clouding...',
673
- 'Clustering...', 'Clutching...', 'Coaching...', 'Coalescing...', 'Coating...', 'Coaxing...', 'Cocooning...', 'Codifying...', 'Coexisting...', 'Cogitating...',
674
- 'Cohering...', 'Coiling...', 'Coinciding...', 'Collaborating...', 'Collapsing...', 'Collating...', 'Collecting...', 'Coloring...', 'Combining...', 'Comforting...',
1058
+ 'Caching', 'Cajoling', 'Caking', 'Calibrating', 'Calling', 'Calming', 'Campaigning', 'Camping', 'Canoodling', 'Canvasing',
1059
+ 'Capitalizing', 'Captioning', 'Captivating', 'Capturing', 'Caring', 'Carving', 'Cascading', 'Cashing', 'Casting', 'Cataloging',
1060
+ 'Catapulting', 'Catching', 'Categorizing', 'Catering', 'Cautioning', 'Ceasing', 'Celebrating', 'Cementing', 'Centralizing', 'Certifying',
1061
+ 'Chaining', 'Chairing', 'Chalking', 'Challenging', 'Championing', 'Channeling', 'Chanting', 'Chaperoning', 'Charging', 'Charting',
1062
+ 'Chasing', 'Chatting', 'Cheering', 'Cherishing', 'Chewing', 'Chilling', 'Chiming', 'Chipping', 'Chirping', 'Chiseling',
1063
+ 'Choosing', 'Chopping', 'Choreographing', 'Chronicling', 'Chugging', 'Ciphering', 'Circling', 'Circulating', 'Citing', 'Civilizing',
1064
+ 'Clamping', 'Clanging', 'Clapping', 'Clarifying', 'Clashing', 'Classifying', 'Clawing', 'Cleaning', 'Cleansing', 'Clearing',
1065
+ 'Cleaving', 'Climbing', 'Clinching', 'Clipping', 'Cloaking', 'Clocking', 'Cloning', 'Closing', 'Clouding', 'Clustering',
1066
+ 'Clutching', 'Coaching', 'Coalescing', 'Coating', 'Coaxing', 'Cocooning', 'Codifying', 'Coexisting', 'Cogitating', 'Cohering',
1067
+ 'Coiling', 'Coinciding', 'Collaborating', 'Collapsing', 'Collating', 'Collecting', 'Coloring', 'Combining', 'Comforting', 'Dabbling',
675
1068
  // ---- D (100) ----
676
- 'Dabbling...', 'Dampening...', 'Dancing...', 'Daring...', 'Darting...', 'Dashing...', 'Dating...', 'Dawdling...', 'Dazzling...', 'Deactivating...',
677
- 'Debugging...', 'Debuting...', 'Decanting...', 'Decelerating...', 'Decentralizing...', 'Deciding...', 'Deciphering...', 'Decking...', 'Declaring...', 'Decoding...',
678
- 'Decomposing...', 'Decorating...', 'Decoupling...', 'Dedicating...', 'Deducing...', 'Deepening...', 'Defeating...', 'Defending...', 'Deferring...', 'Defining...',
679
- 'Deflecting...', 'Defragmenting...', 'Defusing...', 'Degreasing...', 'Dehydrating...', 'Delegating...', 'Deliberating...', 'Delighting...', 'Delivering...', 'Delving...',
680
- 'Demanding...', 'Demarcating...', 'Demonstrating...', 'Demystifying...', 'Denoting...', 'Densifying...', 'Departing...', 'Depending...', 'Deploying...', 'Depicting...',
681
- 'Depositing...', 'Depressurizing...', 'Deriving...', 'Descending...', 'Describing...', 'Desiring...', 'Despatching...', 'Detailing...', 'Detecting...', 'Deterring...',
682
- 'Determining...', 'Detoxing...', 'Devaluing...', 'Developing...', 'Deviating...', 'Devising...', 'Devoting...', 'Diagnosing...', 'Diagramming...', 'Dialing...',
683
- 'Digesting...', 'Digging...', 'Digitizing...', 'Diluting...', 'Dimensioning...', 'Directing...', 'Disarming...', 'Disbursing...', 'Discarding...', 'Discerning...',
684
- 'Discharging...', 'Disciplining...', 'Disclosing...', 'Disconnecting...', 'Discounting...', 'Discovering...', 'Discrediting...', 'Discussing...', 'Disentangling...', 'Disguising...',
685
- 'Disinfecting...', 'Dislodging...', 'Dismantling...', 'Dismissing...', 'Dispatching...', 'Dispensing...', 'Dispersing...', 'Displaying...', 'Disposing...', 'Disproving...',
686
- 'Dissecting...', 'Disseminating...', 'Dissipating...', 'Dissolving...', 'Distilling...', 'Distinguishing...', 'Distributing...', 'Diversifying...', 'Diverting...', 'Dividing...',
687
- 'Divining...', 'Divulging...', 'Docking...', 'Documenting...', 'Dodging...', 'Domesticating...', 'Dominating...', 'Donating...', 'Doodling...', 'Dosing...',
688
- 'Doubling...', 'Dovetailing...', 'Downloading...', 'Drafting...', 'Draining...', 'Dramatizing...', 'Drawing...', 'Dreaming...', 'Dredging...', 'Dressing...',
689
- 'Drifting...', 'Drilling...', 'Driving...', 'Dropping...', 'Drumming...', 'Drying...', 'Ducking...', 'Dusting...', 'Dwelling...', 'Dyeing...',
1069
+ 'Dampening', 'Dancing', 'Daring', 'Darting', 'Dashing', 'Dating', 'Dawdling', 'Dazzling', 'Deactivating', 'Debugging',
1070
+ 'Debuting', 'Decanting', 'Decelerating', 'Decentralizing', 'Deciding', 'Deciphering', 'Decking', 'Declaring', 'Decoding', 'Decomposing',
1071
+ 'Decorating', 'Decoupling', 'Dedicating', 'Deducing', 'Deepening', 'Defeating', 'Defending', 'Defining', 'Deflecting', 'Defragmenting',
1072
+ 'Defusing', 'Degreasing', 'Dehydrating', 'Delegating', 'Deliberating', 'Delighting', 'Delivering', 'Delving', 'Demanding', 'Demarcating',
1073
+ 'Demonstrating', 'Demystifying', 'Denoting', 'Densifying', 'Departing', 'Depending', 'Deploying', 'Depicting', 'Depositing', 'Depressurizing',
1074
+ 'Deriving', 'Descending', 'Describing', 'Desiring', 'Despatching', 'Detailing', 'Detecting', 'Deterring', 'Determining', 'Detoxing',
1075
+ 'Devaluing', 'Developing', 'Deviating', 'Devising', 'Devoting', 'Diagnosing', 'Diagramming', 'Dialing', 'Digesting', 'Digging',
1076
+ 'Digitizing', 'Diluting', 'Dimensioning', 'Directing', 'Disarming', 'Disbursing', 'Discarding', 'Discerning', 'Discharging', 'Disciplining',
1077
+ 'Disclosing', 'Disconnecting', 'Discounting', 'Discovering', 'Discrediting', 'Discussing', 'Disentangling', 'Disguising', 'Disinfecting', 'Dislodging',
1078
+ 'Dismantling', 'Dismissing', 'Dispatching', 'Dispensing', 'Dispersing', 'Displaying', 'Disposing', 'Disproving', 'Dissecting', 'Disseminating',
1079
+ 'Dissipating', 'Dissolving', 'Distilling', 'Distinguishing', 'Distributing', 'Diversifying', 'Diverting', 'Dividing', 'Divining', 'Divulging',
1080
+ 'Docking', 'Documenting', 'Dodging', 'Domesticating', 'Dominating', 'Donating', 'Doodling', 'Dosing', 'Doubling', 'Dovetailing',
1081
+ 'Downloading', 'Drafting', 'Draining', 'Dramatizing', 'Drawing', 'Dreaming', 'Dredging', 'Dressing', 'Drifting', 'Drilling',
1082
+ 'Driving', 'Dropping', 'Drumming', 'Drying', 'Ducking', 'Dusting', 'Dwelling', 'Dyeing', 'Earning', 'Easing',
690
1083
  // ---- E (100) ----
691
- 'Earning...', 'Easing...', 'Echoing...', 'Eclipsing...', 'Economizing...', 'Edging...', 'Editing...', 'Educating...', 'Effecting...', 'Effervescing...',
692
- 'Elaborating...', 'Elating...', 'Electing...', 'Electrifying...', 'Elevating...', 'Eliciting...', 'Eliminating...', 'Elucidating...', 'Emancipating...', 'Embarking...',
693
- 'Embedding...', 'Embellishing...', 'Embodying...', 'Embracing...', 'Embroidering...', 'Emerging...', 'Emitting...', 'Empathizing...', 'Emphasizing...', 'Empowering...',
694
- 'Emulating...', 'Enabling...', 'Enacting...', 'Encapsulating...', 'Enchanting...', 'Encircling...', 'Enclosing...', 'Encoding...', 'Encouraging...', 'Encrypting...',
695
- 'Endearing...', 'Endeavoring...', 'Endorsing...', 'Endowing...', 'Enduring...', 'Energizing...', 'Enforcing...', 'Engaging...', 'Engineering...', 'Engraving...',
696
- 'Enhancing...', 'Enjoying...', 'Enlarging...', 'Enlightening...', 'Enlisting...', 'Enlivening...', 'Enriching...', 'Enrolling...', 'Ensuring...', 'Entering...',
697
- 'Entertaining...', 'Enthralling...', 'Enthusing...', 'Enticing...', 'Entitling...', 'Enumerating...', 'Enveloping...', 'Envisioning...', 'Equipping...', 'Equalizing...',
698
- 'Erecting...', 'Erasing...', 'Escalating...', 'Escaping...', 'Escorting...', 'Establishing...', 'Esteeming...', 'Estimating...', 'Eulogizing...', 'Evaluating...',
699
- 'Evaporating...', 'Evolving...', 'Examining...', 'Exceeding...', 'Excelling...', 'Exchanging...', 'Exciting...', 'Excluding...', 'Executing...', 'Exercising...',
700
- 'Exhaling...', 'Exhibiting...', 'Exhilarating...', 'Existing...', 'Expanding...', 'Expecting...', 'Expediting...', 'Experiencing...', 'Experimenting...', 'Explaining...',
701
- 'Exploring...', 'Exporting...', 'Exposing...', 'Expressing...', 'Extending...', 'Externalizing...', 'Extracting...', 'Extrapolating...', 'Eyeing...',
702
- // ---- F (100) ----
703
- 'Fabricating...', 'Facilitating...', 'Facing...', 'Factoring...', 'Fading...', 'Farming...', 'Fascinating...', 'Fashioning...', 'Fastening...', 'Fathoming...',
704
- 'Favoring...', 'Feathering...', 'Featuring...', 'Feeding...', 'Feeling...', 'Fencing...', 'Fermenting...', 'Ferrying...', 'Fertilizing...', 'Fetching...',
705
- 'Fiddling...', 'Fielding...', 'Figuring...', 'Filing...', 'Filling...', 'Filtering...', 'Financing...', 'Finding...', 'Fingerprinting...', 'Finishing...',
706
- 'Firing...', 'Firming...', 'Fishing...', 'Fitting...', 'Fixing...', 'Flagging...', 'Flanking...', 'Flashing...', 'Flattening...', 'Flattering...',
707
- 'Flavoring...', 'Fleshing...', 'Flexing...', 'Flicking...', 'Flinging...', 'Flipping...', 'Floating...', 'Flooding...', 'Flooring...', 'Flourishing...',
708
- 'Flowing...', 'Flushing...', 'Fluttering...', 'Flying...', 'Foaming...', 'Focusing...', 'Folding...', 'Following...', 'Forging...', 'Forgiving...',
709
- 'Formalizing...', 'Forming...', 'Formulating...', 'Fortifying...', 'Forwarding...', 'Fostering...', 'Founding...', 'Fragmenting...', 'Framing...', 'Freeing...',
710
- 'Freezing...', 'Freshening...', 'Frosting...', 'Frying...', 'Fueling...', 'Fulfilling...', 'Functioning...', 'Funding...', 'Furnishing...', 'Furthering...',
711
- 'Fusing...', 'Fussing...', 'Factorizing...', 'Fantasizing...', 'Faultfinding...', 'Federating...', 'Fending...', 'Ferreting...', 'Filming...', 'Finalizing...',
712
- 'Finessing...', 'Fireproofing...', 'Fizzing...', 'Flameproofing...', 'Fleecing...', 'Flickering...', 'Flitting...', 'Fluctuating...', 'Fluorescing...', 'Freewheeling...',
713
- // ---- G (100) ----
714
- 'Gaining...', 'Galloping...', 'Galvanizing...', 'Gaming...', 'Gardening...', 'Garlanding...', 'Garnering...', 'Gathering...', 'Gauging...', 'Gazing...',
715
- 'Gearing...', 'Generalizing...', 'Generating...', 'Gesticulating...', 'Gesturing...', 'Getting...', 'Gifting...', 'Giggling...', 'Gilding...', 'Girding...',
716
- 'Giving...', 'Glancing...', 'Glazing...', 'Gleaming...', 'Gleaning...', 'Glimmering...', 'Glimpsing...', 'Gliding...', 'Glistening...', 'Glittering...',
717
- 'Glowing...', 'Glueing...', 'Goading...', 'Gobbling...', 'Golfing...', 'Governing...', 'Grabbing...', 'Graduating...', 'Grafting...', 'Granting...',
718
- 'Graphing...', 'Grasping...', 'Gratifying...', 'Grating...', 'Greasing...', 'Greeting...', 'Gridding...', 'Grilling...', 'Grinding...', 'Gripping...',
719
- 'Grooving...', 'Grooming...', 'Grounding...', 'Grouping...', 'Growing...', 'Guaranteeing...', 'Guarding...', 'Guessing...', 'Guiding...', 'Gushing...',
720
- 'Gusting...', 'Gutting...', 'Guzzling...', 'Gamboling...', 'Gating...', 'Gazetting...', 'Gelling...', 'Gentrifying...', 'Germinating...', 'Gestating...',
721
- 'Ghosting...', 'Glaciating...', 'Gladdening...', 'Globalizing...', 'Glorifying...', 'Glossing...', 'Gonging...', 'Gouging...', 'Grandstanding...', 'Granulating...',
722
- 'Gravitating...', 'Grazing...', 'Greening...', 'Grimacing...', 'Grubbing...', 'Gurgling...', 'Gyrating...', 'Gabbing...', 'Gaffing...', 'Gallivanting...',
723
- 'Galumphing...', 'Garbling...', 'Garrisoning...', 'Gasping...', 'Gentling...', 'Geotagging...', 'Gimballing...', 'Glinting...', 'Gorging...', 'Grinning...',
724
- // ---- H (100) ----
725
- 'Habilitating...', 'Hacking...', 'Haggling...', 'Hailing...', 'Halting...', 'Hammering...', 'Handcrafting...', 'Handing...', 'Handling...', 'Hanging...',
726
- 'Happening...', 'Harboring...', 'Hardening...', 'Harmonizing...', 'Harnessing...', 'Harvesting...', 'Hashing...', 'Hastening...', 'Hatching...', 'Hauling...',
727
- 'Having...', 'Heading...', 'Healing...', 'Heaping...', 'Hearing...', 'Heartening...', 'Heating...', 'Heaving...', 'Hedging...', 'Heeding...',
728
- 'Helping...', 'Heralding...', 'Herding...', 'Hesitating...', 'Hibernating...', 'Hiding...', 'Highlighting...', 'Hiking...', 'Hinging...', 'Hinting...',
729
- 'Hiring...', 'Hitting...', 'Hoarding...', 'Hoisting...', 'Holding...', 'Hollowing...', 'Homing...', 'Honing...', 'Honoring...', 'Hooking...',
730
- 'Hopping...', 'Hoping...', 'Horsing...', 'Hosting...', 'Hotfixing...', 'Hovering...', 'Howling...', 'Huddling...', 'Hugging...', 'Hulling...',
731
- 'Humanizing...', 'Humbling...', 'Humming...', 'Hunting...', 'Hurling...', 'Hurrying...', 'Hushing...', 'Hustling...', 'Hybridizing...', 'Hydrating...',
732
- 'Hydroplaning...', 'Hymning...', 'Hyping...', 'Hypothesizing...', 'Habituating...', 'Hairdressing...', 'Hallowing...', 'Halving...', 'Handholding...', 'Handpicking...',
733
- 'Handwriting...', 'Hankering...', 'Hardcoding...', 'Harkening...', 'Hawking...', 'Haymaking...', 'Headhunting...', 'Headlining...', 'Heartwarming...', 'Heightening...',
734
- 'Heehawing...', 'Homesteading...', 'Homeschooling...', 'Honeying...', 'Hooping...', 'Hornswoggling...', 'Hosing...', 'Hounding...', 'Huckstering...', 'Humoring...',
735
- // ---- I (100) ----
736
- 'Idealizing...', 'Identifying...', 'Igniting...', 'Ignoring...', 'Illuminating...', 'Illustrating...', 'Imagining...', 'Imitating...', 'Immersing...', 'Immortalizing...',
737
- 'Impacting...', 'Imparting...', 'Impersonating...', 'Implementing...', 'Implicating...', 'Importing...', 'Imposing...', 'Impressing...', 'Imprinting...', 'Improving...',
738
- 'Improvising...', 'Inaugurating...', 'Incentivizing...', 'Inching...', 'Including...', 'Incorporating...', 'Increasing...', 'Incrementing...', 'Incubating...', 'Incurring...',
739
- 'Indenting...', 'Indexing...', 'Indicating...', 'Inducting...', 'Indulging...', 'Industrializing...', 'Inferring...', 'Infiltrating...', 'Inflating...', 'Influencing...',
740
- 'Informing...', 'Infusing...', 'Ingesting...', 'Inhabiting...', 'Inhaling...', 'Inheriting...', 'Inhibiting...', 'Initializing...', 'Initiating...', 'Injecting...',
741
- 'Injuring...', 'Inking...', 'Innovating...', 'Inoculating...', 'Inquiring...', 'Inscribing...', 'Inserting...', 'Insisting...', 'Inspecting...', 'Inspiring...',
742
- 'Installing...', 'Instantiating...', 'Instigating...', 'Instilling...', 'Instituting...', 'Instructing...', 'Insulating...', 'Insuring...', 'Integrating...', 'Intending...',
743
- 'Intensifying...', 'Interacting...', 'Intercepting...', 'Interfacing...', 'Interleaving...', 'Interlinking...', 'Interlacing...', 'Interlocking...', 'Interning...', 'Interpolating...',
744
- 'Interpreting...', 'Interrogating...', 'Interrupting...', 'Intersecting...', 'Intervening...', 'Interviewing...', 'Interweaving...', 'Intoning...', 'Introducing...', 'Intuiting...',
745
- 'Inventing...', 'Inventorying...', 'Inverting...', 'Investing...', 'Investigating...', 'Invigorating...', 'Inviting...', 'Involving...', 'Ionizing...', 'Iterating...',
746
- // ---- J ----
747
- 'Jabbing...', 'Jacketing...', 'Jailing...', 'Jamming...', 'Jangling...', 'Jarring...', 'Jaunting...', 'Jazzing...',
748
- 'Jeering...', 'Jelling...', 'Jeopardizing...', 'Jerking...', 'Jesting...', 'Jettisoning...', 'Jeweling...', 'Jibing...',
749
- 'Jiggling...', 'Jigsawing...', 'Jilting...', 'Jingling...', 'Jiving...', 'Jobbing...', 'Jockeying...', 'Jogging...',
750
- 'Joining...', 'Joking...', 'Jollying...', 'Jolting...', 'Jostling...', 'Jotting...', 'Journeying...', 'Jousting...',
751
- 'Joyriding...', 'Judging...', 'Juggling...', 'Julienning...', 'Jumping...', 'Junketing...', 'Justifying...', 'Jutting...',
752
- 'Juxtaposing...', 'Jabbering...', 'Jading...', 'Jalousing...', 'Japing...', 'Jargonizing...', 'Jauncing...', 'Jawboning...',
753
- 'Jawing...', 'Jeopardying...', 'Jellifying...', 'Jemmying...', 'Jettying...', 'Jewelling...', 'Jibbing...', 'Jigging...',
754
- 'Jitterbugging...', 'Jinxing...', 'Jittering...', 'Jobhunting...', 'Joggling...', 'Joisting...', 'Jokifying...', 'Jollifying...',
755
- 'Jonesing...', 'Joshing...', 'Jouking...', 'Journalling...', 'Jovializing...', 'Jowing...', 'Joying...', 'Joypopping...',
756
- 'Jubilating...', 'Juddering...', 'Judoing...', 'Juking...', 'Jumbling...', 'Jumpstarting...', 'Junking...', 'Jurifying...',
757
- 'Jurying...', 'Justling...', 'Juicing...', 'Jouncing...', 'Javelining...', 'Jocundizing...', 'Japering...', 'Japonicaing...',
758
- 'Jasperizing...', 'Jazzercising...', 'Jellyfishing...', 'Jerrybuilding...', 'Jerseying...', 'Jessying...', 'Jobseeking...', 'Jointing...',
759
- 'Junketeering...', 'Jawbreaking...', 'Jaywalking...',
760
- // ---- K ----
761
- 'Keeling...', 'Keening...', 'Keeping...', 'Kenning...', 'Kerbing...', 'Kerneling...', 'Kettling...', 'Keyboarding...',
762
- 'Keying...', 'Keynoting...', 'Kibitzing...', 'Kicking...', 'Kidding...', 'Kidnapping...', 'Kilning...', 'Kindling...',
763
- 'Kinging...', 'Kinking...', 'Kissing...', 'Kitting...', 'Kneading...', 'Kneeling...', 'Knifing...', 'Knighting...',
764
- 'Knitting...', 'Knocking...', 'Knotting...', 'Knowing...', 'Knuckling...', 'Koshering...', 'Kowtowing...', 'Kvetching...',
765
- 'Karaoking...', 'Kayoing...', 'Kecking...', 'Kedging...', 'Keeking...', 'Kelping...', 'Kenneling...', 'Keratinizing...',
766
- 'Kerfing...', 'Kernelling...', 'Kibbutzing...', 'Kiboshing...', 'Kickboxing...', 'Kickstarting...', 'Kiddying...', 'Kiltering...',
767
- 'Kippering...', 'Kiting...', 'Kittening...', 'Klaxoning...', 'Knapping...', 'Kneecapping...', 'Knelling...', 'Knobbling...',
768
- 'Knolling...', 'Kodaking...', 'Konking...', 'Kooking...', 'Koreanizing...', 'Kremlining...', 'Krumping...', 'Kudosing...',
769
- 'Kurbling...', 'Kvelling...', 'Kingmaking...', 'Kyanising...', 'Kyboshing...', 'Kything...', 'Kaolinizing...', 'Kaputing...',
770
- 'Katalysing...', 'Kathoding...', 'Keshing...', 'Keypunching...', 'Khediving...', 'Kalsomining...', 'Kamiing...', 'Kantianing...',
771
- 'Karaokeing...', 'Karateing...', 'Karstifying...', 'Katabolizing...', 'Kathing...', 'Kazaching...', 'Kebabbing...', 'Kebobbing...',
772
- 'Keeving...', 'Keelhauling...', 'Keenlying...', 'Keepnetting...', 'Keltering...', 'Kerseying...', 'Ketling...', 'Keyframing...',
773
- 'Keyseating...', 'Kibbling...', 'Kickboarding...', 'Kickflipping...', 'Kidneystoning...', 'Kieving...', 'Kimchifying...', 'Kimmering...',
774
- 'Kindergartening...', 'Kinescoping...', 'Kingfishing...', 'Kirkifying...', 'Kirshening...', 'Kitbagging...', 'Kitchening...', 'Kitesurfing...',
775
- 'Klondiking...', 'Kludging...', 'Knackering...', 'Kneippering...', 'Knobkerrying...', 'Knowledging...', 'Kohlrabiing...', 'Koorifying...',
776
- 'Kotowing...', 'Kronaing...', 'Krytroning...', 'Kugelhopfing...', 'Kumquating...', 'Kurrajonging...', 'Kyanizing...',
777
- // ---- L (100) ----
778
- 'Labeling...', 'Laboring...', 'Lacing...', 'Laddering...', 'Lading...', 'Lagging...', 'Lambasting...', 'Laminating...', 'Landing...', 'Languishing...',
779
- 'Lapping...', 'Lapsing...', 'Larding...', 'Lashing...', 'Lasting...', 'Latching...', 'Lathering...', 'Lauding...', 'Laughing...', 'Launching...',
780
- 'Laundering...', 'Lavishing...', 'Laying...', 'Lazing...', 'Leaching...', 'Leading...', 'Leafing...', 'Leaking...', 'Leaning...', 'Leaping...',
781
- 'Learning...', 'Leasing...', 'Leathering...', 'Leaving...', 'Lecturing...', 'Ledgering...', 'Leeching...', 'Leering...', 'Legitimizing...', 'Lending...',
782
- 'Lengthening...', 'Lessening...', 'Letting...', 'Leveling...', 'Leveraging...', 'Levying...', 'Liberating...', 'Licensing...', 'Licking...', 'Lifting...',
783
- 'Lightening...', 'Lighting...', 'Liking...', 'Limbering...', 'Limiting...', 'Lining...', 'Linking...', 'Lionizing...', 'Liquidating...', 'Lisping...',
784
- 'Listening...', 'Litigating...', 'Littering...', 'Living...', 'Loading...', 'Loafing...', 'Loaning...', 'Lobbying...', 'Lobing...', 'Localizing...',
785
- 'Locating...', 'Locking...', 'Lodging...', 'Lofting...', 'Logging...', 'Longing...', 'Looking...', 'Looping...', 'Loosening...', 'Looting...',
786
- 'Loping...', 'Losing...', 'Lounging...', 'Loving...', 'Lowering...', 'Lubricating...', 'Lugging...', 'Lulling...', 'Lumbering...', 'Lumping...',
787
- 'Lunching...', 'Lunging...', 'Luring...', 'Lurking...', 'Lustering...', 'Lustrating...', 'Luxuriating...', 'Lying...', 'Lynching...', 'Lyricizing...',
788
- // ---- M (100) ----
789
- 'Machining...', 'Magnetizing...', 'Magnifying...', 'Maintaining...', 'Mainstreaming...', 'Making...', 'Managing...', 'Mandating...',
790
- 'Maneuvering...', 'Mangling...', 'Manifesting...', 'Manipulating...', 'Manning...', 'Manufacturing...', 'Mapping...', 'Marching...',
791
- 'Marketing...', 'Marking...', 'Marrying...', 'Marshaling...', 'Marveling...', 'Mashing...', 'Masking...', 'Massaging...',
792
- 'Massing...', 'Mastering...', 'Matching...', 'Materializing...', 'Matriculating...', 'Mattering...', 'Maximizing...', 'Meaning...',
793
- 'Measuring...', 'Mediating...', 'Meditating...', 'Meeting...', 'Melding...', 'Mellowing...', 'Melting...', 'Memorizing...',
794
- 'Mending...', 'Mentioning...', 'Mentoring...', 'Merging...', 'Meriting...', 'Meshing...', 'Mesmerizing...', 'Messaging...',
795
- 'Metabolizing...', 'Metalworking...', 'Metamorphosing...', 'Metering...', 'Microblogging...', 'Microchipping...', 'Microfilming...', 'Micromanaging...',
796
- 'Migrating...', 'Milking...', 'Milling...', 'Mimicking...', 'Mincing...', 'Minding...', 'Mingling...', 'Miniaturizing...',
797
- 'Minimizing...', 'Mining...', 'Ministering...', 'Minting...', 'Mirroring...', 'Misaligning...', 'Miscalculating...', 'Misdiagnosing...',
798
- 'Misfiring...', 'Misjudging...', 'Mislaying...', 'Mismatching...', 'Misplacing...', 'Misquoting...', 'Misreading...', 'Missing...',
799
- 'Misspelling...', 'Mistaking...', 'Misting...', 'Misunderstanding...', 'Mitigating...', 'Mixing...', 'Moaning...', 'Mobilizing...',
800
- 'Mocking...', 'Modeling...', 'Moderating...', 'Modernizing...', 'Modifying...', 'Modularizing...', 'Modulating...', 'Moistening...',
801
- 'Moisturizing...', 'Molding...', 'Mollifying...', 'Monitoring...', 'Monetizing...', 'Monogramming...', 'Monologuing...', 'Monopolizing...',
802
- 'Mooring...', 'Moralizing...', 'Morphing...', 'Mortgaging...', 'Moseying...', 'Motivating...', 'Motoring...', 'Mottling...',
803
- 'Moulding...', 'Mounting...', 'Mountaineering...', 'Mourning...', 'Mousetrapping...', 'Mouthing...', 'Moving...', 'Mowing...',
804
- 'Muckraking...', 'Muddling...', 'Mudslinging...', 'Muffling...', 'Mulching...', 'Multiplying...', 'Multiplexing...', 'Multitasking...',
805
- 'Mumbling...', 'Mummifying...', 'Munching...', 'Murmuring...', 'Muscling...', 'Musicalizing...', 'Musing...', 'Musseling...',
806
- 'Mustering...', 'Mutating...', 'Muting...', 'Mutinying...', 'Muttering...', 'Mutualizing...', 'Muzzling...', 'Mystifying...',
807
- 'Mailing...', 'Mamboing...', 'Manacling...', 'Manhandling...', 'Manicuring...', 'Mansplaining...', 'Mantling...', 'Marinating...',
808
- 'Marooning...', 'Marring...', 'Masquerading...', 'Masterminding...', 'Masticating...', 'Mauling...', 'Meandering...', 'Mechanizing...',
809
- 'Meddling...', 'Meliorating...', 'Melodizing...', 'Memorializing...', 'Menacing...', 'Mercerizing...', 'Merchandising...', 'Metallizing...',
810
- 'Mildewing...', 'Militating...', 'Mimeographing...', 'Mindmapping...', 'Minesweeping...', 'Misappropriating...', 'Misconstruing...', 'Misrepresenting...',
811
- // ---- N (100) ----
812
- 'Nabbing...', 'Nailing...', 'Naming...', 'Nanosizing...', 'Napping...', 'Narrating...', 'Narrowing...', 'Nattering...',
813
- 'Naturalizing...', 'Navigating...', 'Neatening...', 'Nebulizing...', 'Necessitating...', 'Necking...', 'Necropsying...', 'Needling...',
814
- 'Negating...', 'Negotiating...', 'Nerving...', 'Nesting...', 'Nestling...', 'Netting...', 'Nettling...', 'Networking...',
815
- 'Neutralizing...', 'Newscasting...', 'Nibbling...', 'Nicking...', 'Nicknaming...', 'Nictitating...', 'Niggling...', 'Nipping...',
816
- 'Nitpicking...', 'Nobbling...', 'Nocking...', 'Nodding...', 'Noising...', 'Nominating...', 'Nonplussing...', 'Noodling...',
817
- 'Normalizing...', 'Nosediving...', 'Nosing...', 'Notarizing...', 'Notating...', 'Notching...', 'Noticing...', 'Notifying...',
818
- 'Noting...', 'Nourishing...', 'Novelizing...', 'Nucleating...', 'Nudging...', 'Nuggetting...', 'Nuking...', 'Nullifying...',
819
- 'Numbering...', 'Numbing...', 'Nursemaiding...', 'Nursing...', 'Nurturing...', 'Nutmegging...', 'Nuzzling...', 'Narrowcasting...',
820
- // ---- O (100) ----
821
- 'Obeying...', 'Objecting...', 'Objectivizing...', 'Obligating...', 'Obliging...', 'Obliterating...', 'Obnubilating...', 'Obscuring...',
822
- 'Observing...', 'Obsessing...', 'Obsoleting...', 'Obstructing...', 'Obtaining...', 'Obtruding...', 'Obviating...', 'Occasioning...',
823
- 'Occupying...', 'Occurring...', 'Offering...', 'Officiating...', 'Offloading...', 'Offsetting...', 'Ogling...', 'Oiling...',
824
- 'Okaying...', 'Ominating...', 'Omitting...', 'Onboarding...', 'Oozing...', 'Opacifying...', 'Opalescing...', 'Opening...',
825
- 'Operating...', 'Opining...', 'Opposing...', 'Optimizing...', 'Optioning...', 'Orating...', 'Orbiting...', 'Orchestrating...',
826
- 'Ordaining...', 'Ordering...', 'Organizing...', 'Orienting...', 'Originating...', 'Ornamenting...', 'Orphaning...', 'Oscillating...',
827
- 'Ossifying...', 'Ostracizing...', 'Outbidding...', 'Outclassing...', 'Outdoing...', 'Outfitting...', 'Outgrowing...', 'Outlasting...',
828
- 'Outlining...', 'Outmaneuvering...', 'Outpacing...', 'Outperforming...', 'Outreaching...', 'Outsmarting...', 'Outsourcing...', 'Outstripping...',
829
- 'Outwitting...', 'Overachieving...', 'Overarching...', 'Overbalancing...', 'Overbooking...', 'Overbuilding...', 'Overcoming...', 'Overcompensating...',
830
- 'Overdelivering...', 'Overdoing...', 'Overeating...', 'Overemphasizing...', 'Overestimating...', 'Overflowing...', 'Overhauling...', 'Overhearing...',
831
- 'Overheating...', 'Overlaying...', 'Overloading...', 'Overlooking...', 'Overpowering...', 'Overprinting...', 'Overproducing...', 'Overreaching...',
832
- 'Overriding...', 'Overruling...', 'Overseeing...', 'Overshadowing...', 'Overshooting...', 'Oversimplifying...', 'Oversleeping...', 'Overstating...',
833
- 'Overstepping...', 'Overstocking...', 'Overstretching...', 'Overtaking...', 'Overthinking...', 'Overthrowing...', 'Overturning...', 'Overvaluing...',
834
- 'Overwatching...', 'Overwhelming...', 'Owing...', 'Owning...', 'Oxidizing...', 'Oxygenating...', 'Ozonizing...', 'Obolizing...',
835
- // ---- P ----
836
- 'Pacing...', 'Packaging...', 'Packing...', 'Padding...', 'Paddling...', 'Paging...', 'Painting...', 'Pairing...',
837
- 'Palatalizing...', 'Palling...', 'Palming...', 'Palpating...', 'Pampering...', 'Panelizing...', 'Panicking...', 'Pantomiming...',
838
- 'Papering...', 'Parachuting...', 'Parading...', 'Paragliding...', 'Paralysing...', 'Paraphrasing...', 'Parceling...', 'Pardoning...',
839
- 'Parenting...', 'Pariking...', 'Parking...', 'Parleying...', 'Parodying...', 'Paroling...', 'Parrying...', 'Parsing...',
840
- 'Partaking...', 'Partitioning...', 'Partnering...', 'Partying...', 'Passing...', 'Passivizing...', 'Pasting...', 'Pastoring...',
841
- 'Patching...', 'Patenting...', 'Patrolling...', 'Patronizing...', 'Patterning...', 'Pausing...', 'Paving...', 'Pawing...',
842
- 'Paying...', 'Peaking...', 'Pealing...', 'Pecking...', 'Pedaling...', 'Peeking...', 'Peeling...', 'Peering...',
843
- 'Pegging...', 'Pelting...', 'Penalizing...', 'Penciling...', 'Pending...', 'Penetrating...', 'Pensioning...', 'Peppering...',
844
- 'Perceiving...', 'Perching...', 'Perfecting...', 'Perforating...', 'Performing...', 'Perfuming...', 'Perishing...', 'Permeating...',
845
- 'Permitting...', 'Permuting...', 'Perpetuating...', 'Perplexing...', 'Persevering...', 'Persisting...', 'Personalizing...', 'Personifying...',
846
- 'Persuading...', 'Perving...', 'Pestering...', 'Petitioning...', 'Petrifying...', 'Pettifogging...', 'Phaseing...', 'Philandering...',
847
- 'Philosophizing...', 'Phoneing...', 'Photocopying...', 'Photographing...', 'Photosynthesizing...', 'Phrasing...', 'Picking...', 'Picnicking...',
848
- 'Picturing...', 'Piecing...', 'Piercing...', 'Piggybacking...', 'Pilfering...', 'Piloting...', 'Pinching...', 'Pinging...',
849
- 'Pinning...', 'Pioneering...', 'Piping...', 'Pitching...', 'Pitying...', 'Pivoting...', 'Placating...', 'Placing...',
850
- 'Plagiarizing...', 'Plaiting...', 'Planning...', 'Planting...', 'Plastering...', 'Plating...', 'Playing...', 'Pleading...',
851
- 'Pleasuring...', 'Pledging...', 'Plodding...', 'Plotting...', 'Ploughing...', 'Plowing...', 'Plucking...', 'Plugging...',
852
- 'Plumbing...', 'Plummeting...', 'Plumping...', 'Plundering...', 'Plunging...', 'Pluralizing...', 'Plying...', 'Poaching...',
853
- 'Pocketing...', 'Podcasting...', 'Poeticizing...', 'Pointing...', 'Poising...', 'Poisoning...', 'Poking...', 'Polarizing...',
854
- 'Policing...', 'Polishing...', 'Politicizing...', 'Pollinating...', 'Polling...', 'Pondering...', 'Pooling...', 'Popping...',
855
- 'Popularizing...', 'Populating...', 'Poring...', 'Porting...', 'Portioning...', 'Portraying...', 'Posing...', 'Positioning...',
856
- 'Positing...', 'Possessing...', 'Posting...', 'Postponing...', 'Postulating...', 'Posturing...', 'Potting...', 'Pouncing...',
857
- 'Pounding...', 'Pouring...', 'Pouting...', 'Powdering...', 'Powering...', 'Practicing...', 'Praising...', 'Prancing...',
858
- 'Prattling...', 'Praying...', 'Preaching...', 'Preambleing...', 'Preceding...', 'Precipitating...', 'Precluding...', 'Precooking...',
859
- 'Predicting...', 'Predisposing...', 'Prefacing...', 'Preferring...', 'Prefiguring...', 'Prefixing...', 'Preheating...', 'Prejudging...',
860
- 'Preluding...', 'Premising...', 'Preoccupying...', 'Preordaining...', 'Preparing...', 'Prepaving...', 'Prepaying...', 'Preponderating...',
861
- 'Prerecording...', 'Prescribing...', 'Presenting...', 'Preserving...', 'Presetting...', 'Presiding...', 'Pressuring...', 'Pressurizing...',
862
- 'Prestressing...', 'Presuming...', 'Pretending...', 'Prettifying...', 'Prevailing...', 'Preventing...', 'Previewing...', 'Preying...',
863
- 'Pricing...', 'Pricking...', 'Pridiing...', 'Priming...', 'Printing...', 'Prioritizing...', 'Privatizing...', 'Privileging...',
864
- 'Prizing...', 'Probing...', 'Proceeding...', 'Processing...', 'Proclaiming...', 'Procrastinating...', 'Procuring...', 'Prodding...',
865
- 'Producing...', 'Profaning...', 'Professing...', 'Profiling...', 'Profiting...', 'Programming...', 'Progressing...', 'Prohibiting...',
866
- 'Projecting...', 'Proliferating...', 'Prolonging...', 'Promenading...', 'Promising...', 'Promoting...', 'Prompting...', 'Promulgating...',
867
- 'Pronouncing...', 'Proofing...', 'Proofreading...', 'Propagating...', 'Propelling...', 'Prophesying...', 'Proposing...', 'Propping...',
868
- 'Proroguing...', 'Prosecuting...', 'Proselytizing...', 'Prospecting...', 'Prospering...', 'Protecting...', 'Protesting...', 'Protruding...',
869
- 'Proving...', 'Providing...', 'Provisioning...', 'Provoking...', 'Prowling...', 'Pruning...', 'Psyching...', 'Publicizing...',
870
- 'Publishing...', 'Puckering...', 'Puffing...', 'Pulling...', 'Pulping...', 'Pulsating...', 'Pulverizing...', 'Pummeling...',
871
- 'Pumping...', 'Punching...', 'Puncturing...', 'Punishing...', 'Punning...', 'Purging...', 'Purifying...', 'Purloining...',
872
- 'Purporting...', 'Purring...', 'Pursuing...', 'Pushing...', 'Putting...', 'Puzzling...', 'Pyramiding...',
873
- // ---- Q ----
874
- 'Quacking...', 'Quadrupling...', 'Quaffing...', 'Quailing...', 'Qualifying...', 'Quantifying...', 'Quarreling...', 'Quarrying...',
875
- 'Quartering...', 'Quashing...', 'Quavering...', 'Queening...', 'Quelling...', 'Quenching...', 'Querying...', 'Questing...',
876
- 'Questioning...', 'Queueing...', 'Quickening...', 'Quieting...', 'Quilting...', 'Quipping...', 'Quivering...', 'Quizzing...',
877
- 'Quoting...',
878
- // ---- R ----
879
- 'Racing...', 'Racking...', 'Radiating...', 'Raffling...', 'Rafting...', 'Raging...', 'Raining...', 'Raising...',
880
- 'Rallying...', 'Rambling...', 'Ramifying...', 'Ramping...', 'Ransacking...', 'Ransoming...', 'Ranting...', 'Rapping...',
881
- 'Rapproching...', 'Rapturing...', 'Raring...', 'Rasping...', 'Ratifying...', 'Rating...', 'Rationalizing...',
882
- 'Rationing...', 'Rattling...', 'Ravaging...', 'Raving...', 'Reaching...', 'Reacting...', 'Readjusting...', 'Readying...',
883
- 'Realigning...', 'Realizing...', 'Reallocating...', 'Reanimating...', 'Reaping...', 'Reappearing...', 'Reapplying...', 'Rearranging...',
884
- 'Reasoning...', 'Reassembling...', 'Reasserting...', 'Reassessing...', 'Reassigning...', 'Reassuring...', 'Rebalancing...', 'Rebating...',
885
- 'Rebelling...', 'Rebooting...', 'Rebounding...', 'Rebranding...', 'Rebuilding...', 'Rebuking...', 'Recalculating...', 'Recalibrating...',
886
- 'Recalling...', 'Recanting...', 'Recapping...', 'Recapturing...', 'Receding...', 'Receiving...', 'Recentering...', 'Recharging...',
887
- 'Rechecking...', 'Rechristening...', 'Recirculating...', 'Reciting...', 'Reclaiming...', 'Reclassifying...', 'Reclining...', 'Recognizing...',
888
- 'Recoiling...', 'Recollecting...', 'Recommending...', 'Recommissioning...', 'Recompiling...', 'Reconciling...', 'Reconfiguring...', 'Reconnecting...',
889
- 'Reconsidering...', 'Reconstituting...', 'Reconstructing...', 'Recording...', 'Recounting...', 'Recouping...', 'Recovering...', 'Recreating...',
890
- 'Recruiting...', 'Rectifying...', 'Recuperating...', 'Recurring...', 'Recycling...', 'Redacting...', 'Redefining...', 'Redeploying...',
891
- 'Redesigning...', 'Redeveloping...', 'Redialing...', 'Redirecting...', 'Rediscovering...', 'Redistributing...', 'Redoing...', 'Redoubling...',
892
- 'Redrafting...', 'Reducing...', 'Reeling...', 'Refactoring...', 'Referencing...', 'Referring...', 'Refilling...', 'Refinancing...',
893
- 'Refining...', 'Refitting...', 'Reflecting...', 'Reformatting...', 'Reforming...', 'Refracting...', 'Refraining...', 'Refreshing...',
894
- 'Refueling...', 'Refunding...', 'Refurbishing...', 'Refusing...', 'Refuting...', 'Regaining...', 'Regaling...', 'Regarding...',
895
- 'Regenerating...', 'Registering...', 'Regressing...', 'Regretting...', 'Regrouping...', 'Regulating...', 'Rehabilitating...', 'Rehearsing...',
896
- 'Reigning...', 'Reimbursing...', 'Reinforcing...', 'Reining...', 'Reinstalling...', 'Reinstating...', 'Reintegrating...', 'Reinterpreting...',
897
- 'Reintroducing...', 'Reinventing...', 'Reinvesting...', 'Reissuing...', 'Reiterating...', 'Rejecting...', 'Rejoicing...', 'Rejoining...',
898
- 'Rejuvenating...', 'Rekindling...', 'Relating...', 'Relaxing...', 'Relaying...', 'Releasing...', 'Relegating...', 'Relenting...',
899
- 'Relieving...', 'Relighting...', 'Relishing...', 'Reliving...', 'Reloading...', 'Relocating...', 'Relying...', 'Remaining...',
900
- 'Remanding...', 'Remarking...', 'Remarrying...', 'Remastering...', 'Remedying...', 'Remembering...', 'Reminding...', 'Reminiscing...',
901
- 'Remitting...', 'Remodeling...', 'Remolding...', 'Remonstrating...', 'Removing...', 'Rendering...', 'Rendezvousing...', 'Reneging...',
902
- 'Renegotiating...', 'Renewing...', 'Renouncing...', 'Renovating...', 'Renting...', 'Reopening...', 'Reorganizing...', 'Repacking...',
903
- 'Repairing...', 'Repaying...', 'Repealing...', 'Repeating...', 'Repelling...', 'Repenting...', 'Repercussing...', 'Replacing...',
904
- 'Replaying...', 'Replenishing...', 'Replicating...', 'Replying...', 'Repointing...', 'Reporting...', 'Reposing...', 'Repositioning...',
905
- 'Representing...', 'Repressing...', 'Reprimanding...', 'Reprinting...', 'Reproaching...', 'Reprocessing...', 'Reproducing...', 'Reprogramming...',
906
- 'Reproving...', 'Repulsing...', 'Repurposing...', 'Requesting...', 'Requiring...', 'Requisitioning...', 'Requiting...', 'Rescinding...',
907
- 'Rescuing...', 'Researching...', 'Reselling...', 'Resembling...', 'Resenting...', 'Reserving...', 'Resetting...', 'Resettling...',
908
- 'Reshaping...', 'Residing...', 'Resigning...', 'Resisting...', 'Reskilling...', 'Resoling...', 'Resolving...', 'Resonating...',
909
- 'Resorting...', 'Resounding...', 'Respecting...', 'Respiring...', 'Responding...', 'Restarting...', 'Restating...', 'Resting...',
910
- 'Restocking...', 'Restoring...', 'Restraining...', 'Restricting...', 'Restructuring...', 'Resulting...', 'Resuming...', 'Resurfacing...',
911
- 'Resurging...', 'Resurrecting...', 'Retailing...', 'Retaining...', 'Retaliating...', 'Retelling...', 'Rethinking...', 'Retiring...',
912
- 'Retorting...', 'Retouching...', 'Retracing...', 'Retracting...', 'Retraining...', 'Retranslating...', 'Retreating...', 'Retrieving...',
913
- 'Retrofitting...', 'Retrying...', 'Returning...', 'Reunifying...', 'Reuniting...', 'Reusing...', 'Revealing...', 'Reveling...',
914
- 'Revenging...', 'Reverberating...', 'Revering...', 'Reversing...', 'Reverting...', 'Reviewing...', 'Reviling...', 'Revising...',
915
- 'Revisiting...', 'Revitalizing...', 'Reviving...', 'Revoking...', 'Revolting...', 'Revolutionizing...', 'Revolving...', 'Rewarding...',
916
- 'Rewiring...', 'Rewording...', 'Reworking...', 'Rewriting...', 'Rhapsodizing...', 'Rhyming...', 'Ribbing...', 'Ridding...',
917
- 'Riding...', 'Riffling...', 'Rifling...', 'Rigging...', 'Righting...', 'Rimming...', 'Ringing...', 'Rinsing...',
918
- 'Rioting...', 'Ripping...', 'Rippling...', 'Rising...', 'Risking...', 'Rivaling...', 'Riveting...', 'Roaming...',
919
- 'Roaring...', 'Roasting...', 'Robbing...', 'Robing...', 'Rocking...', 'Rolling...', 'Romancing...', 'Romanticizing...',
920
- 'Roofing...', 'Rooming...', 'Rooting...', 'Roping...', 'Rorting...', 'Rostering...', 'Rotating...', 'Roting...',
921
- 'Roughening...', 'Rounding...', 'Rousing...', 'Rousting...', 'Roving...', 'Rowing...', 'Rubbing...', 'Ruining...',
922
- 'Ruling...', 'Rumbling...', 'Ruminating...', 'Rummaging...', 'Running...', 'Ruptioning...', 'Rushing...', 'Rusticating...',
923
- 'Rustling...', 'Reticulating...',
1084
+ 'Echoing', 'Eclipsing', 'Economizing', 'Edging', 'Editing', 'Educating', 'Effecting', 'Effervescing', 'Elaborating', 'Elating',
1085
+ 'Electing', 'Electrifying', 'Elevating', 'Eliciting', 'Eliminating', 'Elucidating', 'Emancipating', 'Embarking', 'Embedding', 'Embellishing',
1086
+ 'Embodying', 'Embracing', 'Embroidering', 'Emerging', 'Emitting', 'Empathizing', 'Emphasizing', 'Empowering', 'Emulating', 'Enabling',
1087
+ 'Enacting', 'Encapsulating', 'Enchanting', 'Encircling', 'Enclosing', 'Encoding', 'Encouraging', 'Encrypting', 'Endearing', 'Endeavoring',
1088
+ 'Endorsing', 'Endowing', 'Enduring', 'Energizing', 'Enforcing', 'Engaging', 'Engineering', 'Engraving', 'Enhancing', 'Enjoying',
1089
+ 'Enlarging', 'Enlightening', 'Enlisting', 'Enlivening', 'Enriching', 'Enrolling', 'Ensuring', 'Entering', 'Entertaining', 'Enthralling',
1090
+ 'Enthusing', 'Enticing', 'Entitling', 'Enumerating', 'Enveloping', 'Envisioning', 'Equipping', 'Equalizing', 'Erecting', 'Erasing',
1091
+ 'Escalating', 'Escaping', 'Escorting', 'Establishing', 'Esteeming', 'Estimating', 'Eulogizing', 'Evaluating', 'Evaporating', 'Evolving',
1092
+ 'Examining', 'Exceeding', 'Excelling', 'Exchanging', 'Exciting', 'Excluding', 'Executing', 'Exercising', 'Exhaling', 'Exhibiting',
1093
+ 'Exhilarating', 'Existing', 'Expanding', 'Expecting', 'Expediting', 'Experiencing', 'Experimenting', 'Explaining', 'Exploring', 'Exporting',
1094
+ 'Exposing', 'Expressing', 'Extending', 'Externalizing', 'Extracting', 'Extrapolating', 'Eyeing', 'Fabricating', 'Facilitating', 'Facing',
1095
+ 'Factoring', 'Fading', 'Farming', 'Fascinating', 'Fashioning', 'Fastening', 'Fathoming', 'Favoring', 'Feathering', 'Featuring',
1096
+ 'Feeding', 'Feeling', 'Fencing', 'Fermenting', 'Ferrying', 'Fertilizing', 'Fetching', 'Fiddling', 'Fielding', 'Figuring',
1097
+ 'Filing', 'Filling', 'Filtering', 'Financing', 'Finding', 'Fingerprinting', 'Finishing', 'Firing', 'Firming', 'Fishing',
1098
+ 'Fitting', 'Fixing', 'Flagging', 'Flanking', 'Flashing', 'Flattening', 'Flattering', 'Flavoring', 'Fleshing', 'Flexing',
1099
+ 'Flicking', 'Flinging', 'Flipping', 'Floating', 'Flooding', 'Flooring', 'Flourishing', 'Flowing', 'Flushing', 'Fluttering',
1100
+ 'Flying', 'Foaming', 'Focusing', 'Folding', 'Following', 'Forging', 'Forgiving', 'Formalizing', 'Forming', 'Formulating',
1101
+ 'Fortifying', 'Forwarding', 'Fostering', 'Founding', 'Fragmenting', 'Framing', 'Freeing', 'Freezing', 'Freshening', 'Frosting',
1102
+ 'Frying', 'Fueling', 'Fulfilling', 'Functioning', 'Funding', 'Furnishing', 'Furthering', 'Fusing', 'Fussing', 'Factorizing',
1103
+ 'Fantasizing', 'Faultfinding', 'Federating', 'Fending', 'Ferreting', 'Filming', 'Finalizing', 'Finessing', 'Fireproofing', 'Fizzing',
1104
+ 'Flameproofing', 'Fleecing', 'Flickering', 'Flitting', 'Fluctuating', 'Fluorescing', 'Freewheeling', 'Gaining', 'Galloping', 'Galvanizing',
1105
+ 'Gaming', 'Gardening', 'Garlanding', 'Garnering', 'Gathering', 'Gauging', 'Gazing', 'Gearing', 'Generalizing', 'Generating',
1106
+ 'Gesticulating', 'Gesturing', 'Getting', 'Gifting', 'Giggling', 'Gilding', 'Girding', 'Giving', 'Glancing', 'Glazing',
1107
+ 'Gleaming', 'Gleaning', 'Glimmering', 'Glimpsing', 'Gliding', 'Glistening', 'Glittering', 'Glowing', 'Glueing', 'Goading',
1108
+ 'Gobbling', 'Golfing', 'Governing', 'Grabbing', 'Graduating', 'Grafting', 'Granting', 'Graphing', 'Grasping', 'Gratifying',
1109
+ 'Grating', 'Greasing', 'Greeting', 'Gridding', 'Grilling', 'Grinding', 'Gripping', 'Grooving', 'Grooming', 'Grounding',
1110
+ 'Grouping', 'Growing', 'Guaranteeing', 'Guarding', 'Guessing', 'Guiding', 'Gushing', 'Gusting', 'Gutting', 'Guzzling',
1111
+ 'Gamboling', 'Gating', 'Gazetting', 'Gelling', 'Gentrifying', 'Germinating', 'Gestating', 'Ghosting', 'Glaciating', 'Gladdening',
1112
+ 'Globalizing', 'Glorifying', 'Glossing', 'Gonging', 'Gouging', 'Grandstanding', 'Granulating', 'Gravitating', 'Grazing', 'Greening',
1113
+ 'Grimacing', 'Grubbing', 'Gurgling', 'Gyrating', 'Gabbing', 'Gaffing', 'Gallivanting', 'Galumphing', 'Garbling', 'Garrisoning',
1114
+ 'Gasping', 'Gentling', 'Geotagging', 'Gimballing', 'Glinting', 'Gorging', 'Grinning', 'Habilitating', 'Hacking', 'Haggling',
1115
+ 'Hailing', 'Hammering', 'Handcrafting', 'Handing', 'Handling', 'Hanging', 'Happening', 'Harboring', 'Hardening', 'Harmonizing',
1116
+ 'Harnessing', 'Harvesting', 'Hashing', 'Hastening', 'Hatching', 'Hauling', 'Having', 'Heading', 'Healing', 'Heaping',
1117
+ 'Hearing', 'Heartening', 'Heating', 'Heaving', 'Hedging', 'Heeding', 'Helping', 'Heralding', 'Herding', 'Hesitating',
1118
+ 'Hibernating', 'Hiding', 'Highlighting', 'Hiking', 'Hinging', 'Hinting', 'Hiring', 'Hitting', 'Hoarding', 'Hoisting',
1119
+ 'Hollowing', 'Homing', 'Honing', 'Honoring', 'Hooking', 'Hopping', 'Hoping', 'Horsing', 'Hosting', 'Hotfixing',
1120
+ 'Hovering', 'Howling', 'Huddling', 'Hugging', 'Hulling', 'Humanizing', 'Humbling', 'Humming', 'Hunting', 'Hurling',
1121
+ 'Hurrying', 'Hushing', 'Hustling', 'Hybridizing', 'Hydrating', 'Hydroplaning', 'Hymning', 'Hyping', 'Hypothesizing', 'Habituating',
1122
+ 'Hairdressing', 'Hallowing', 'Halving', 'Handholding', 'Handpicking', 'Handwriting', 'Hankering', 'Hardcoding', 'Harkening', 'Hawking',
1123
+ 'Haymaking', 'Headhunting', 'Headlining', 'Heartwarming', 'Heightening', 'Heehawing', 'Homesteading', 'Homeschooling', 'Honeying', 'Hooping',
1124
+ 'Hornswoggling', 'Hosing', 'Hounding', 'Huckstering', 'Humoring', 'Idealizing', 'Identifying', 'Igniting', 'Ignoring', 'Illuminating',
1125
+ 'Illustrating', 'Imagining', 'Imitating', 'Immersing', 'Immortalizing', 'Impacting', 'Imparting', 'Impersonating', 'Implementing', 'Implicating',
1126
+ 'Importing', 'Imposing', 'Impressing', 'Imprinting', 'Improving', 'Improvising', 'Inaugurating', 'Incentivizing', 'Inching', 'Including',
1127
+ 'Incorporating', 'Increasing', 'Incrementing', 'Incubating', 'Incurring', 'Indenting', 'Indexing', 'Indicating', 'Inducting', 'Indulging',
1128
+ 'Industrializing', 'Inferring', 'Infiltrating', 'Inflating', 'Influencing', 'Informing', 'Infusing', 'Ingesting', 'Inhabiting', 'Inhaling',
1129
+ 'Inheriting', 'Inhibiting', 'Initializing', 'Initiating', 'Injecting', 'Injuring', 'Inking', 'Innovating', 'Inoculating', 'Inquiring',
1130
+ 'Inscribing', 'Inserting', 'Insisting', 'Inspecting', 'Inspiring', 'Installing', 'Instantiating', 'Instigating', 'Instilling', 'Instituting',
1131
+ 'Instructing', 'Insulating', 'Insuring', 'Integrating', 'Intending', 'Intensifying', 'Interacting', 'Intercepting', 'Interfacing', 'Interleaving',
1132
+ 'Interlinking', 'Interlacing', 'Interlocking', 'Interning', 'Interpolating', 'Interpreting', 'Interrogating', 'Intersecting', 'Intervening', 'Interviewing',
1133
+ 'Interweaving', 'Intoning', 'Introducing', 'Intuiting', 'Inventing', 'Inventorying', 'Inverting', 'Investing', 'Investigating', 'Invigorating',
1134
+ 'Inviting', 'Involving', 'Ionizing', 'Iterating', 'Jabbing', 'Jacketing', 'Jailing', 'Jamming', 'Jangling', 'Jarring',
1135
+ 'Jaunting', 'Jazzing', 'Jeering', 'Jelling', 'Jeopardizing', 'Jerking', 'Jesting', 'Jettisoning', 'Jeweling', 'Jibing',
1136
+ 'Jiggling', 'Jigsawing', 'Jilting', 'Jingling', 'Jiving', 'Jobbing', 'Jockeying', 'Jogging', 'Joining', 'Joking',
1137
+ 'Jollying', 'Jolting', 'Jostling', 'Jotting', 'Journeying', 'Jousting', 'Joyriding', 'Judging', 'Juggling', 'Julienning',
1138
+ 'Jumping', 'Junketing', 'Justifying', 'Jutting', 'Juxtaposing', 'Jabbering', 'Jading', 'Jalousing', 'Japing', 'Jargonizing',
1139
+ 'Jauncing', 'Jawboning', 'Jawing', 'Jeopardying', 'Jellifying', 'Jemmying', 'Jettying', 'Jewelling', 'Jibbing', 'Jigging',
1140
+ 'Jitterbugging', 'Jinxing', 'Jittering', 'Jobhunting', 'Joggling', 'Joisting', 'Jokifying', 'Jollifying', 'Jonesing', 'Joshing',
1141
+ 'Jouking', 'Journalling', 'Jovializing', 'Jowing', 'Joying', 'Joypopping', 'Jubilating', 'Juddering', 'Judoing', 'Juking',
1142
+ 'Jumbling', 'Jumpstarting', 'Junking', 'Jurifying', 'Jurying', 'Justling', 'Juicing', 'Jouncing', 'Javelining', 'Jocundizing',
1143
+ 'Japering', 'Japonicaing', 'Jasperizing', 'Jazzercising', 'Jellyfishing', 'Jerrybuilding', 'Jerseying', 'Jessying', 'Jobseeking', 'Jointing',
1144
+ 'Junketeering', 'Jawbreaking', 'Jaywalking', 'Keeling', 'Keening', 'Keeping', 'Kenning', 'Kerbing', 'Kerneling', 'Kettling',
1145
+ 'Keyboarding', 'Keying', 'Keynoting', 'Kibitzing', 'Kicking', 'Kidding', 'Kidnapping', 'Kilning', 'Kindling', 'Kinging',
1146
+ 'Kinking', 'Kissing', 'Kitting', 'Kneading', 'Kneeling', 'Knifing', 'Knighting', 'Knitting', 'Knocking', 'Knotting',
1147
+ 'Knowing', 'Knuckling', 'Koshering', 'Kowtowing', 'Kvetching', 'Karaoking', 'Kayoing', 'Kecking', 'Kedging', 'Keeking',
1148
+ 'Kelping', 'Kenneling', 'Keratinizing', 'Kerfing', 'Kernelling', 'Kibbutzing', 'Kiboshing', 'Kickboxing', 'Kickstarting', 'Kiddying',
1149
+ 'Kiltering', 'Kippering', 'Kiting', 'Kittening', 'Klaxoning', 'Knapping', 'Kneecapping', 'Knelling', 'Knobbling', 'Knolling',
1150
+ 'Kodaking', 'Konking', 'Kooking', 'Koreanizing', 'Kremlining', 'Krumping', 'Kudosing', 'Kurbling', 'Kvelling', 'Kingmaking',
1151
+ 'Kyanising', 'Kyboshing', 'Kything', 'Kaolinizing', 'Kaputing', 'Katalysing', 'Kathoding', 'Keshing', 'Keypunching', 'Khediving',
1152
+ 'Kalsomining', 'Kamiing', 'Kantianing', 'Karaokeing', 'Karateing', 'Karstifying', 'Katabolizing', 'Kathing', 'Kazaching', 'Kebabbing',
1153
+ 'Kebobbing', 'Keeving', 'Keelhauling', 'Keenlying', 'Keepnetting', 'Keltering', 'Kerseying', 'Ketling', 'Keyframing', 'Keyseating',
1154
+ 'Kibbling', 'Kickboarding', 'Kickflipping', 'Kidneystoning', 'Kieving', 'Kimchifying', 'Kimmering', 'Kindergartening', 'Kinescoping', 'Kingfishing',
1155
+ 'Kirkifying', 'Kirshening', 'Kitbagging', 'Kitchening', 'Kitesurfing', 'Klondiking', 'Kludging', 'Knackering', 'Kneippering', 'Knobkerrying',
1156
+ 'Knowledging', 'Kohlrabiing', 'Koorifying', 'Kotowing', 'Kronaing', 'Krytroning', 'Kugelhopfing', 'Kumquating', 'Kurrajonging', 'Kyanizing',
1157
+ 'Labeling', 'Laboring', 'Lacing', 'Laddering', 'Lading', 'Lagging', 'Lambasting', 'Laminating', 'Landing', 'Languishing',
1158
+ 'Lapping', 'Lapsing', 'Larding', 'Lashing', 'Lasting', 'Latching', 'Lathering', 'Lauding', 'Laughing', 'Launching',
1159
+ 'Laundering', 'Lavishing', 'Laying', 'Lazing', 'Leaching', 'Leading', 'Leafing', 'Leaking', 'Leaning', 'Leaping',
1160
+ 'Learning', 'Leasing', 'Leathering', 'Leaving', 'Lecturing', 'Ledgering', 'Leeching', 'Leering', 'Legitimizing', 'Lending',
1161
+ 'Lengthening', 'Lessening', 'Letting', 'Leveling', 'Leveraging', 'Levying', 'Liberating', 'Licensing', 'Licking', 'Lifting',
1162
+ 'Lightening', 'Lighting', 'Liking', 'Limbering', 'Lining', 'Linking', 'Lionizing', 'Liquidating', 'Lisping', 'Listening',
1163
+ 'Litigating', 'Littering', 'Living', 'Loading', 'Loafing', 'Loaning', 'Lobbying', 'Lobing', 'Localizing', 'Locating',
1164
+ 'Locking', 'Lodging', 'Lofting', 'Logging', 'Longing', 'Looking', 'Looping', 'Loosening', 'Looting', 'Loping',
1165
+ 'Losing', 'Lounging', 'Loving', 'Lowering', 'Lubricating', 'Lugging', 'Lulling', 'Lumbering', 'Lumping', 'Lunching',
1166
+ 'Lunging', 'Luring', 'Lurking', 'Lustering', 'Lustrating', 'Luxuriating', 'Lying', 'Lynching', 'Lyricizing', 'Machining',
1167
+ 'Magnetizing', 'Magnifying', 'Maintaining', 'Mainstreaming', 'Making', 'Managing', 'Mandating', 'Maneuvering', 'Mangling', 'Manifesting',
1168
+ 'Manipulating', 'Manning', 'Manufacturing', 'Mapping', 'Marching', 'Marketing', 'Marking', 'Marrying', 'Marshaling', 'Marveling',
1169
+ 'Mashing', 'Masking', 'Massaging', 'Massing', 'Mastering', 'Matching', 'Materializing', 'Matriculating', 'Mattering', 'Maximizing',
1170
+ 'Meaning', 'Measuring', 'Mediating', 'Meditating', 'Meeting', 'Melding', 'Mellowing', 'Melting', 'Memorizing', 'Mending',
1171
+ 'Mentioning', 'Mentoring', 'Merging', 'Meriting', 'Meshing', 'Mesmerizing', 'Messaging', 'Metabolizing', 'Metalworking', 'Metamorphosing',
1172
+ 'Metering', 'Microblogging', 'Microchipping', 'Microfilming', 'Micromanaging', 'Migrating', 'Milking', 'Milling', 'Mimicking', 'Mincing',
1173
+ 'Minding', 'Mingling', 'Miniaturizing', 'Minimizing', 'Mining', 'Ministering', 'Minting', 'Mirroring', 'Misaligning', 'Miscalculating',
1174
+ 'Misdiagnosing', 'Misfiring', 'Misjudging', 'Mislaying', 'Mismatching', 'Misplacing', 'Misquoting', 'Misreading', 'Misspelling', 'Mistaking',
1175
+ 'Misting', 'Misunderstanding', 'Mitigating', 'Mixing', 'Moaning', 'Mobilizing', 'Mocking', 'Modeling', 'Moderating', 'Modernizing',
1176
+ 'Modifying', 'Modularizing', 'Modulating', 'Moistening', 'Moisturizing', 'Molding', 'Mollifying', 'Monitoring', 'Monetizing', 'Monogramming',
1177
+ 'Monologuing', 'Monopolizing', 'Mooring', 'Moralizing', 'Morphing', 'Mortgaging', 'Moseying', 'Motivating', 'Motoring', 'Mottling',
1178
+ 'Moulding', 'Mounting', 'Mountaineering', 'Mourning', 'Mousetrapping', 'Mouthing', 'Moving', 'Mowing', 'Muckraking', 'Muddling',
1179
+ 'Mudslinging', 'Muffling', 'Mulching', 'Multiplying', 'Multiplexing', 'Multitasking', 'Mumbling', 'Mummifying', 'Munching', 'Murmuring',
1180
+ 'Muscling', 'Musicalizing', 'Musing', 'Musseling', 'Mustering', 'Mutating', 'Muting', 'Mutinying', 'Muttering', 'Mutualizing',
1181
+ 'Muzzling', 'Mystifying', 'Mailing', 'Mamboing', 'Manacling', 'Manhandling', 'Manicuring', 'Mansplaining', 'Mantling', 'Marinating',
1182
+ 'Marooning', 'Marring', 'Masquerading', 'Masterminding', 'Masticating', 'Mauling', 'Meandering', 'Mechanizing', 'Meddling', 'Meliorating',
1183
+ 'Melodizing', 'Memorializing', 'Menacing', 'Mercerizing', 'Merchandising', 'Metallizing', 'Mildewing', 'Militating', 'Mimeographing', 'Mindmapping',
1184
+ 'Minesweeping', 'Misappropriating', 'Misconstruing', 'Misrepresenting', 'Nabbing', 'Nailing', 'Naming', 'Nanosizing', 'Napping', 'Narrating',
1185
+ 'Narrowing', 'Nattering', 'Naturalizing', 'Navigating', 'Neatening', 'Nebulizing', 'Necessitating', 'Necking', 'Necropsying', 'Needling',
1186
+ 'Negating', 'Negotiating', 'Nerving', 'Nesting', 'Nestling', 'Netting', 'Nettling', 'Networking', 'Neutralizing', 'Newscasting',
1187
+ 'Nibbling', 'Nicking', 'Nicknaming', 'Nictitating', 'Niggling', 'Nipping', 'Nitpicking', 'Nobbling', 'Nocking', 'Nodding',
1188
+ 'Noising', 'Nominating', 'Nonplussing', 'Noodling', 'Normalizing', 'Nosediving', 'Nosing', 'Notarizing', 'Notating', 'Notching',
1189
+ 'Noticing', 'Notifying', 'Noting', 'Nourishing', 'Novelizing', 'Nucleating', 'Nudging', 'Nuggetting', 'Nuking', 'Nullifying',
1190
+ 'Numbering', 'Numbing', 'Nursemaiding', 'Nursing', 'Nurturing', 'Nutmegging', 'Nuzzling', 'Narrowcasting', 'Obeying', 'Objecting',
1191
+ 'Objectivizing', 'Obligating', 'Obliging', 'Obliterating', 'Obnubilating', 'Obscuring', 'Observing', 'Obsessing', 'Obsoleting', 'Obstructing',
1192
+ 'Obtaining', 'Obtruding', 'Obviating', 'Occasioning', 'Occupying', 'Occurring', 'Offering', 'Officiating', 'Offloading', 'Offsetting',
1193
+ 'Ogling', 'Oiling', 'Okaying', 'Ominating', 'Omitting', 'Onboarding', 'Oozing', 'Opacifying', 'Opalescing', 'Opening',
1194
+ 'Operating', 'Opining', 'Opposing', 'Optimizing', 'Optioning', 'Orating', 'Orbiting', 'Orchestrating', 'Ordaining', 'Ordering',
1195
+ 'Organizing', 'Orienting', 'Originating', 'Ornamenting', 'Orphaning', 'Oscillating', 'Ossifying', 'Ostracizing', 'Outbidding', 'Outclassing',
1196
+ 'Outdoing', 'Outfitting', 'Outgrowing', 'Outlasting', 'Outlining', 'Outmaneuvering', 'Outpacing', 'Outperforming', 'Outreaching', 'Outsmarting',
1197
+ 'Outsourcing', 'Outstripping', 'Outwitting', 'Overachieving', 'Overarching', 'Overbalancing', 'Overbooking', 'Overbuilding', 'Overcoming', 'Overcompensating',
1198
+ 'Overdelivering', 'Overdoing', 'Overeating', 'Overemphasizing', 'Overestimating', 'Overflowing', 'Overhauling', 'Overhearing', 'Overheating', 'Overlaying',
1199
+ 'Overloading', 'Overlooking', 'Overpowering', 'Overprinting', 'Overproducing', 'Overreaching', 'Overriding', 'Overruling', 'Overseeing', 'Overshadowing',
1200
+ 'Overshooting', 'Oversimplifying', 'Oversleeping', 'Overstating', 'Overstepping', 'Overstocking', 'Overstretching', 'Overtaking', 'Overthinking', 'Overthrowing',
1201
+ 'Overturning', 'Overvaluing', 'Overwatching', 'Overwhelming', 'Owing', 'Owning', 'Oxidizing', 'Oxygenating', 'Ozonizing', 'Obolizing',
1202
+ 'Pacing', 'Packaging', 'Packing', 'Padding', 'Paddling', 'Paging', 'Painting', 'Pairing', 'Palatalizing', 'Palling',
1203
+ 'Palming', 'Palpating', 'Pampering', 'Panelizing', 'Panicking', 'Pantomiming', 'Papering', 'Parachuting', 'Parading', 'Paragliding',
1204
+ 'Paralysing', 'Paraphrasing', 'Parceling', 'Pardoning', 'Parenting', 'Pariking', 'Parking', 'Parleying', 'Parodying', 'Paroling',
1205
+ 'Parrying', 'Parsing', 'Partaking', 'Partitioning', 'Partnering', 'Partying', 'Passing', 'Passivizing', 'Pasting', 'Pastoring',
1206
+ 'Patching', 'Patenting', 'Patrolling', 'Patronizing', 'Patterning', 'Paving', 'Pawing', 'Paying', 'Peaking', 'Pealing',
1207
+ 'Pecking', 'Pedaling', 'Peeking', 'Peeling', 'Peering', 'Pegging', 'Pelting', 'Penalizing', 'Penciling', 'Penetrating',
1208
+ 'Pensioning', 'Peppering', 'Perceiving', 'Perching', 'Perfecting', 'Perforating', 'Performing', 'Perfuming', 'Perishing', 'Permeating',
1209
+ 'Permitting', 'Permuting', 'Perpetuating', 'Perplexing', 'Persevering', 'Persisting', 'Personalizing', 'Personifying', 'Persuading', 'Perving',
1210
+ 'Pestering', 'Petitioning', 'Petrifying', 'Pettifogging', 'Phaseing', 'Philandering', 'Philosophizing', 'Phoneing', 'Photocopying', 'Photographing',
1211
+ 'Photosynthesizing', 'Phrasing', 'Picking', 'Picnicking', 'Picturing', 'Piecing', 'Piercing', 'Piggybacking', 'Pilfering', 'Piloting',
1212
+ 'Pinching', 'Pinging', 'Pinning', 'Pioneering', 'Piping', 'Pitching', 'Pitying', 'Pivoting', 'Placating', 'Placing',
1213
+ 'Plagiarizing', 'Plaiting', 'Planning', 'Planting', 'Plastering', 'Plating', 'Playing', 'Pleading', 'Pleasuring', 'Pledging',
1214
+ 'Plodding', 'Plotting', 'Ploughing', 'Plowing', 'Plucking', 'Plugging', 'Plumbing', 'Plummeting', 'Plumping', 'Plundering',
1215
+ 'Plunging', 'Pluralizing', 'Plying', 'Poaching', 'Pocketing', 'Podcasting', 'Poeticizing', 'Pointing', 'Poising', 'Poisoning',
1216
+ 'Poking', 'Polarizing', 'Policing', 'Polishing', 'Politicizing', 'Pollinating', 'Polling', 'Pondering', 'Pooling', 'Popping',
1217
+ 'Popularizing', 'Populating', 'Poring', 'Porting', 'Portioning', 'Portraying', 'Posing', 'Positioning', 'Positing', 'Possessing',
1218
+ 'Posting', 'Postponing', 'Postulating', 'Posturing', 'Potting', 'Pouncing', 'Pounding', 'Pouring', 'Pouting', 'Powdering',
1219
+ 'Powering', 'Practicing', 'Praising', 'Prancing', 'Prattling', 'Praying', 'Preaching', 'Preambleing', 'Preceding', 'Precipitating',
1220
+ 'Precluding', 'Precooking', 'Predicting', 'Predisposing', 'Prefacing', 'Preferring', 'Prefiguring', 'Prefixing', 'Preheating', 'Prejudging',
1221
+ 'Preluding', 'Premising', 'Preoccupying', 'Preordaining', 'Preparing', 'Prepaving', 'Prepaying', 'Preponderating', 'Prerecording', 'Prescribing',
1222
+ 'Presenting', 'Preserving', 'Presetting', 'Presiding', 'Pressuring', 'Pressurizing', 'Prestressing', 'Presuming', 'Pretending', 'Prettifying',
1223
+ 'Prevailing', 'Preventing', 'Previewing', 'Preying', 'Pricing', 'Pricking', 'Pridiing', 'Priming', 'Printing', 'Prioritizing',
1224
+ 'Privatizing', 'Privileging', 'Prizing', 'Probing', 'Proceeding', 'Processing', 'Proclaiming', 'Procrastinating', 'Procuring', 'Prodding',
1225
+ 'Producing', 'Profaning', 'Professing', 'Profiling', 'Profiting', 'Programming', 'Progressing', 'Prohibiting', 'Projecting', 'Proliferating',
1226
+ 'Prolonging', 'Promenading', 'Promising', 'Promoting', 'Prompting', 'Promulgating', 'Pronouncing', 'Proofing', 'Proofreading', 'Propagating',
1227
+ 'Propelling', 'Prophesying', 'Proposing', 'Propping', 'Proroguing', 'Prosecuting', 'Proselytizing', 'Prospecting', 'Prospering', 'Protecting',
1228
+ 'Protesting', 'Protruding', 'Proving', 'Providing', 'Provisioning', 'Provoking', 'Prowling', 'Pruning', 'Psyching', 'Publicizing',
1229
+ 'Publishing', 'Puckering', 'Puffing', 'Pulling', 'Pulping', 'Pulsating', 'Pulverizing', 'Pummeling', 'Pumping', 'Punching',
1230
+ 'Puncturing', 'Punishing', 'Punning', 'Purging', 'Purifying', 'Purloining', 'Purporting', 'Purring', 'Pursuing', 'Pushing',
1231
+ 'Putting', 'Puzzling', 'Pyramiding', 'Quacking', 'Quadrupling', 'Quaffing', 'Quailing', 'Qualifying', 'Quantifying', 'Quarreling',
1232
+ 'Quarrying', 'Quartering', 'Quashing', 'Quavering', 'Queening', 'Quelling', 'Quenching', 'Querying', 'Questing', 'Questioning',
1233
+ 'Queueing', 'Quickening', 'Quieting', 'Quilting', 'Quipping', 'Quivering', 'Quizzing', 'Quoting', 'Racing', 'Racking',
1234
+ 'Radiating', 'Raffling', 'Rafting', 'Raging', 'Raining', 'Raising', 'Rallying', 'Rambling', 'Ramifying', 'Ramping',
1235
+ 'Ransacking', 'Ransoming', 'Ranting', 'Rapping', 'Rapproching', 'Rapturing', 'Raring', 'Rasping', 'Ratifying', 'Rating',
1236
+ 'Rationalizing', 'Rationing', 'Rattling', 'Ravaging', 'Raving', 'Reaching', 'Reacting', 'Readjusting', 'Readying', 'Realigning',
1237
+ 'Realizing', 'Reallocating', 'Reanimating', 'Reaping', 'Reappearing', 'Reapplying', 'Rearranging', 'Reasoning', 'Reassembling', 'Reasserting',
1238
+ 'Reassessing', 'Reassigning', 'Reassuring', 'Rebalancing', 'Rebating', 'Rebelling', 'Rebooting', 'Rebounding', 'Rebranding', 'Rebuilding',
1239
+ 'Rebuking', 'Recalculating', 'Recalibrating', 'Recalling', 'Recanting', 'Recapping', 'Recapturing', 'Receding', 'Receiving', 'Recentering',
1240
+ 'Recharging', 'Rechecking', 'Rechristening', 'Recirculating', 'Reciting', 'Reclaiming', 'Reclassifying', 'Reclining', 'Recognizing', 'Recoiling',
1241
+ 'Recollecting', 'Recommending', 'Recommissioning', 'Recompiling', 'Reconciling', 'Reconfiguring', 'Reconsidering', 'Reconstituting', 'Reconstructing', 'Recording',
1242
+ 'Recounting', 'Recouping', 'Recreating', 'Recruiting', 'Rectifying', 'Recuperating', 'Recurring', 'Recycling', 'Redacting', 'Redefining',
1243
+ 'Redeploying', 'Redesigning', 'Redeveloping', 'Redialing', 'Redirecting', 'Rediscovering', 'Redistributing', 'Redoing', 'Redoubling', 'Redrafting',
1244
+ 'Reducing', 'Reeling', 'Refactoring', 'Referencing', 'Referring', 'Refilling', 'Refinancing', 'Refining', 'Refitting', 'Reflecting',
1245
+ 'Reformatting', 'Reforming', 'Refracting', 'Refraining', 'Refreshing', 'Refueling', 'Refunding', 'Refurbishing', 'Refuting', 'Regaining',
1246
+ 'Regaling', 'Regarding', 'Regenerating', 'Registering', 'Regressing', 'Regretting', 'Regrouping', 'Regulating', 'Rehabilitating', 'Rehearsing',
1247
+ 'Reigning', 'Reimbursing', 'Reinforcing', 'Reining', 'Reinstalling', 'Reinstating', 'Reintegrating', 'Reinterpreting', 'Reintroducing', 'Reinventing',
1248
+ 'Reinvesting', 'Reissuing', 'Reiterating', 'Rejecting', 'Rejoicing', 'Rejoining', 'Rejuvenating', 'Rekindling', 'Relating', 'Relaxing',
1249
+ 'Relaying', 'Releasing', 'Relegating', 'Relenting', 'Relieving', 'Relighting', 'Relishing', 'Reliving', 'Reloading', 'Relocating',
1250
+ 'Relying', 'Remaining', 'Remanding', 'Remarking', 'Remarrying', 'Remastering', 'Remedying', 'Remembering', 'Reminding', 'Reminiscing',
1251
+ 'Remitting', 'Remodeling', 'Remolding', 'Remonstrating', 'Removing', 'Rendering', 'Rendezvousing', 'Reneging', 'Renegotiating', 'Renewing',
1252
+ 'Renouncing', 'Renovating', 'Renting', 'Reopening', 'Reorganizing', 'Repacking', 'Repairing', 'Repaying', 'Repealing', 'Repeating',
1253
+ 'Repelling', 'Repenting', 'Repercussing', 'Replacing', 'Replaying', 'Replenishing', 'Replicating', 'Replying', 'Repointing', 'Reporting',
1254
+ 'Reposing', 'Repositioning', 'Representing', 'Repressing', 'Reprimanding', 'Reprinting', 'Reproaching', 'Reprocessing', 'Reproducing', 'Reprogramming',
1255
+ 'Reproving', 'Repulsing', 'Repurposing', 'Requesting', 'Requiring', 'Requisitioning', 'Requiting', 'Rescinding', 'Rescuing', 'Researching',
1256
+ 'Reselling', 'Resembling', 'Resenting', 'Reserving', 'Resetting', 'Resettling', 'Reshaping', 'Residing', 'Resigning', 'Resisting',
1257
+ 'Reskilling', 'Resoling', 'Resolving', 'Resonating', 'Resorting', 'Resounding', 'Respecting', 'Respiring', 'Responding', 'Restarting',
1258
+ 'Restating', 'Resting', 'Restocking', 'Restoring', 'Restraining', 'Restricting', 'Restructuring', 'Resulting', 'Resuming', 'Resurfacing',
1259
+ 'Resurging', 'Resurrecting', 'Retailing', 'Retaining', 'Retaliating', 'Retelling', 'Rethinking', 'Retiring', 'Retorting', 'Retouching',
1260
+ 'Retracing', 'Retracting', 'Retraining', 'Retranslating', 'Retreating', 'Retrieving', 'Retrofitting', 'Returning', 'Reunifying', 'Reuniting',
1261
+ 'Reusing', 'Revealing', 'Reveling', 'Revenging', 'Reverberating', 'Revering', 'Reversing', 'Reverting', 'Reviewing', 'Reviling',
1262
+ 'Revising', 'Revisiting', 'Revitalizing', 'Reviving', 'Revoking', 'Revolting', 'Revolutionizing', 'Revolving', 'Rewarding', 'Rewiring',
1263
+ 'Rewording', 'Reworking', 'Rewriting', 'Rhapsodizing', 'Rhyming', 'Ribbing', 'Ridding', 'Riding', 'Riffling', 'Rifling',
1264
+ 'Rigging', 'Righting', 'Rimming', 'Ringing', 'Rinsing', 'Rioting', 'Ripping', 'Rippling', 'Rising', 'Risking',
1265
+ 'Rivaling', 'Riveting', 'Roaming', 'Roaring', 'Roasting', 'Robbing', 'Robing', 'Rocking', 'Rolling', 'Romancing',
1266
+ 'Romanticizing', 'Roofing', 'Rooming', 'Rooting', 'Roping', 'Rorting', 'Rostering', 'Rotating', 'Roting', 'Roughening',
1267
+ 'Rounding', 'Rousing', 'Rousting', 'Roving', 'Rowing', 'Rubbing', 'Ruining', 'Ruling', 'Rumbling', 'Ruminating',
1268
+ 'Rummaging', 'Running', 'Ruptioning', 'Rushing', 'Rusticating', 'Rustling', 'Reticulating',
924
1269
  ];
925
1270
 
926
1271
  // ---- ANSI helpers ----
@@ -1218,40 +1563,58 @@ export function lineDiff(oldStr, newStr, startLine = 1) {
1218
1563
 
1219
1564
  // Word-wrap a string that may contain ANSI colour codes, measuring VISIBLE
1220
1565
  // width only. Escape sequences are carried over to the line they belong to.
1221
- function wrapAnsiWords(str, width) {
1222
- width = Math.max(1, width | 0);
1223
- const tokens = String(str).split(/(\s+)/); // keep whitespace as tokens
1224
- const out = [];
1225
- let line = '';
1226
- let lineW = 0;
1227
- const push = () => { if (line !== '') out.push(line); line = ''; lineW = 0; };
1228
- for (const tok of tokens) {
1229
- if (tok === '') continue;
1230
- const tw = visualCol(tok);
1231
- if (/^\s+$/.test(tok)) {
1232
- if (lineW > 0) { line += tok; lineW += tw; }
1233
- continue;
1234
- }
1235
- if (lineW > 0 && lineW + tw > width) push();
1236
- if (tw > width && visualCol(tok) > width) {
1237
- // Hard-split an over-long token by visible columns.
1238
- let chunk = '';
1239
- let cw = 0;
1240
- for (const ch of tok) {
1241
- const chw = visualCol(ch);
1242
- if (cw + chw > width) { out.push(chunk); chunk = ''; cw = 0; }
1243
- chunk += ch; cw += chw;
1244
- }
1245
- if (chunk) { line = chunk; lineW = cw; }
1246
- continue;
1247
- }
1248
- line += tok;
1249
- lineW += tw;
1250
- }
1251
- if (line !== '') out.push(line);
1252
- if (out.length === 0) out.push('');
1253
- return out;
1254
- }
1566
+ function wrapAnsiWords(str, width) {
1567
+ width = Math.max(1, width | 0);
1568
+ const tokens = String(str).split(/(\s+)/); // keep whitespace as tokens
1569
+ const out = [];
1570
+ let line = '';
1571
+ let lineW = 0;
1572
+ // THE COLOUR ACTIVE AT THE BREAK, carried onto the next row.
1573
+ //
1574
+ // A colour is opened once and closed at the end of the string, so a line wrapped mid-colour leaves the
1575
+ // continuation starting with plain text — the terminal paints it in its DEFAULT foreground, which on a
1576
+ // dark theme is brighter than the grey the rest of the block uses. That is what a wrapped, expanded
1577
+ // command looked like: the first row grey and every row below it lighter.
1578
+ //
1579
+ // Tracked from the escapes seen so far and re-emitted at the start of each new row, then cleared. `0m`
1580
+ // resets it, so a line that closes its colour before the break does not reopen one.
1581
+ let active = '';
1582
+ const scan = (text) => {
1583
+ for (const m of text.matchAll(/\x1b\[([0-9;]*)m/g)) {
1584
+ active = m[1] === '0' || m[1] === '' ? '' : `\x1b[${m[1]}m`;
1585
+ }
1586
+ };
1587
+ const push = () => { if (line !== '') out.push(line); line = active; lineW = 0; };
1588
+ for (const tok of tokens) {
1589
+ if (tok === '') continue;
1590
+ const tw = visualCol(tok);
1591
+ if (/^\s+$/.test(tok)) {
1592
+ if (lineW > 0) { line += tok; lineW += tw; }
1593
+ continue;
1594
+ }
1595
+ if (lineW > 0 && lineW + tw > width) push();
1596
+ scan(tok);
1597
+ if (tw > width && visualCol(tok) > width) {
1598
+ // Hard-split an over-long token by visible columns.
1599
+ let chunk = '';
1600
+ let cw = 0;
1601
+ for (const ch of tok) {
1602
+ const chw = visualCol(ch);
1603
+ if (cw + chw > width) { out.push(chunk); chunk = active; cw = 0; }
1604
+ chunk += ch; cw += chw;
1605
+ }
1606
+ if (chunk) { line = chunk; lineW = cw; }
1607
+ continue;
1608
+ }
1609
+ line += tok;
1610
+ lineW += tw;
1611
+ }
1612
+ if (line !== '') out.push(line);
1613
+ if (out.length === 0) out.push('');
1614
+ // Rows that were seeded with a carried colour but ended up holding nothing are dropped: they would
1615
+ // render as an invisible escape-only line.
1616
+ return out.map((s) => s.replace(/^\x1b\[[0-9;]*m$/, '')).filter((s) => s !== '' || out.length === 1);
1617
+ }
1255
1618
 
1256
1619
  // Word-aware wrap that also returns each row's starting offset in the source
1257
1620
  // string, so the caret can be mapped to a (row, col) inside the composer.
@@ -1334,10 +1697,70 @@ export function expandPastes(text, pastes) {
1334
1697
  // style, so it also wiped the foreground the composer had already applied to the text AFTER the
1335
1698
  // chip — the rest of the line came out in the terminal's default grey instead of the prompt's
1336
1699
  // colour. `highlightSelection` below documents this exact hazard; this function was missing it.
1337
- export function highlightPasteMarkers(row) {
1700
+ //
1701
+ // `hoverId` is the paste number the pointer is over, when it is over one. That chip keeps its
1702
+ // background and takes a BRIGHTER FOREGROUND instead — `C.hover` is a foreground (bold + bright), not a
1703
+ // background, so using it as one would collide with the chip's own `selBg` and the two would fight.
1704
+ // Without any change the chip looks like ordinary text and Ctrl+click stays undiscoverable.
1705
+ export function highlightPasteMarkers(row, hoverId = null) {
1338
1706
  const s = String(row);
1339
1707
  if (!s.includes('[paste #')) return s;
1340
- return s.replace(PASTE_MARKER_RE, (m) => C.selBg + m + C.bgReset);
1708
+ return s.replace(PASTE_MARKER_RE, (m, id) => {
1709
+ if (Number(id) === hoverId) return C.hover + C.selBg + m + C.bgReset + C.reset;
1710
+ return C.selBg + m + C.bgReset;
1711
+ });
1712
+ }
1713
+ /**
1714
+ * Which paste number, if any, occupies this input index. Returns null when none does.
1715
+ * Which paste number, if any, occupies this input index. Returns null when none does.
1716
+ *
1717
+ * The companion to `expandPasteMarkerAt`: that one acts on the chip under the pointer, this one merely
1718
+ * names it so the row can be drawn differently. Both need the same bounds, and a chip is inclusive of its
1719
+ * end because the caret index rounds to the position after the last character.
1720
+ */
1721
+ export function pasteMarkerIdAt(input, idx) {
1722
+ const cur = String(input == null ? '' : input);
1723
+ if (!cur.includes('[paste #')) return null;
1724
+ for (const m of cur.matchAll(PASTE_MARKER_RE)) {
1725
+ const at = m.index;
1726
+ if (idx >= at && idx <= at + m[0].length) return Number(m[1]);
1727
+ }
1728
+ return null;
1729
+ }
1730
+
1731
+
1732
+ /**
1733
+ * Expand the collapsed paste the caret index lands on, in place. Returns true when one was expanded.
1734
+ *
1735
+ * Takes the ALREADY-CONVERTED index. An earlier version took the row and column and called
1736
+ * `composerTextIndexAt` itself, which subtracts the row's prefix width — but the caller had already
1737
+ * subtracted it, so every click landed two columns to the left of where the pointer was and a click on
1738
+ * the chip's first character expanded nothing.
1739
+ *
1740
+ * `state.pastes` keeps its entry: `expandPastes` is a no-op for text with no marker, so leaving it costs
1741
+ * nothing, and dropping it would break a later marker that still refers to the same paste.
1742
+ */
1743
+ export function expandPasteMarkerAt(state, idx) {
1744
+ const cur = state.input || '';
1745
+ if (!cur.includes('[paste #')) return false;
1746
+
1747
+ for (const m of cur.matchAll(PASTE_MARKER_RE)) {
1748
+ const at = m.index;
1749
+ const end = at + m[0].length;
1750
+ // The caret index is where the click landed, and clicking any character of the chip targets it —
1751
+ // including the character after the last one, which is where a click on the closing bracket rounds to.
1752
+ if (idx < at || idx > end) continue;
1753
+ const entry = state.pastes && state.pastes.get(Number(m[1]));
1754
+ if (!entry) return false;
1755
+ state.input = cur.slice(0, at) + entry.text + cur.slice(end);
1756
+ // The caret goes AFTER the expanded text, so the next keystroke continues where it ends rather than
1757
+ // landing in the middle of the paste the user just revealed.
1758
+ state.caret = at + entry.text.length;
1759
+ state.composerSel = null;
1760
+ refreshMenu(state);
1761
+ return true;
1762
+ }
1763
+ return false;
1341
1764
  }
1342
1765
 
1343
1766
  // Apply selection background to the text portion of a composer row.
@@ -1642,9 +2065,28 @@ const KEY_ARG = {
1642
2065
  TaskStop: ['task_id'],
1643
2066
  };
1644
2067
  const MAX_ARG = 60;
1645
- function keyArgument(name, args, workspace) {
2068
+ /**
2069
+ * The one argument worth showing next to a tool's name. `expanded` lifts the length cut.
2070
+ *
2071
+ * The cut exists so the single `● Used Bash (…)` row stays readable: a `node -e "…"` one-liner is
2072
+ * routinely hundreds of characters, and printing it whole wrapped the row across the screen for
2073
+ * every call. Ctrl+O is the escape hatch — whoever expands tool output to read it wants to see the
2074
+ * command they are reading the output OF, and the command is what they asked for in the first place.
2075
+ */
2076
+ function keyArgument(name, args, workspace, expanded = false) {
1646
2077
  if (!args || typeof args !== 'object') return '';
1647
2078
  const keys = KEY_ARG[name] || Object.keys(args);
2079
+ // Expanded: the whole value, with newlines kept, so a multi-line script shows as the lines it was
2080
+ // written as rather than being flattened to its first line.
2081
+ if (expanded) {
2082
+ for (const k of keys) {
2083
+ const v = args[k];
2084
+ if (typeof v !== 'string' || !v.length) continue;
2085
+ const full = sanitizeText(v).replace(/\r\n/g, '\n');
2086
+ return (k === 'path' || k === 'file_path') ? full.replace(/\\/g, '/') : full;
2087
+ }
2088
+ return '';
2089
+ }
1648
2090
  for (const k of keys) {
1649
2091
  const v = args[k];
1650
2092
  if (typeof v !== 'string' || !v.length) continue;
@@ -1683,95 +2125,78 @@ function keyArgument(name, args, workspace) {
1683
2125
  }
1684
2126
  return '';
1685
2127
  }
1686
- export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge) {
2128
+ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded = false, reducedMotion = false) {
1687
2129
  const name = msg.toolName || '';
1688
- const arg = keyArgument(name, msg.toolArgs, workspace);
2130
+ const arg = keyArgument(name, msg.toolArgs, workspace, expanded);
1689
2131
  const verb = msg.pending ? 'Using' : 'Used';
1690
2132
  // Pending tool calls have a sweep animation (like "Working...")
1691
2133
  // Once the tool finishes (pending=false) the name shows in plain cyan.
1692
2134
  let nameDisplay;
1693
2135
  if (msg.pending) {
1694
- // Multi-stage sweep animation for tool names (like the "Working..." line):
1695
- // out to pale cyan, back; out to cyan-blue, back — one cycle.
1696
- // The tick counts come from SWEEP_TICKS so this row and the Working row share
1697
- // one smoothness budget (see the SWEEP_* block near the top of the file).
1698
- const SWEEP = SWEEP_TICKS; // ticks per half-sweep
1699
- const HALF = SWEEP + SWEEP; // ticks per colour pair (sweep + sweep back)
1700
- const CYCLE = HALF * 2; // ticks for the full loop
1701
- // Phase is measured from the tick the pulse BEGAN, so a spinner that has already
1702
- // been running does not drop the sweep into a mid-cycle colour.
2136
+ // A light flashes across the tool name roughly once a second: the name stays plain
2137
+ // cyan, and a lamp slightly whiter than cyan runs over it from left to right.
1703
2138
  //
1704
- // `??`, NOT `||`: `pulseStart` is legitimately 0 at the START of a turn (that is
1705
- // where the spinner begins), and `0 || spin` evaluates to `spin`, making
1706
- // `spin - spin === 0` forever — the name then held ONE flat colour and never
1707
- // animated at all. Only `null`/`undefined` mean "no anchor yet".
1708
- const anchor = pulseStart ?? spin;
1709
- const inCycle = ((spin - anchor) % CYCLE + CYCLE) % CYCLE;
1710
- const inHalf = inCycle % HALF; // position within this colour pair's half
1711
- const phase = inCycle < HALF ? 0 : 1; // 0 = l↔c, 1 = c↔b
1712
-
1713
- // Colours as plain RGB triples (mirroring Working's orange→yellow/red pattern).
1714
- // The orange pulse swings HARD — yellow is +100G/+120B off the base and red is
1715
- // -140G — but this sweep used to move only +64R / -35G, i.e. about a QUARTER of
1716
- // that amplitude, so "pale cyan" and "cyan-blue" were both nearly
1717
- // indistinguishable from plain cyan. Match the orange pulse's swing:
1718
- // pale cyan = base + a strong red lift (toward white)
1719
- // cyan-blue = base with the green pulled DOWN but not away: green 90 read as
1720
- // plain blue and clashed with the cyan base, so it is kept at 150
1721
- // — still clearly darker/cooler than the base, still cyan-family
1722
- // so each phase is a DIFFERENT colour, not a shade of the same one.
1723
- const c = [0, 215, 255]; // pure cyan (like ORANGE base)
1724
- const l = [150, 255, 255]; // pale cyan (like YELLOW: base + strong R/G lift)
1725
- const b = [0, 150, 255]; // cyan-blue (like RED: base with green pulled down)
1726
- const base = c; // always cyan, like Working's constant ORANGE
1727
- const target = phase === 0 ? l : b; // pale cyan, then cyan-blue
1728
-
1729
- const chars = name;
1730
- const n = chars.length;
1731
-
1732
- // A character fades over a band this many characters wide. Same sizing rule as
1733
- // the Working row, so the two sweeps have the same feel.
1734
- const BAND = bandCharsFor(n) / n;
1735
-
1736
- // Interpolate base ↔ target by `t` (0 = base, 1 = target).
1737
- const mix = (t) => {
1738
- const k = Math.max(0, Math.min(1, t));
1739
- return lerpColor(
1740
- base[0] + (target[0] - base[0]) * k,
1741
- base[1] + (target[1] - base[1]) * k,
1742
- base[2] + (target[2] - base[2]) * k,
1743
- base[0] + (target[0] - base[0]) * k,
1744
- base[1] + (target[1] - base[1]) * k,
1745
- base[2] + (target[2] - base[2]) * k,
1746
- 0,
1747
- );
1748
- };
1749
-
1750
- const out = [];
1751
- // The band SHAPE is shared with the Working row, and this sweep SETTLES: a
1752
- // character the head has passed stays at its target colour, which is what makes
1753
- // the name read as "filling in" left to right rather than a light passing over.
1754
- // The edge arrives as a PARAMETER, threaded from `state` by the render chain:
1755
- // formatToolLine is module-level and `state` lives in startTUI's closure, so
1756
- // reading `state.shimmerEdge` here threw "state is not defined" on the first
1757
- // pending tool row — the ReferenceError surfaced as an unhandledRejection and
1758
- // the process handler exited the TUI the moment a tool call started.
1759
- const edge = shimmerEdge || 'cosine';
1760
- for (let i = 0; i < n; i++) {
1761
- const charAt = n > 0 ? i / n : 0;
1762
- let t; // 0 = base, 1 = target
1763
- // Same geometry as the Working row's pulse: the head runs from BEHIND the name
1764
- // to past its end within each half, so the first frame of a half is the resting
1765
- // colour and the last frame is fully settled. Starting at `1/SWEEP` (already
1766
- // past the first character) made the name jump on the very first frame.
1767
- // / (SWEEP - 1) so the FIRST frame is progress 0 and the LAST is 1.
1768
- const progress = (inHalf < SWEEP ? inHalf : inHalf - SWEEP) / (SWEEP - 1);
1769
- const head = -BAND + progress * (1 + BAND * SWEEP_TRAVEL_PAD);
1770
- const settled = sweepSettle(head - charAt, BAND, edge);
1771
- t = inHalf < SWEEP ? settled : 1 - settled;
1772
- out.push(col(chars[i], mix(t)));
1773
- }
1774
- nameDisplay = out.join('');
2139
+ // This replaces a two-phase colour sweep (cyan → pale cyan → back, then cyan →
2140
+ // cyan-blue → back) whose band was half the row wide. That band left every character
2141
+ // changed colour behind it, so the name ended up entirely pale or entirely blue — a
2142
+ // wash rather than a highlight, and on a long tool name the moving edge was hard to
2143
+ // see at all because so much of the row was lit at once.
2144
+ //
2145
+ // The light is now a QUARTER of a character wide, which is what makes it read as a
2146
+ // glint travelling along the name rather than a region changing colour.
2147
+ //
2148
+ // Phase is measured from the tick the row began on, so a spinner already running does
2149
+ // not drop the light into the middle of its travel.
2150
+ //
2151
+ // `??`, NOT `||`: `pulseStart` is legitimately 0 at the START of a turn (that is where
2152
+ // the spinner begins), and `0 || spin` evaluates to `spin`, making `spin - spin === 0`
2153
+ // forever — the light would then sit still instead of moving. Only `null`/`undefined`
2154
+ // mean "no anchor yet".
2155
+ // The phase comes from a WALL CLOCK, not from the tick count.
2156
+ //
2157
+ // That is what makes the light smooth. The spinner advances `spin` every 80ms while the render path
2158
+ // redraws far more often, so a phase derived from `spin` steps in 80ms jumps however often it is
2159
+ // redrawn — which is exactly the per-cell hopping this replaced. The tick count still says WHERE the
2160
+ // animation began (a spinner already running must not drop the light into mid-travel); it is turned
2161
+ // once into a wall-clock origin that later frames measure against.
2162
+ //
2163
+ // The origin is held in a module-level WeakMap keyed by nothing but the anchor, rather than on
2164
+ // `state`. This function is module-level and `state` lives inside startTUI's closure: reading it
2165
+ // here threw "state is not defined" the moment a pending tool row painted, surfaced as an
2166
+ // unhandledRejection that exited the whole TUI. The origin is the only per-row animation state, and
2167
+ // it belongs to the module for exactly that reason.
2168
+ //
2169
+ // `??`, NOT `||`: tick 0 is a legitimate anchor, and `0 || spin` would collapse the elapsed time to
2170
+ // zero so the light never moved at all.
2171
+ const anchorSpin = pulseStart ?? spin;
2172
+ // THE KEY IS THE ROW'S OWN IDENTITY, and nothing else.
2173
+ //
2174
+ // It used to carry `anchorSpin` as well, on the reasoning that the same message re-animated on a new
2175
+ // tick should get a new origin. The opposite happened: `anchorSpin` is `pulseStart ?? spin`, and when
2176
+ // the caller passes no `pulseStart` it IS the tick counter — it changes every 80ms, so every frame
2177
+ // built a new key, `lightOrigin` created a fresh origin for it, and `elapsedMs` stayed at 0. The light
2178
+ // sat still, except for the one frame after the row content changed and the message object was
2179
+ // replaced.
2180
+ //
2181
+ // A row animates from the moment it appears until it stops being pending, and its id is stable across
2182
+ // that whole life: one origin, and elapsed grows from there.
2183
+ const rowKey = msg && (msg.id || msg.toolId) ? `tool:${msg.id || msg.toolId}` : `tool:${name}`;
2184
+ const elapsedMs = Math.max(0, Date.now() - lightOrigin(rowKey, anchorSpin, spin));
2185
+ // ONE shimmer for both rows — see `shimmerText`.
2186
+ const phaseMs = elapsedMs % cycleMs(name.length, TOOL_STEP_MS);
2187
+
2188
+ // The same restraint as the Working row's glint: the light is the base colour with a small lift in
2189
+ // the SAME hue, not a jump to white. `[150, 255, 255]` against `[0, 215, 255]` read as "the name is
2190
+ // now pale" — a state change — where this reads as a sheen passing over it.
2191
+ const BASE = [0, 215, 255]; // cyan: the name's resting colour
2192
+ const GLINT = [110, 240, 255]; // the same hue, a shade brighter
2193
+
2194
+ // The same setting the Working row honours: a pending tool's name is decoration too.
2195
+ if (reducedMotion) {
2196
+ nameDisplay = col(name, C.cyan);
2197
+ } else {
2198
+ nameDisplay = shimmerText(name, phaseMs, BASE, GLINT, shimmerEdge || 'cosine', TOOL_STEP_MS);
2199
+ }
1775
2200
  } else {
1776
2201
  // Non-pending: show in plain cyan
1777
2202
  nameDisplay = col(name, C.cyan);
@@ -1795,10 +2220,23 @@ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge) {
1795
2220
  diffStr = ' ' + (adds ? col(`+${adds}`, C.green) : '') + ' ' + (deletes ? col(`-${deletes}`, C.red) : '');
1796
2221
  }
1797
2222
  }
1798
- // The `●` bullet carries the state (orange while running, green when done),
1799
- // so the verb itself stays plain white and the tool name keeps the theme
1800
- // colour.
1801
- const argText = arg ? ' ' + col(`(${arg})`, C.gray) : '';
2223
+ // COLORED PER LINE, not around the whole string.
2224
+ //
2225
+ // `col(text)` writes one escape pair around everything, so a multi-line argument — exactly what Ctrl+O
2226
+ // reveals, the body of a script — had its colour open on line 1 and, after the newline, no colour at all:
2227
+ // the terminal's reset made every line below the first render in the DEFAULT foreground, one shade
2228
+ // brighter than the grey the parentheses were given. Splitting on the newline and colouring each piece
2229
+ // keeps the whole block the same grey. The parentheses still wrap the WHOLE value, so a multi-line script
2230
+ // reads as one bracketed argument rather than as several.
2231
+ let argText = '';
2232
+ if (arg) {
2233
+ const lines = `${arg}`.split('\n');
2234
+ const painted = lines.map((l, i) => col(
2235
+ (i === 0 ? '(' : '') + l + (i === lines.length - 1 ? ')' : ''),
2236
+ C.gray,
2237
+ ));
2238
+ argText = ' ' + painted.join('\n');
2239
+ }
1802
2240
  return col(verb, C.white) + ' ' + nameDisplay + argText + col(sizeStr, C.gray) + diffStr;
1803
2241
  }
1804
2242
 
@@ -1851,6 +2289,10 @@ function todoRow(todo, w) {
1851
2289
  function renderTodoPanel(state, w, hoverTop, dragTop) {
1852
2290
  const todos = state.todos || [];
1853
2291
  if (!todos.length) return [];
2292
+ // A finished list draws NOTHING, matching `todoPanelHeight` returning 0 for the same state. Both ask
2293
+ // the same question, so the reserved height and the drawn rows cannot disagree — a mismatch would
2294
+ // shift every row below the panel.
2295
+ if (todoListFinished(state)) return [];
1854
2296
  const out = [];
1855
2297
  const ruleColor = dragTop ? C.scrollThumbActive : (hoverTop ? C.hover : C.border);
1856
2298
  out.push(col('─'.repeat(w), ruleColor));
@@ -1947,9 +2389,31 @@ export function queuePanelHeight(state) {
1947
2389
  // * `state.todoRows` (set by dragging the top rule) wins when present;
1948
2390
  // * otherwise the automatic heuristic (selectVisibleTodos) decides.
1949
2391
  // Always clamped to [1, todos.length]: at least ONE row, never more than exist.
2392
+ /**
2393
+ * Is this list FINISHED — every item done, and at least one item?
2394
+ *
2395
+ * A finished list is DROPPED, not merely hidden: it has stopped being a list of what is left to do, so
2396
+ * the data goes as well as the panel. Hiding alone left the array to be persisted with the session and
2397
+ * handed back to the model as `cfg.todoState`, which is a finished plan being described as current work.
2398
+ *
2399
+ * `every` on an EMPTY array is true, so the length check is load-bearing: this must not fire for "no
2400
+ * todos", which is a different state with its own handling everywhere else.
2401
+ */
2402
+ export function todosAllComplete(list) {
2403
+ const todos = Array.isArray(list) ? list : [];
2404
+ return todos.length > 0 && todos.every((t) => t && t.status === 'done');
2405
+ }
2406
+
2407
+ /** The same question, asked of a TUI state object rather than a bare array. */
2408
+ export function todoListFinished(state) {
2409
+ return todosAllComplete(state && state.todos);
2410
+ }
2411
+
1950
2412
  export function todoRowCount(state) {
1951
2413
  const todos = state.todos || [];
1952
2414
  if (!todos.length) return 0;
2415
+ // A finished list has no rows: see `todoListFinished`.
2416
+ if (todoListFinished(state)) return 0;
1953
2417
  const manual = state.todoRows;
1954
2418
  let rows;
1955
2419
  if (typeof manual === 'number' && Number.isFinite(manual)) {
@@ -1964,6 +2428,10 @@ export function todoRowCount(state) {
1964
2428
  export function todoPanelHeight(state) {
1965
2429
  const todos = state.todos || [];
1966
2430
  if (!todos.length) return 0;
2431
+ // Zero once everything is done, so the chrome shrinks by the panel's whole height rather than leaving
2432
+ // a blank gap where it used to be. Both this and the renderer ask the same predicate, so the reserved
2433
+ // height and the drawn rows cannot disagree — a mismatch there shifts every row below.
2434
+ if (todoListFinished(state)) return 0;
1967
2435
  const rows = todoRowCount(state);
1968
2436
  const extra = todos.length > rows ? 1 : 0; // the "… +N more" hint
1969
2437
  return 2 + rows + extra; // rule + heading + rows (+ hint)
@@ -1976,7 +2444,7 @@ export function todoPanelHeight(state) {
1976
2444
  // wrapping, no ANSI interpretation. It exists to answer "what did the model
1977
2445
  // actually send" when a rendering bug makes a reply look wrong — with the renderer
1978
2446
  // in the way there is no way to tell a bad reply from a bad render.
1979
- function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode, shimmerEdge) {
2447
+ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode, shimmerEdge, reducedMotion = false) {
1980
2448
  if (rawMode && (msg.role === 'assistant' || msg.role === 'thinking')) {
1981
2449
  const src = String(msg.text == null ? '' : msg.text);
1982
2450
  // escape the ANSI bytes so a stray `\x1b[31m` in the model's output cannot
@@ -1989,20 +2457,33 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
1989
2457
  // Tool calls render as a single "● Using/Used Name (arg)" line (kimi style).
1990
2458
  // The body already carries its own ANSI colours, so mark it pre-colored.
1991
2459
  if (msg.role === 'tool') {
1992
- const body = formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge);
2460
+ const body = formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded, reducedMotion);
1993
2461
  // Red bullet for a failed call (a Bash non-zero exit / [error: …]).
1994
2462
  const failed = msg.failed === true || (msg.role === 'tool' && msg.failed === true);
1995
2463
  const pre = col('● ', msg.pending ? C.orange : (failed ? C.red : C.green));
1996
2464
  const bodyW = Math.max(1, width - visualCol('● '));
1997
- const wrapped = wrapAnsiWords(body, bodyW);
1998
- // Mark every CONTINUATION boundary with a trailing `…` so a wrapped command
1999
- // never looks like it ended at a quote or `;`. Without this, a command like
2000
- // `$ProgressPreference='SilentlyContinue'; Invoke-WebRequest …` broke right
2001
- // after the closing quote, and the first row read as the whole command.
2002
- // The last row keeps whatever `keyArgument` already put there (its own `…`
2003
- // when the text itself was truncated).
2465
+ // EXPANDED: break the body at the newlines the command actually has, so a multi-line script renders
2466
+ // as the lines it was written as. `wrapAnsiWords` alone flattens a newline into a space, turning
2467
+ // `set -e\nnpm ci\nnpm test` into one long reflowed line — and the reason to expand is to read the
2468
+ // script, not a reflow of it.
2469
+ //
2470
+ // Collapsed, there are no newlines to honour: `keyArgument` already reduced the value to one line
2471
+ // plus a count, so this is a single chunk and the wrap behaves exactly as before.
2472
+ const segments = expanded && body.includes('\n') ? body.split('\n') : [body];
2473
+ // Mark every CONTINUATION boundary with a trailing `…` so a wrapped command never looks like it
2474
+ // ended at a quote or `;`. Without this, a command like
2475
+ // `$ProgressPreference='SilentlyContinue'; Invoke-WebRequest …` broke right after the closing
2476
+ // quote, and the first row read as the whole command. A boundary that is the command's OWN newline
2477
+ // is not a continuation and gets no mark — the break is already unmistakable.
2478
+ const wrapped = [];
2479
+ for (const segment of segments) {
2480
+ const parts = wrapAnsiWords(segment, bodyW);
2481
+ for (let i = 0; i < parts.length; i++) {
2482
+ wrapped.push(i < parts.length - 1 ? parts[i] + col('…', C.gray) : parts[i]);
2483
+ }
2484
+ }
2004
2485
  const out = wrapped.map((t, i) => ({
2005
- text: i < wrapped.length - 1 ? t + col('…', C.gray) : t,
2486
+ text: t,
2006
2487
  ind: i === 0 ? pre : ' ',
2007
2488
  raw: true, // text already contains ANSI; do not wrap in another colour
2008
2489
  }));
@@ -2073,16 +2554,24 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
2073
2554
  // one the trailing newline produced (an intentionally blank last line survives
2074
2555
  // as long as the content does not end with a newline).
2075
2556
  if (lines.length > 1 && lines[lines.length - 1] === '' && /\n$/.test(head)) lines.pop();
2076
- const MAX = 20;
2557
+ // CTRL+O EXPANDS IT, like every other body in the transcript. This block was
2558
+ // the one place that hard-coded a preview and never consulted `expanded`, so
2559
+ // a 500-line file could only ever show its first 20 lines — the summary said
2560
+ // "… 480 more lines" and there was no key that would reveal them.
2561
+ //
2562
+ // The expanded cap is the number of lines actually read, not `Infinity`: the
2563
+ // 64 KB prefix above is what exists, and claiming more would print nothing
2564
+ // extra while looking like it should.
2565
+ const MAX = expanded ? lines.length : 20;
2077
2566
  // The TRUE line count comes from the message: `lines` only covers the scanned
2078
2567
  // prefix, so counting it would understate a large file's size.
2079
- const totalLines = msg._streamLineCount || lines.length;
2568
+ const totalLines = expanded ? lines.length : (msg._streamLineCount || lines.length);
2080
2569
  const wNum = String(totalLines).length;
2081
2570
  lines.slice(0, MAX).forEach((ln, i) => {
2082
2571
  out.push({ text: col(`${String(i + 1).padStart(wNum)} ${ln}`, C.gray), ind: i === 0 ? '↳ ' : indent, raw: true });
2083
2572
  });
2084
2573
  const shown = Math.min(lines.length, MAX);
2085
- if (totalLines > shown) out.push({ text: col(`… ${totalLines - shown} more lines`, C.gray), ind: indent, raw: true });
2574
+ if (totalLines > shown) out.push({ text: col(`… ${totalLines - shown} more lines, ctrl+o to expand`, C.gray), ind: indent, raw: true });
2086
2575
  }
2087
2576
 
2088
2577
  // The Edit diff is NOT drawn here: it rides on the tool_result message and is
@@ -2150,13 +2639,16 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
2150
2639
  const indent = ' ';
2151
2640
  const avail = Math.max(1, width - visualCol(indent));
2152
2641
  const lines = body.replace(/\r\n/g, '\n').split('\n');
2153
- const MAX = expanded ? 200 : 6;
2642
+ // No cap once expanded. 200 was a silent ceiling: a long result stopped there with a hint telling
2643
+ // the user to press the key they had already pressed.
2644
+ const MAX = expanded ? lines.length : 6;
2154
2645
  for (const ln of lines.slice(0, MAX)) {
2155
2646
  for (const w of (ln === '' ? [''] : wrapWords(ln, avail))) {
2156
2647
  out.push({ text: col(w, C.gray), ind: indent, raw: true });
2157
2648
  }
2158
2649
  }
2159
2650
  if (lines.length > MAX) {
2651
+ // Only reachable while COLLAPSED: expanded, `MAX` is the whole list and this cannot fire.
2160
2652
  out.push({ text: col(`… ${lines.length - MAX} more lines, ctrl+o to expand`, C.gray), ind: indent, raw: true });
2161
2653
  }
2162
2654
  }
@@ -2260,7 +2752,15 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
2260
2752
  });
2261
2753
  });
2262
2754
  if (visible.length > MAX_RESULT_LINES) {
2263
- diffRows.push({ text: `… ${visible.length - MAX_RESULT_LINES} more lines`, ind: cont, color: failed ? C.red : C.gray });
2755
+ // The hint says HOW to see the rest. It used to read only "… N more lines", so a truncated
2756
+ // result gave the user no way to know Ctrl+O would reveal it — the key existed and was
2757
+ // undiscoverable from the one place that needed it. Only reachable while collapsed: expanded,
2758
+ // `MAX_RESULT_LINES` is Infinity and this cannot fire.
2759
+ diffRows.push({
2760
+ text: `… ${visible.length - MAX_RESULT_LINES} more lines, ctrl+o to expand`,
2761
+ ind: cont,
2762
+ color: failed ? C.red : C.gray,
2763
+ });
2264
2764
  }
2265
2765
  }
2266
2766
 
@@ -2447,6 +2947,24 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
2447
2947
  }
2448
2948
  return lines;
2449
2949
  }
2950
+ // A MESSAGE FROM A TEAMMATE.
2951
+ //
2952
+ // Drawn like a user row but labelled with the sender, because these arrive OUTSIDE the turn loop —
2953
+ // a member writes whenever it has something to say, and the person must be able to read it without
2954
+ // the model choosing to repeat it. `from` is the member's roster name.
2955
+ //
2956
+ // The body is wrapped to the same inner width the user boxes use, so a long answer does not run past
2957
+ // the right edge and take the row's colour with it.
2958
+ if (msg.role === 'team') {
2959
+ const who = String(msg.from || 'team');
2960
+ lines.push({ text: prefix + C.bold + col(who, C.cyan) + col(' → you', C.gray), ind: '', raw: true });
2961
+ const w = Math.max(1, width - 2);
2962
+ for (const ln of String(msg.text || '').replace(/\r\n/g, '\n').split('\n')) {
2963
+ if (ln === '') { lines.push({ text: '', ind: ' ', raw: true }); continue; }
2964
+ for (const seg of wrapWords(ln, w)) lines.push({ text: seg, ind: ' ', color: fg });
2965
+ }
2966
+ return lines;
2967
+ }
2450
2968
  // A pasted IMAGE. The row NAMES the file: that is the receipt for what the model received,
2451
2969
  // and it is the only thing shown, because the terminal is not asked to draw a picture.
2452
2970
  if (msg.role === 'image') {
@@ -2702,7 +3220,7 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
2702
3220
  const out = [];
2703
3221
  const t = line.replace(/\s+$/, '');
2704
3222
 
2705
- // Headings: `# 标题`
3223
+ // Headings: `# Title`
2706
3224
  let m = /^(\s*)(#{1,6})\s+(.*)$/.exec(t);
2707
3225
  if (m && !isContinuation) {
2708
3226
  const hashCount = m[2].length;
@@ -3138,12 +3656,12 @@ export function renderChatLines(state, w, viewport) {
3138
3656
  const boxText = Math.max(1, boxInner - Math.min(1, Math.max(0, boxInner - 1)) * 2);
3139
3657
  const rowWidth = border ? boxText : innerW;
3140
3658
  const cached = m._cache;
3141
- // Which rows animate? A pending tool call and a running compaction block both
3142
- // derive their look from `state.spin`, so the cached layout must be invalidated
3143
- // when that tick advances. Setting spinKey to 0 for everything else keeps
3144
- // static rows cached across ticks.
3145
- const animating = (m.role === 'tool' && m.pending) || (m.role === 'compaction' && m.phase === 'running');
3146
- const spinKeyNow = animating ? (state.spin || 0) : 0;
3659
+ // Which rows animate? A pending tool call and a running compaction block do, and their appearance
3660
+ // depends on the current step of the light. Everything else gets a constant key, so a static row is
3661
+ // laid out once and reused across frames.
3662
+ // A pending tool advances from the wall clock at TOOL_STEP_MS, so its cache key is THAT step index.
3663
+ const animating = (m.role === 'tool' && m.pending) || (m.role === 'compaction' && m.phase === 'running');
3664
+ const spinKeyNow = animating ? Math.floor(Date.now() / TOOL_STEP_MS) : 0;
3147
3665
  const valid = cached
3148
3666
  && cached.rowW === rowWidth && cached.expanded === expanded
3149
3667
  && cached.textRef === m.text && cached.streamRef === m.streamContent
@@ -3197,7 +3715,8 @@ export function renderChatLines(state, w, viewport) {
3197
3715
  // happen (replacing a word). Those are caught by the `_cache` nulling at the
3198
3716
  // edit sites, which is what the shared-store path below relies on too.
3199
3717
  const countLen = typeof m.text === 'string' ? m.text.length : 0;
3200
- if (!valid && !cached && m._count && m._count.key === countKey && m._count.textLen === countLen) {
3718
+ // Only a NON-ANIMATING message can be answered from its row count. See the note below.
3719
+ if (!valid && !cached && !animating && m._count && m._count.key === countKey && m._count.textLen === countLen) {
3201
3720
  msgLen[mi] = m._count.len + rules;
3202
3721
  cursor += msgLen[mi];
3203
3722
  prevBordered = border;
@@ -3227,7 +3746,8 @@ export function renderChatLines(state, w, viewport) {
3227
3746
  msgLen[mi] = shared.rows.length + rules;
3228
3747
  } else {
3229
3748
  const rows = messageLines(m, rowWidth, state.cwd, expanded, state.spin, state.pulseStart, state.rawMode,
3230
- state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge));
3749
+ state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge),
3750
+ reducedMotionOn(state));
3231
3751
  const wrapped = rows.map(rowToLine);
3232
3752
  const isPending2 = m.pending ? 1 : 0;
3233
3753
  const cc = {
@@ -3377,8 +3897,9 @@ export function renderChatLines(state, w, viewport) {
3377
3897
  let ccc = msg._cache;
3378
3898
  if (!ccc || !ccc.rows || ccc.rows.length !== len - (bordered ? (shared ? 1 : 2) : 0)) {
3379
3899
  const bw = bordered ? boxText : innerW;
3380
- const built = messageLines(msg, bw, state.cwd, expanded, state.spin, state.pulseStart, state.rawMode,
3381
- state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge)).map(rowToLine);
3900
+ const src = messageLines(msg, bw, state.cwd, expanded, state.spin, state.pulseStart, state.rawMode,
3901
+ state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge));
3902
+ const built = src.map(rowToLine);
3382
3903
  ccc = { rows: built, spinRows: null };
3383
3904
  for (let i = 0; i < built.length; i++) {
3384
3905
  if (built[i].indexOf(SPIN_PLACEHOLDER) >= 0) {
@@ -3394,8 +3915,9 @@ export function renderChatLines(state, w, viewport) {
3394
3915
  if (bordered) {
3395
3916
  const pad = ' '.repeat(boxPad);
3396
3917
  const wall = col('│', boxColor);
3397
- for (let i = 0; i < ccc.rows.length; i++)
3918
+ for (let i = 0; i < ccc.rows.length; i++) {
3398
3919
  out.push(PAD + wall + pad + fitAnsi(liveAt(ccc.rows[i], i), boxText) + pad + wall);
3920
+ }
3399
3921
  } else {
3400
3922
  for (let i = 0; i < ccc.rows.length; i++) out.push(PAD + liveAt(ccc.rows[i], i));
3401
3923
  }
@@ -3405,6 +3927,10 @@ export function renderChatLines(state, w, viewport) {
3405
3927
  }
3406
3928
  cursor += len;
3407
3929
  }
3930
+ // NO SELECTION HERE. `state.selection` is in SCREEN rows and this function does not know where its
3931
+ // rows will land — the chat body starts below whatever a plan table or prompt box has already drawn.
3932
+ // `composeFrame` paints the selection instead, at the point where both the body's origin and the
3933
+ // selection's rows are known. See `applySelection`.
3408
3934
  return out;
3409
3935
  }
3410
3936
 
@@ -3414,7 +3940,12 @@ export function renderChatLines(state, w, viewport) {
3414
3940
  export function metricsSignature(state, n, w, expanded, innerW) {
3415
3941
  let h = 2166136261 >>> 0;
3416
3942
  const mix = (v) => { h ^= (v | 0); h = Math.imul(h, 16777619) >>> 0; };
3417
- mix(n); mix(w); mix(innerW); mix(expanded ? 1 : 0); mix(state.spin || 0);
3943
+ // NOT `state.spin`. The spin changes four times a second and only ever picks which glyph an animation
3944
+ // shows; it never changes the row count. Including it invalidated the memo on every tick, so each wheel
3945
+ // event walked and re-rendered the whole transcript just to read back the total it already had — the
3946
+ // stall when scrolling a long session. A spinner frame is a per-row concern, handled by the rows' own
3947
+ // `valid` checks, which still run.
3948
+ mix(n); mix(w); mix(innerW); mix(expanded ? 1 : 0);
3418
3949
  // THE PALETTE. This signature decides whether the WHOLE transcript is walked at all, so a
3419
3950
  // theme switch that left it alone skipped the per-message `valid` check too — every cached
3420
3951
  // row kept the colours it was rendered with, which is why the chrome repainted and the
@@ -3438,10 +3969,15 @@ export function metricsSignature(state, n, w, expanded, innerW) {
3438
3969
  mix(m.pluginLog && m.expanded ? 1 : 0);
3439
3970
  mix(m._cache ? 1 : 0);
3440
3971
  mix(m._count ? 1 : 0);
3972
+ // A pending row repaints as the light moves, with none of the fields above changing. Without this
3973
+ // the signature matched every frame and the transcript was never re-walked, so the animation froze
3974
+ // until the message itself was touched.
3975
+ if (m.pending) mix(Math.floor(Date.now() / STEP_MS));
3441
3976
  }
3442
3977
  return h;
3443
3978
  }
3444
3979
 
3980
+
3445
3981
  /**
3446
3982
  * Total transcript rows WITHOUT building a single rendered string. Reuses the
3447
3983
  * memo from the last renderChatLines when the transcript and layout are unchanged
@@ -3759,10 +4295,41 @@ function proseSummary(value) {
3759
4295
  return `${lines.length} line${lines.length === 1 ? '' : 's'}, ${text.length} chars`;
3760
4296
  }
3761
4297
 
4298
+ /**
4299
+ * The `[2s | ↓40]` tail shown at the end of the Working row.
4300
+ *
4301
+ * The arrow says which direction the count is: tokens this turn has RECEIVED, as opposed to the context
4302
+ * gauge, which also counts what has been sent.
4303
+ */
3762
4304
  function elapsedTail(ms, tokens) {
3763
- const time = fmtDuration(ms);
3764
- const tok = fmtTokensShort(tokens);
3765
- return `${time} | ${tok}`;
4305
+ // The token count is shown FROM THE FIRST FRAME, with a downward arrow to say what it is: what the
4306
+ // turn has received. It used to be hidden for thirty seconds, on the theory that a small number is
4307
+ // noise on a short turn — but the row is the only place that number exists, and a count that appears
4308
+ // partway through reads as something that just started rather than as a running total.
4309
+ return `${fmtDuration(ms)} | ↓ ${fmtTokensShort(tokens)}`;
4310
+ }
4311
+
4312
+ /**
4313
+ * Has the turn gone quiet, and how much should that show?
4314
+ *
4315
+ * A turn that produces no tokens for a few seconds is either thinking, waiting on a tool, or stuck — and
4316
+ * from outside those look identical. The one case that matters is the last: a request that hangs leaves
4317
+ * the row cheerfully spinning forever with no way to tell it apart from a long answer.
4318
+ *
4319
+ * Mirrors Claude Code's `useStalledAnimation`: AFTER 3s with no new output, fade toward the warning colour
4320
+ * over another 2s, and NEVER while a tool is running — a `npm install` that takes a minute is working, not
4321
+ * stalled, and flashing red at it would train the user to ignore the signal.
4322
+ *
4323
+ * Returns 0 while healthy and 1 at full stall, so the caller can interpolate rather than switch abruptly.
4324
+ */
4325
+ const STALL_AFTER_MS = 3000;
4326
+ const STALL_FADE_MS = 2000;
4327
+
4328
+ function stallIntensity({ sinceLastTokenMs, hasActiveTools }) {
4329
+ if (hasActiveTools) return 0;
4330
+ const quiet = Number(sinceLastTokenMs) || 0;
4331
+ if (quiet <= STALL_AFTER_MS) return 0;
4332
+ return Math.min((quiet - STALL_AFTER_MS) / STALL_FADE_MS, 1);
3766
4333
  }
3767
4334
 
3768
4335
  // Left/right justify: left text, then padding, then right text flush to `w`.
@@ -3970,16 +4537,30 @@ function applySelectionToRow(lines, idx, sel, w) {
3970
4537
  return lines;
3971
4538
  }
3972
4539
 
3973
- // Wrap the visible columns [c0, c1) of an ANSI string in the selection
3974
- // background, preserving the existing foreground colours inside the range.
3975
- // NOTE: pass (end.col + 1) as c1 — the end column is the cell under the pointer
3976
- // and is part of the selection.
4540
+ /**
4541
+ * Paint the selection background over visible columns [c0, c1) of one rendered row.
4542
+ *
4543
+ * COLUMN 0 IS NEVER SELECTION. Every chat row is drawn with one column of left margin — the `PAD` the
4544
+ * renderer prepends — so a drag that begins at the left edge of the text paints that space too, and
4545
+ * copying a reply puts a leading space on the clipboard on every line. Skipping exactly column 0 is the
4546
+ * whole rule.
4547
+ *
4548
+ * It is one column and no more, and applied to every row regardless of what sits there. Measuring a
4549
+ * per-row "indent width" instead — a tool row's ` ● ` bullet, a user box's `│ ❯ ` wall — was the first
4550
+ * attempt and it was wrong: those columns are part of what the row DRAWS, and a selection that begins on
4551
+ * one of them is a selection the user made deliberately. The margin is different only because it is not
4552
+ * the content at all.
4553
+ *
4554
+ * The column is still drawn and still occupies its space; it is simply not selection.
4555
+ */
3977
4556
  function highlightAnsiRange(row, c0, c1, w) {
3978
4557
  const s = String(row);
3979
4558
  let out = '';
3980
4559
  let col = 0;
3981
4560
  let i = 0;
3982
4561
  let inSel = false;
4562
+ // A range that begins at the margin starts one column later.
4563
+ const lo = Math.max(c0, 1);
3983
4564
 
3984
4565
  while (i < s.length) {
3985
4566
  if (s[i] === '\x1b') {
@@ -4000,7 +4581,7 @@ function highlightAnsiRange(row, c0, c1, w) {
4000
4581
  const cp = s.codePointAt(i);
4001
4582
  const ch = String.fromCodePoint(cp);
4002
4583
  const cw = visualWidth(ch);
4003
- const want = col >= c0 && col < c1;
4584
+ const want = col >= lo && col < c1;
4004
4585
 
4005
4586
  if (want && !inSel) {
4006
4587
  // Selection changes only the BACKGROUND; the foreground (documented colour)
@@ -4027,6 +4608,44 @@ function highlightAnsiRange(row, c0, c1, w) {
4027
4608
  return out;
4028
4609
  }
4029
4610
 
4611
+ /**
4612
+ * Paint the mouse selection over a finished set of chat rows.
4613
+ *
4614
+ * `sel` is the selection ITSELF — `{ anchor: {row, col}, head: {row, col} }` — not `state`. It used to
4615
+ * take `state` and read `state.selection` out of it while the only caller passed the selection
4616
+ * directly, so `sel` was undefined and the function returned at its first line: the highlight silently
4617
+ * never appeared. Taking the range as an argument also means the caller converts screen rows to body
4618
+ * rows once, where both coordinate systems are known.
4619
+ *
4620
+ * Rows are indices into `rows`; a cross-row drag extends the first and last rows to the whole line,
4621
+ * which is what a text selection in a terminal looks like.
4622
+ */
4623
+ function applySelection(rows, sel) {
4624
+ if (!sel || !sel.anchor || !sel.head) return rows;
4625
+ const a = sel.anchor;
4626
+ const h = sel.head;
4627
+ if (!Number.isFinite(a.row) || !Number.isFinite(h.row)) return rows;
4628
+
4629
+ // Whichever end is earlier on screen, so dragging upwards works like dragging downwards.
4630
+ const start = (h.row < a.row || (h.row === a.row && h.col < a.col)) ? h : a;
4631
+ const end = start === a ? h : a;
4632
+
4633
+ for (let i = 0; i < rows.length; i++) {
4634
+ if (i < start.row || i > end.row) continue;
4635
+ let c0;
4636
+ let c1;
4637
+ // +1: the end column is the cell under the pointer and is selected too.
4638
+ if (start.row === end.row) { c0 = start.col; c1 = end.col + 1; }
4639
+ else if (i === start.row) { c0 = start.col; c1 = Infinity; }
4640
+ else if (i === end.row) { c0 = 0; c1 = end.col + 1; }
4641
+ else { c0 = 0; c1 = Infinity; }
4642
+ // The width bound is the row's own length: `highlightAnsiRange` stops there anyway, and passing a
4643
+ // number derived from the terminal would be wrong for a row rendered at a different width.
4644
+ rows[i] = highlightAnsiRange(rows[i], c0, c1, Infinity);
4645
+ }
4646
+ return rows;
4647
+ }
4648
+
4030
4649
  // Truncate an ANSI string to exactly `width` visible columns (no padding).
4031
4650
  function sliceAnsi(s, width) {
4032
4651
  const str = String(s);
@@ -4335,9 +4954,14 @@ export function refreshGitInfo(state, opts = {}) {
4335
4954
  if (!cwd) return;
4336
4955
  const now = Date.now();
4337
4956
  const force = opts.force === true;
4338
- // Mid-turn the agent is about to write more files, so a poll would only produce
4339
- // a value that is stale on arrival. The turn-end refresh covers it. Explicit
4340
- // callers (the timer between turns, /git) still get through with force.
4957
+ // Mid-turn the agent is about to write more files, so a POLL would only produce a value that is
4958
+ // stale on arrival. The turn-end refresh covers it. Explicit callers (the timer between turns,
4959
+ // /git) still get through with force.
4960
+ //
4961
+ // A step boundary is the exception, and it is deliberately the only one: the tool_result handler
4962
+ // calls this with `force` once a step has finished, because at that moment the agent has stopped
4963
+ // writing and the count is both current and worth looking at (see the call site in the event
4964
+ // handler). Without it a long turn showed an unchanging `+0 -0` while files were being edited.
4341
4965
  if (!force && state.running) return;
4342
4966
  state._gitCwd = cwd;
4343
4967
  state._gitAt = now;
@@ -4941,30 +5565,17 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
4941
5565
  const chat = state._forceFullRender
4942
5566
  ? renderChatLines(state, w)
4943
5567
  : renderChatLines(state, w, { bodyH, scroll: state.scroll || 0 });
4944
- if (state.selection && state.selection.anchor && state.selection.head) {
4945
- // Apply selection highlight directly on ANSI strings
4946
- for (let i = 0; i < chat.length; i++) {
4947
- const a = state.selection.anchor, h = state.selection.head;
4948
- if (!Number.isFinite(a.row) || !Number.isFinite(h.row)) { /* no range */ }
4949
- else {
4950
- const startIdx = (h.row < a.row || (h.row === a.row && h.col < a.col)) ? h : a;
4951
- const endIdx = startIdx === a ? h : a;
4952
- if (i < startIdx.row || i > endIdx.row) continue;
4953
- let c0, c1;
4954
- // +1: the end column is the cell under the pointer and is selected too.
4955
- if (startIdx.row === endIdx.row) { c0 = startIdx.col; c1 = endIdx.col + 1; }
4956
- else if (i === startIdx.row) { c0 = startIdx.col; c1 = Infinity; }
4957
- else if (i === endIdx.row) { c0 = 0; c1 = endIdx.col + 1; }
4958
- else { c0 = 0; c1 = Infinity; }
4959
- chat[i] = highlightAnsiRange(chat[i], c0, c1, cols);
4960
- }
4961
- }
4962
- }
4963
- if (process.env.HNCODE_DEBUG && state.selection) {
4964
- const _a = state.selection.anchor, _h = state.selection.head;
4965
- const _s = (_h.row < _a.row || (_h.row === _a.row && _h.col < _a.col)) ? _h : _a;
4966
- const _e = _s === _a ? _h : _a;
4967
- }
5568
+ // THE SELECTION IS ALREADY PAINTED, inside `renderChatLines`.
5569
+ //
5570
+ // There used to be a second pass here that re-walked the rows and highlighted them again. It is
5571
+ // gone, for two reasons. It re-painted rows `renderChatLines` had just painted, and its `cols`
5572
+ // argument named a variable that does not exist in this scope — a `ReferenceError` waiting for the
5573
+ // first user who dragged across a row. The body path never reached it because `chat` was empty in
5574
+ // the tests that exercised the window, and the crash would have surfaced as the whole TUI exiting
5575
+ // through the unhandledRejection handler.
5576
+ //
5577
+ // One place owns the selection now, which is also why `renderChatLines` takes no selection argument:
5578
+ // it reads `state.selection` itself.
4968
5579
  const total = chat.length;
4969
5580
  const maxScroll = Math.max(0, total - bodyH);
4970
5581
  const scroll = Math.min(maxScroll, Math.max(0, state.scroll || 0));
@@ -4977,7 +5588,16 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
4977
5588
  // body, so `lines.length` here IS the body's origin; deriving it afterwards as
4978
5589
  // `lines.length - bodyH` assumes nothing came before it, which is false.
4979
5590
  const bodyScreenTop = lines.length;
4980
- chatOrigin = bodyScreenTop;
5591
+ // The selection, in SCREEN rows. `state.selection` comes from mouse coordinates, which are screen
5592
+ // `state.selection` rows are ALREADY transcript indices — `mouseToCell` converts the pointer's
5593
+ // screen row into a body row before recording the anchor and head, subtracting the body's origin and
5594
+ // adding the transcript's start.
5595
+ //
5596
+ // Converting again here is what broke the highlight completely: the row went negative, no row matched,
5597
+ // and nothing was painted. The selection goes to `applySelection` unchanged.
5598
+ if (state.selection && state.selection.anchor && state.selection.head) {
5599
+ applySelection(chat, state.selection);
5600
+ }
4981
5601
  for (let i = 0; i < bodyH; i++) {
4982
5602
  const idx = start + i;
4983
5603
  let row = '';
@@ -5285,11 +5905,11 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5285
5905
  //
5286
5906
  // Layout:
5287
5907
  // |Q1| Q2 <- question tabs (Tab switches), current lit
5288
- // 用哪种权限模式? <- the question text
5289
- // > A 读取自动执行… <- option: marker + label + dim desc on the same row
5290
- // B 什么都不问
5908
+ // Which permission mode? <- the question text
5909
+ // > A Run reads automatically <- option: marker + label + dim desc on the same row
5910
+ // B Ask about everything
5291
5911
  // Other
5292
- // ↑↓ 移动 · Enter 选择 · Esc 取消
5912
+ // up/down move · Enter select · Esc cancel
5293
5913
  // Single-select marker is `> `; multi-select uses an empty/filled square.
5294
5914
  if (state.question && !takeover) {
5295
5915
  const qs = state.question;
@@ -5417,7 +6037,14 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5417
6037
  : '';
5418
6038
  // The wording was chosen once at turn start; the gradient below loops
5419
6039
  // independently, so the phrase stays put while the colours sweep.
5420
- const workMsg = state.workMsg || WORKING_MESSAGES[0];
6040
+ //
6041
+ // A RUNNING COMPACTION OWNS THE PHRASE. The turn is not "working", it is compacting —
6042
+ // one specific long operation — and saying so is the whole point of putting it here.
6043
+ // `state.workMsg` is left untouched underneath, so the rotating phrase the turn began
6044
+ // with comes back the moment compaction ends rather than being lost.
6045
+ const workMsg = state.compacting
6046
+ ? COMPACTING_MSG
6047
+ : (state.workMsg || WORKING_MESSAGES[0]);
5421
6048
  const spin = state.spin || 0;
5422
6049
 
5423
6050
  // ---- reduced motion: the resting row ----
@@ -5433,14 +6060,18 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5433
6060
  );
5434
6061
  }
5435
6062
  // ---- turn-start animation ----
5436
- // 0.5s: type out "Working..." in grey (chars appear left→right)
5437
- // 0.5s: fill with orange (chars change colour left→right)
5438
- // After 1s total: start the normal pulse animation (yellow/red cycle)
6063
+ // 0.25s: type out "Working..." in grey (chars appear left→right)
6064
+ // 0.25s: fill with orange (chars change colour left→right)
6065
+ // After 0.5s total: start the normal pulse animation
5439
6066
  else if (state.startAnim) {
5440
- // 1s total: 0.5s types `Working... [0s]` out in grey, then 0.5s sweeps the
6067
+ // 0.5s total: 0.25s types `Working... [0s]` out in grey, then 0.25s sweeps the
5441
6068
  // PHRASE grey → orange. The `[0s]` tail types out WITH the phrase but is
5442
6069
  // EXCLUDED from the colour sweep — it stays grey the whole time.
5443
- const DUR = 1000;
6070
+ //
6071
+ // Halved from 0.5s each. A full second of the row still typing itself out delayed the point
6072
+ // where the user could tell the turn had started, and the swaths of time are what the pulse
6073
+ // below is for — the entry animation only has to get the phrase on screen.
6074
+ const DUR = 500;
5444
6075
  const t = Math.min(1, (Date.now() - state.startAnim.start) / DUR);
5445
6076
  const phase = t < 0.5 ? 'type' : 'color';
5446
6077
  const phaseT = t < 0.5 ? t * 2 : (t - 0.5) * 2; // 0→1 within each phase
@@ -5488,7 +6119,10 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5488
6119
  }
5489
6120
  lines.push(col(frame, orangeEsc) + ' ' + out.join('') + elapsed);
5490
6121
  }
5491
- if ((Date.now() - state.startAnim.start) >= 1000) {
6122
+ // The cutoff has to be the SAME number as `DUR`. They were separate literals (1000 here, 1000
6123
+ // above) and changing one alone left the preview exiting early or the animation running on into
6124
+ // the pulse — the row would paint one frame of pulse with `startAnim` still set.
6125
+ if ((Date.now() - state.startAnim.start) >= DUR) {
5492
6126
  state.startAnim = null;
5493
6127
  // The colour phase has just finished painting the phrase orange. Anchor
5494
6128
  // the pulse to THIS tick so its first frame is the orange start of the
@@ -5529,79 +6163,68 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5529
6163
  const animColor = lerpColor(r, g, b, r, g, b, 0);
5530
6164
  lines.push(col(frame, animColor) + ' ' + col('[', animColor) + col(word, animColor) + col(tail, greyEsc));
5531
6165
  } else {
5532
-
5533
- // Timing: SWEEP ticks per half-sweep, four halves per cycle (out to yellow,
5534
- // back, out to red, back). The tick count lives in SWEEP_TICKS so every animated
5535
- // row shares one smoothness budget — see the SWEEP_* block near the top.
5536
- const SWEEP = SWEEP_TICKS; // ticks per half-sweep
5537
- const HALF = SWEEP + SWEEP; // ticks per colour (out + back)
5538
- const CYCLE = HALF * 2; // ticks for the full loop
5539
- // Phase is measured from the tick the pulse BEGAN (state.pulseStart), not
5540
- // from 0: the spinner has been running since turn start, so a raw
5541
- // `spin % CYCLE` dropped the pulse into a mid-cycle colour (often the red
5542
- // half) the instant the start animation handed over.
5543
- // `??`, NOT `||` — the same trap as the tool-name sweep: a `pulseStart` of 0 is a
5544
- // real anchor (the turn's first tick), and `0 || spin` collapses the cycle to 0, so
5545
- // the row would sit on one colour forever instead of pulsing.
5546
- const inCycle = ((spin - (state.pulseStart ?? spin)) % CYCLE + CYCLE) % CYCLE;
5547
- const inHalf = inCycle % HALF; // position within this colour's half
5548
- const phase = inCycle < HALF ? 0 : 1; // 0 = yellow, 1 = red
5549
-
5550
- // Colours as plain RGB triples (never ANSI strings). ORANGE is the shared
5551
- // constant so the pulse starts exactly where the start animation ended.
5552
- const YELLOW = [255, 240, 120];
5553
- const RED = [255, 0, 0]; // pure red
5554
- const target = phase === 0 ? YELLOW : RED;
6166
+ // A light flashes across the phrase roughly once a second: the text stays orange and
6167
+ // a yellow lamp runs over it from left to right.
6168
+ //
6169
+ // This replaces a four-phase colour pulse (orange → yellow → orange → red → orange)
6170
+ // whose band was half the row wide. Two things were wrong with it. The band left every
6171
+ // character changed colour behind it, so what the eye saw was the WHOLE PHRASE swinging
6172
+ // between yellow and red — a slow breathing, not a movement. And the red half read as
6173
+ // an error state on a row that only means "working".
6174
+ //
6175
+ //
6176
+ // The light is a QUARTER OF THE PHRASE WIDE, over a yellow highlight running left to right.
6177
+ // The phase comes from a WALL CLOCK, not the tick count — see the same block in formatToolLine.
6178
+ // The render path redraws far more often than the 80ms spinner tick, and a phase derived from
6179
+ // `spin` steps in 80ms jumps however often it is redrawn, which is the per-cell hopping this
6180
+ // replaced.
6181
+ //
6182
+ // The anchor is prefixed so the Working row cannot share an origin with a tool row that happened to
6183
+ // start on the same tick — the two animations are independent and must not be tied together.
6184
+ //
6185
+ // `??`, NOT `||`: tick 0 is a legitimate anchor, and `0 || spin` would collapse the elapsed time
6186
+ // to zero so the light never moved at all.
6187
+ const anchorSpin = state.pulseStart ?? spin;
6188
+ // THE KEY MUST NOT REPEAT ACROSS TURNS.
6189
+ //
6190
+ // `pulseStart` is the tick count within a turn, so two turns can both reach `pulseStart = 6`. With the
6191
+ // key built from the anchor alone, the second turn FOUND the first turn's origin still in the map and
6192
+ // started its sweep from an origin minutes in the past — a phase far past the cycle, so the light
6193
+ // snapped to a position mid-row. Keying on the turn's own start stamp makes every animation unique.
6194
+ const turnKey = state.turnStart || 0;
6195
+ const phaseMs = Math.max(0, Date.now() - lightOrigin(`work:${turnKey}:${anchorSpin}`, anchorSpin, spin)) % cycleMs(workMsg.length);
6196
+
6197
+ // THE GLINT IS A STEP AWAY FROM THE BASE, not a jump to another colour.
6198
+ //
6199
+ // `[255, 240, 120]` against orange `[255, 140, 0]` is a different hue entirely — the eye reads it as
6200
+ // "this text is yellow now", which is a status change rather than a ripple passing over. Claude Code
6201
+ // keeps the two within a few percent of each other and lets the MOVEMENT carry the effect; measured
6202
+ // against its `messageColor`/`shimmerColor` pair, the light is a shade brighter and nothing more.
6203
+ //
6204
+ // These are the base with a lift in the same hue, so the animation reads as a sheen travelling across
6205
+ // the word rather than as the word changing colour.
6206
+ const GLINT = [255, 205, 95];
5555
6207
 
5556
6208
  const chars = workMsg;
5557
- const n = chars.length;
5558
- // The band is sized once for this row; `framesPerChar(n)` is the resulting
5559
- // smoothness, and 3+ frames is where a fade stops looking like a snap.
5560
- const BAND = bandCharsFor(n) / n;
5561
-
5562
- // Interpolate orange ↔ target by `t` (0 = orange, 1 = target).
5563
- const mix = (t) => {
5564
- const k = Math.max(0, Math.min(1, t));
5565
- return lerpColor(
5566
- ORANGE_RGB[0] + (target[0] - ORANGE_RGB[0]) * k,
5567
- ORANGE_RGB[1] + (target[1] - ORANGE_RGB[1]) * k,
5568
- ORANGE_RGB[2] + (target[2] - ORANGE_RGB[2]) * k,
5569
- ORANGE_RGB[0] + (target[0] - ORANGE_RGB[0]) * k,
5570
- ORANGE_RGB[1] + (target[1] - ORANGE_RGB[1]) * k,
5571
- ORANGE_RGB[2] + (target[2] - ORANGE_RGB[2]) * k,
5572
- 0,
5573
- );
5574
- };
5575
-
5576
- const out = [];
5577
- // The pulse is TWO settles, not a ramp: the first takes every character from
5578
- // orange to the phase's target (yellow or red), the second takes it back to
5579
- // orange. Each runs its head from the far edge of the band to the settle point,
5580
- // so:
6209
+ const lightEdge = state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge) || 'cosine';
6210
+ // ONE shimmer for both rows — see `shimmerText`.
6211
+ const out = shimmerText(chars, phaseMs, ORANGE_RGB, GLINT, lightEdge);
6212
+ // A QUIET TURN FADES TOWARD THE WARNING COLOUR.
5581
6213
  //
5582
- // first frame of a phase -> head is behind everything -> nothing swept yet
5583
- // last frame -> head is past everything -> every char settled
6214
+ // `state.lastTokenAt` is stamped whenever output arrives; `state.hasActiveTools` is set while a tool
6215
+ // is running. Both are read here rather than recomputed, because this is the paint path.
5584
6216
  //
5585
- // That geometry is what makes the boundary seamless. The previous version started
5586
- // the head at `1/SWEEP`, already PAST the first character, so the very first pulse
5587
- // frame painted char 0 at 50% of the target right after the row had been fully
5588
- // ORANGE — a visible jump on every handoff, and again at each half-cycle.
5589
- // `/ (SWEEP - 1)`, not `/ SWEEP`: the first frame of a half must be progress 0
5590
- // and its LAST frame progress 1, so every half starts untouched and ends fully
5591
- // settled. Dividing by SWEEP left the final frame at (SWEEP-1)/SWEEP = 0.9, so the
5592
- // sweep never quite completed and the row carried a 2% residue into the next half.
5593
- const progress = (inHalf < SWEEP ? inHalf : inHalf - SWEEP) / (SWEEP - 1);
5594
- const head = -BAND + progress * (1 + BAND * SWEEP_TRAVEL_PAD);
5595
- const pulseEdge = state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge) || 'cosine';
5596
- for (let i = 0; i < n; i++) {
5597
- const charAt = n > 0 ? i / n : 0;
5598
- // Settled amount toward the target; the back-sweep is the same shape mirrored,
5599
- // so it un-settles toward orange in the same left-to-right order.
5600
- const settled = sweepSettle(head - charAt, BAND, pulseEdge);
5601
- const t = inHalf < SWEEP ? settled : 1 - settled;
5602
- out.push(col(chars[i], mix(t)));
5603
- }
5604
- lines.push(col(frame, orangeEsc) + ' ' + out.join('') + elapsed);
6217
+ // The intensity is interpolated into the base colour rather than switching, so a slow model does not
6218
+ // look like a broken one for one frame and then fine the next.
6219
+ const stall = stallIntensity({
6220
+ sinceLastTokenMs: state.lastTokenAt ? Date.now() - state.lastTokenAt : 0,
6221
+ hasActiveTools: !!state.hasActiveTools,
6222
+ });
6223
+ const baseFrame = stall > 0 ? col(frame, lerpColor(
6224
+ ORANGE_RGB[0], ORANGE_RGB[1], ORANGE_RGB[2],
6225
+ 255, 90, 90, stall,
6226
+ )) : col(frame, orangeEsc);
6227
+ lines.push(baseFrame + ' ' + out + elapsed);
5605
6228
  }
5606
6229
  }
5607
6230
 
@@ -5705,7 +6328,10 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5705
6328
  // the typed TEXT white. The prefix is the first `❯ ` of the row (the
5706
6329
  // continuation rows are padded with spaces instead).
5707
6330
  const rawRow = composer.rows[ri];
5708
- const styledRow = highlightPasteMarkers(rawRow);
6331
+ // Which chip the pointer is over, if any. `state.pasteHover` is the paste NUMBER, set by the
6332
+ // mousehover handler from the same index maths the expand gesture uses, so the highlight and the
6333
+ // click can never disagree about which chip they mean.
6334
+ const styledRow = highlightPasteMarkers(rawRow, state.pasteHover);
5709
6335
  // One space of padding after the left wall (and before the right wall), the
5710
6336
  // same as the transcript's user/queue boxes, so the geometry is identical.
5711
6337
  const pre = composerInput(state).prefix;
@@ -6323,7 +6949,12 @@ export function makeState({ cfg, session, opts }) {
6323
6949
  reasoning: effort !== 'off',
6324
6950
  seenThinking: false,
6325
6951
  expanded: false,
6326
- todos: (session && Array.isArray(session.todos)) ? session.todos : [],
6952
+ // A finished list is not restored. A session saved BEFORE this rule existed can still carry one,
6953
+ // and bringing it back would show a panel for work that is already over — the case the drop on the
6954
+ // update path cannot reach.
6955
+ todos: (session && Array.isArray(session.todos) && !todosAllComplete(session.todos))
6956
+ ? session.todos
6957
+ : [],
6327
6958
  todosExpanded: false,
6328
6959
  cwd,
6329
6960
  cwdOverride: null,
@@ -7392,7 +8023,15 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
7392
8023
  if (!opts.length) { appErr('The current model has no thinking control.'); return; }
7393
8024
  if (raw) {
7394
8025
  const v = raw.toLowerCase();
7395
- if (!opts.includes(v)) { appErr(`Unknown effort: ${raw} (expected ${opts.join(' / ')})`); return; }
8026
+ // NO VALIDATION against `opts`. The list is what a model is KNOWN to accept — from the catalog,
8027
+ // or from a user's own `efforts = [...]` — and a value outside it is not necessarily wrong: the
8028
+ // catalog can be behind a model, and a proxy or gateway may take grades the entry never
8029
+ // recorded. Rejecting those made `/effort xhigh` fail on a model that understands it, with no
8030
+ // way to try.
8031
+ //
8032
+ // A wrong value now fails at the provider instead, which is where the truth lives and which
8033
+ // reports what it did not like. `effortWire` already omits the field for a grade with no budget,
8034
+ // so an unknown name is sent as the name itself rather than as a guessed number.
7396
8035
  setEffort(state, cfg, v);
7397
8036
  persist();
7398
8037
  app(`Thinking effort → ${v}`);
@@ -7833,9 +8472,15 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
7833
8472
  refreshGitInfo(state, { force: true });
7834
8473
  }
7835
8474
 
7836
-
7837
8475
  // Update context gauge based on restored session messages.
7838
- const approx = estimateMessagesTokens(session.messages, cfg);
8476
+ //
8477
+ // THE SAME FORMULA AS A LIVE STEP: messages PLUS the tool schemas. This used to count the
8478
+ // messages alone, so the gauge stepped by a fixed amount whenever a restore and a live step
8479
+ // alternated — a visible jump that was two formulas disagreeing, not noise.
8480
+ //
8481
+ // `dispatch` is module-level and cannot reach startTUI's `setCtxTokens`, so the expression is
8482
+ // repeated here rather than the call. Keep the two identical.
8483
+ const approx = estimateMessagesTokens(session.messages, cfg) + (cfg.toolDefTokens || 0);
7839
8484
  state.ctxTokens = approx;
7840
8485
  state.ctxMax = cfg.maxContextTokens || state.ctxMax;
7841
8486
  state.ctxPercent = usagePercent(approx, state.ctxMax);
@@ -8024,7 +8669,21 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8024
8669
  }
8025
8670
  if (ci >= cur.length) { app('Nothing to undo.'); return; }
8026
8671
  const removed = cur.slice(ci);
8672
+ // TRUNCATE THE MODEL'S CONTEXT, not just the transcript on screen.
8673
+ //
8674
+ // `state.chat` is what the user sees; `session.messages` is what the model is sent. This used to
8675
+ // shorten only the former, so /undo looked complete while the withdrawn prompt stayed in the
8676
+ // request — and the model kept acting on text the user believed they had removed. Measured: after
8677
+ // undoing a prompt that deleted a block of words, the next request still carried the deletion.
8678
+ //
8679
+ // `session.messages` is the array the agent sends, so it is the one that has to be cut. The
8680
+ // `_ledger` is rebuilt from it on the next turn (see sync), and the cached prefix is dropped so a
8681
+ // stale token count cannot describe a shorter history.
8682
+ session.messages = cur.slice(0, ci);
8027
8683
  state.chat = state.chat.slice(0, Math.max(0, state.chat.length - removed.length));
8684
+ if (state.agent && state.agent._ledger) {
8685
+ try { state.agent._ledger.reset(); } catch { /* best-effort */ }
8686
+ }
8028
8687
  saveSession(session);
8029
8688
  // Rewind the FILES too. This used to only truncate the transcript and then
8030
8689
  // say "Undid 1 prompt", while every Edit/Write the agent had made stayed on
@@ -8075,6 +8734,9 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8075
8734
  // same time — `state.running` is what submit() checks to queue instead.
8076
8735
  if (state.running) { app('Busy — wait for the current turn to finish.'); return; }
8077
8736
  state.running = true;
8737
+ // The Working row takes over the progress display: it shows `Compacting Context...` for as
8738
+ // long as this runs, so the summary round-trip is visible without adding a transcript row.
8739
+ state.compacting = true;
8078
8740
  renderFrame();
8079
8741
 
8080
8742
  const maxCtx = cfg.maxContextTokens || state.ctxMax || 512000;
@@ -8084,16 +8746,14 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8084
8746
  const dropped = droppedMsgs.length;
8085
8747
 
8086
8748
 
8087
- // Summarize the dropped portion, so context survives the trim — that is the
8088
- // whole point of compaction. Rendered as the SAME live compaction block the
8089
- // automatic path uses (blinking bullet → "Compaction complete"), so the two
8090
- // look identical instead of one getting a spinner and the other a text line.
8749
+ // Summarize the dropped portion, so context survives the trim — that is the whole point of
8750
+ // compaction. The PROGRESS lives on the Working row (`state.compacting`, set above), and the
8751
+ // transcript gets one row at the END recording what happened: the token counts and, behind
8752
+ // Ctrl+O, the summary itself. That row is information; the blinking was state.
8091
8753
  let summary = '';
8092
8754
  const liveBlock = {
8093
8755
  role: 'compaction', phase: 'running', startedAt: Date.now(), instruction: '',
8094
8756
  };
8095
- addChat(liveBlock);
8096
- renderFrame();
8097
8757
  if (droppedMsgs.length > 0) {
8098
8758
  const llm = new LLM(cfg);
8099
8759
  // Let Esc abort the summary request (see the escape handler).
@@ -8113,16 +8773,18 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8113
8773
  state._compactLlm = null;
8114
8774
  }
8115
8775
  // Interrupted (Esc): abort yields an empty summary; do NOT trim — that would
8116
- // drop history with nothing to replace it. Mark the block cancelled and stop.
8776
+ // drop history with nothing to replace it. The Working row goes back to a
8777
+ // rotating phrase and the transcript records that nothing was trimmed.
8117
8778
  if (state._compactAborted) {
8118
8779
  state._compactAborted = false;
8780
+ state.compacting = false;
8119
8781
  liveBlock.phase = 'cancelled';
8120
8782
  liveBlock._cache = null;
8783
+ addChat(liveBlock);
8121
8784
  state.running = false;
8122
8785
  renderFrame();
8123
8786
  return;
8124
8787
  }
8125
-
8126
8788
  const compactedSummary = summary
8127
8789
  ? `[hncode] Earlier conversation context was compacted. ${dropped} older message(s) were replaced by this summary:\n\n${summary}`
8128
8790
  : `[hncode] ${dropped} earlier message(s) were compacted without a summary. Their detail is no longer available.`;
@@ -8149,7 +8811,8 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8149
8811
  ];
8150
8812
 
8151
8813
  saveSession(session);
8152
- state.tokens = estimateMessagesTokens(session.messages, cfg);
8814
+ // Same formula as everywhere else: messages PLUS the tool schemas.
8815
+ state.tokens = estimateMessagesTokens(session.messages, cfg) + (cfg.toolDefTokens || 0);
8153
8816
  state.ctxTokens = state.tokens;
8154
8817
  state.ctxPercent = usagePercent(state.tokens, state.ctxMax || maxCtx);
8155
8818
  // The transcript was just replaced with a much shorter one. Drop the scroll
@@ -8159,13 +8822,16 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8159
8822
  dropScrollPin(state);
8160
8823
  state.scroll = 0;
8161
8824
  state.running = false;
8162
- // Settle the live block into "Compaction complete (before → after tokens)",
8163
- // matching the automatic path.
8825
+ state.compacting = false;
8826
+ // One transcript row, at the END, recording what happened — "Compaction complete
8827
+ // (before → after tokens)" with the summary behind Ctrl+O. The progress was on the
8828
+ // Working row; this is the record.
8164
8829
  liveBlock.phase = 'done';
8165
8830
  liveBlock.tokensBefore = estimateMessagesTokens(msgs, cfg);
8166
8831
  liveBlock.tokensAfter = state.tokens;
8167
8832
  liveBlock.text = summary || '';
8168
8833
  liveBlock._cache = null;
8834
+ addChat(liveBlock);
8169
8835
  renderFrame();
8170
8836
  return;
8171
8837
  }
@@ -8675,7 +9341,11 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8675
9341
  }
8676
9342
  case 'usage': {
8677
9343
  const msgs = session.messages || [];
8678
- const approx = Math.round(JSON.stringify(msgs).length / 3.5);
9344
+ // THE SAME ESTIMATOR AS THE GAUGE. This was `JSON.stringify(msgs).length / 3.5` — a third formula,
9345
+ // and a wrong one: the serialised JSON carries every key, quote and brace, so it charged for the
9346
+ // structure itself. Three formulas for one quantity is how a session reports three different
9347
+ // numbers for the same request.
9348
+ const approx = estimateMessagesTokens(msgs, cfg) + (cfg.toolDefTokens || 0);
8679
9349
  const g = C.gray, w = C.white, b = C.bold;
8680
9350
  const u = state.usage || {};
8681
9351
  const lines = [
@@ -8706,6 +9376,33 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8706
9376
  lines.push(w + b + 'Context window' + C.reset);
8707
9377
  lines.push(' ' + g + 'used ' + C.reset + w + `${state.ctxPercent}% `
8708
9378
  + g + `(${fmtTokens(state.ctxTokens)} / ${fmtTokens(state.ctxMax)})` + C.reset);
9379
+
9380
+ // WHAT THE REQUEST IS MADE OF, itemised.
9381
+ //
9382
+ // A single number cannot answer "why does the provider report more than this?" — the gap could be
9383
+ // the tool block, a cache read, framing, or one enormous tool result. Naming each part makes the
9384
+ // answer visible instead of a theory: the biggest row is where the tokens are.
9385
+ {
9386
+ const sysTokens = msgs.filter((m) => m && m.role === 'system')
9387
+ .reduce((n, m) => n + messageTokens(m, cfg), 0);
9388
+ const convTokens = msgs.filter((m) => m && m.role !== 'system')
9389
+ .reduce((n, m) => n + messageTokens(m, cfg), 0);
9390
+ const toolTokens = cfg.toolDefTokens || 0;
9391
+ const rows = [
9392
+ ['system prompt', sysTokens],
9393
+ ['conversation', convTokens],
9394
+ ['tool schemas', toolTokens],
9395
+ ].filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
9396
+ if (rows.length) {
9397
+ lines.push('');
9398
+ lines.push(w + b + 'Where it goes' + C.reset);
9399
+ for (const [label, v] of rows) {
9400
+ const share = state.ctxTokens ? Math.round((v / state.ctxTokens) * 100) : 0;
9401
+ lines.push(' ' + g + label.padEnd(14) + C.reset + w + fmtTokens(v).padStart(8) + C.reset
9402
+ + g + ` ${share}%` + C.reset);
9403
+ }
9404
+ }
9405
+ }
8709
9406
  // Trend of the request size across this session's steps: rises as history
8710
9407
  // accumulates and drops when a compaction trims it.
8711
9408
  const hist = Array.isArray(state.tokenHistory) ? state.tokenHistory : [];
@@ -9512,8 +10209,11 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
9512
10209
  // requests left it showing the pre-trim number until the next turn — the user
9513
10210
  // had to send a message to see the effect. `/trim` knows the exact count, so
9514
10211
  // push it into the same state fields the events would have set.
9515
- state.ctxTokens = after;
9516
- state.ctxPercent = usagePercent(after, state.ctxMax || cfg.maxContextTokens || 1);
10212
+ //
10213
+ // Plus the tool schemas, like every other writer: `/trim` counts messages, and the request also
10214
+ // carries the tools, so leaving them out dropped the gauge by that much more than was freed.
10215
+ state.ctxTokens = after + (cfg.toolDefTokens || 0);
10216
+ state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || cfg.maxContextTokens || 1);
9517
10217
  if (session) {
9518
10218
  try {
9519
10219
  session.tokenHistory = Array.isArray(session.tokenHistory) ? session.tokenHistory : [];
@@ -11013,8 +11713,22 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
11013
11713
  return;
11014
11714
  }
11015
11715
  }
11016
- // Unknown command: show error message
11017
- appErr(`Unknown command: /${cmdRaw.replace(/^\//, '')}`);
11716
+ // An unknown command is sent to the model AS TEXT rather than refused.
11717
+ //
11718
+ // The slash was already stripped by the composer's command path, so "unknown command" meant the
11719
+ // user's message was swallowed and never reached the model — typing `/why is this slow` produced
11720
+ // an error instead of a question. A leading slash is also how people write a path, a sed script or
11721
+ // a regex, so a message that merely STARTS with one was the most likely casualty.
11722
+ //
11723
+ // Sending it is the honest default: the model can answer a question phrased oddly, and the
11724
+ // alternative is losing the text entirely. The typo case is covered by the notice, so a
11725
+ // near-miss like `/hlep` still says what was not found while its text goes through.
11726
+ // `cmdRaw` is the whole line the user typed, arguments included, and it may or may not carry a
11727
+ // leading slash depending on the caller. It goes out with ONE slash, so the model sees the message
11728
+ // as the person wrote it rather than as a bare word the composer mangled.
11729
+ const asTyped = `/${String(cmdRaw || '').replace(/^\/+/, '')}`.trim();
11730
+ notice(`No such command: ${asTyped.split(/\s+/)[0]} — sent as a message instead`, 'info');
11731
+ await sendPrompt(asTyped);
11018
11732
  return;
11019
11733
  }
11020
11734
  }
@@ -11316,24 +12030,45 @@ function registerProvider(state, cfg, h, name, baseUrl, apiKey, protocol, typeLa
11316
12030
  fetchAndRegisterModels(state, cfg, name, h);
11317
12031
  }
11318
12032
 
11319
- async function fetchAndRegisterModels(state, cfg, providerName, h) {
11320
- const p = (cfg.raw.providers || {})[providerName] || {};
11321
- const baseUrl = p.base_url || p.baseUrl || '';
11322
- if (!baseUrl) return;
11323
- h.notice(`Discovering models for ${providerName}…`, 'info');
11324
- const models = await fetchModels({ baseUrl, apiKey: p.api_key || p.apiKey || '', protocol: p.protocol || 'openai' });
11325
- if (!models.length) { h.notice(`No models discovered for ${providerName} (endpoint unreachable or empty).`, 'error'); return; }
11326
- cfg.raw.models = cfg.raw.models || {};
11327
- for (const m of models) {
11328
- addModel(providerName, m.id, { display_name: m.display, contextLength: m.contextLength, maxTokens: m.maxTokens });
11329
- // Merge over any existing entry rather than replacing it: discovery reports
11330
- // fewer fields than the user may have set (context_length, pricing, …), and a
11331
- // field the endpoint did not report must not blank out a value already there.
11332
- const key = modelKey(providerName, m.id);
11333
- cfg.raw.models[key] = mergeRefreshedModelEntry(providerName, m, cfg.raw.models[key]);
11334
- }
11335
- h.notice(`Discovered ${models.length} models for ${providerName}. Use /model to pick one.`, 'info');
11336
- }
12033
+ async function fetchAndRegisterModels(state, cfg, providerName, h) {
12034
+ const p = (cfg.raw.providers || {})[providerName] || {};
12035
+ const baseUrl = p.base_url || p.baseUrl || '';
12036
+ if (!baseUrl) return;
12037
+ h.notice(`Discovering models for ${providerName}…`, 'info');
12038
+ const models = await fetchModels({ baseUrl, apiKey: p.api_key || p.apiKey || '', protocol: p.protocol || 'openai' });
12039
+ // THE FETCH MUST SUCCEED BEFORE ANYTHING IS REMOVED.
12040
+ //
12041
+ // Deleting first and fetching second means an unreachable endpoint leaves the provider with no models at
12042
+ // all. Everything below runs only after a non-empty result.
12043
+ if (!models.length) { h.notice(`No models discovered for ${providerName} (endpoint unreachable or empty).`, 'error'); return; }
12044
+
12045
+ cfg.raw.models = cfg.raw.models || {};
12046
+ // Snapshot what this provider had, so the merge below can keep fields the source does not report.
12047
+ const prevByKey = new Map();
12048
+ for (const key of Object.keys(cfg.raw.models)) {
12049
+ if (key.split('/')[0] !== providerName) continue;
12050
+ prevByKey.set(key, cfg.raw.models[key]);
12051
+ delete cfg.raw.models[key];
12052
+ }
12053
+
12054
+ // REPLACE THE LIST WITH WHAT WAS JUST FETCHED.
12055
+ //
12056
+ // A model the endpoint no longer reports disappears, and a renamed one does not appear twice. Merging
12057
+ // over the old entries kept both for ever, which is what made Alt+R look like it did nothing.
12058
+ const kept = [];
12059
+ for (const m of models) {
12060
+ const key = modelKey(providerName, m.id);
12061
+ // Merge over the SNAPSHOT: the live entries were just deleted, so reading them yields undefined and
12062
+ // the merge folds nothing — every field the source omits (context_length, pricing) would be wiped.
12063
+ const entry = mergeRefreshedModelEntry(providerName, m, prevByKey.get(key));
12064
+ cfg.raw.models[key] = entry;
12065
+ kept.push({ ...entry, id: m.id });
12066
+ }
12067
+ // Rewrite the provider's TOML blocks from that list. `addModel` appends one block per model and returns
12068
+ // early when one already exists, so calling it here left the removed entries on disk.
12069
+ try { replaceProviderModels(providerName, kept); } catch { /* the in-memory list is still correct */ }
12070
+ h.notice(`Discovered ${models.length} models for ${providerName}. Use /model to pick one.`, 'info');
12071
+ }
11337
12072
 
11338
12073
  // Merge ONE refreshed model's data over its previous config entry.
11339
12074
  //
@@ -11590,18 +12325,22 @@ export function tokenize(str) {
11590
12325
  const mcol = parseInt(mm[2], 10);
11591
12326
  const mrow = parseInt(mm[3], 10);
11592
12327
  const press = mm[4] === 'M';
12328
+ // SGR encodes the modifiers in the button parameter: 4 = Shift, 8 = Meta, 16 = Ctrl. Ctrl is
12329
+ // carried on the token because Ctrl+click has its own meaning in the composer — see the
12330
+ // mousedown handler, where it expands a collapsed paste rather than placing the caret.
12331
+ const ctrl = (btn & 16) === 16;
11593
12332
  if ((btn & 64) === 64) {
11594
- out.push({ key: (btn & 1) ? 'wheeldown' : 'wheelup', col: mcol, row: mrow });
12333
+ out.push({ key: (btn & 1) ? 'wheeldown' : 'wheelup', col: mcol, row: mrow, ctrl });
11595
12334
  } else if (press && (btn & 3) === 0 && (btn & 32) === 0) {
11596
- out.push({ key: 'mousedown', button: 'left', col: mcol, row: mrow });
12335
+ out.push({ key: 'mousedown', button: 'left', col: mcol, row: mrow, ctrl });
11597
12336
  } else if (press && (btn & 32) === 32 && (btn & 3) === 3) {
11598
- out.push({ key: 'mousehover', col: mcol, row: mrow });
12337
+ out.push({ key: 'mousehover', col: mcol, row: mrow, ctrl });
11599
12338
  } else if (press && (btn & 32) === 32) {
11600
- out.push({ key: 'mousemove', button: 'left', col: mcol, row: mrow });
12339
+ out.push({ key: 'mousemove', button: 'left', col: mcol, row: mrow, ctrl });
11601
12340
  } else if (!press && (btn & 3) === 0) {
11602
- out.push({ key: 'mouseup', button: 'left', col: mcol, row: mrow });
12341
+ out.push({ key: 'mouseup', button: 'left', col: mcol, row: mrow, ctrl });
11603
12342
  } else if (press && (btn & 3) === 2) {
11604
- out.push({ key: 'rightclick', col: mcol, row: mrow });
12343
+ out.push({ key: 'rightclick', col: mcol, row: mrow, ctrl });
11605
12344
  }
11606
12345
  i += mm[0].length;
11607
12346
  continue;
@@ -11646,10 +12385,17 @@ export function tokenize(str) {
11646
12385
  if (f === undefined) break;
11647
12386
  out.push({ key: f === 'P' ? 'f1' : 'escape' });
11648
12387
  i += 3;
11649
- } else {
11650
- out.push({ key: 'escape' });
11651
- i++;
11652
- }
12388
+ } else {
12389
+ // `ESC <printable>` is Alt+that key. A second ESC stays a plain Escape.
12390
+ const altNext = str[i + 1];
12391
+ if (altNext && altNext >= '!' && altNext <= '~') {
12392
+ out.push({ key: 'alt-' + altNext.toLowerCase() });
12393
+ i += 2;
12394
+ continue;
12395
+ }
12396
+ out.push({ key: 'escape' });
12397
+ i++;
12398
+ }
11653
12399
  } else if (c === '\r') {
11654
12400
  out.push({ key: 'enter' });
11655
12401
  i++;
@@ -11919,6 +12665,10 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
11919
12665
  // Delivered as ONE user message rather than a system message, and marked read only after
11920
12666
  // the text is built: the model has then genuinely seen it, and a format failure cannot
11921
12667
  // silently consume the mail.
12668
+ //
12669
+ // This does NOT become a transcript row. Team traffic belongs in the team PANEL — that is what it
12670
+ // is for, and putting it in the main transcript interleaved another conversation into the one the
12671
+ // user is having with the lead. The panel reads these same files, so nothing is hidden from them.
11922
12672
  const teamName = leadTeam();
11923
12673
  if (teamName) {
11924
12674
  try {
@@ -11937,7 +12687,6 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
11937
12687
  // text the status bar's `system prompt` segment collapses to zero columns and vanishes
11938
12688
  // mid-turn, then returns at turn end when the untrimmed request is assigned back.
11939
12689
  state.turnSystemPrompt = sysText;
11940
- Object.assign(session, { model: cfg.model, messages: messages.filter((m) => m.role !== 'system') });
11941
12690
  // The user's message is on disk before the agent has done anything. A crash
11942
12691
  // this early used to lose the entire prompt, because the first save was at
11943
12692
  // turn end.
@@ -12230,57 +12979,51 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12230
12979
  return;
12231
12980
  }
12232
12981
  if (e.type === 'compacting') {
12233
- // Open a LIVE compaction block (kimi's CompactionComponent): a blinking
12234
- // bullet while the summary round-trip runs, so the turn does not sit
12235
- // silent for seconds. The same row is upgraded in place on `compacted`.
12236
- // If a compaction block from a PREVIOUS (interrupted) turn is still in the
12237
- // transcript, settle it first so we never leave two blinking rows or let a
12238
- // stale cancelled block get re-promoted to running.
12982
+ // The compaction shows up on the WORKING ROW, not as a transcript block. It is a state of
12983
+ // the turn — the agent is busy doing this one long thing — and the row above the composer is
12984
+ // where "busy" already lives. A transcript block duplicated that and left a blinking row
12985
+ // scrolling away while the turn carried on underneath it.
12986
+ //
12987
+ // A block from a PREVIOUS (interrupted) turn is settled here for the same reason as before:
12988
+ // two compaction rows in the transcript, one stale, is worse than none.
12239
12989
  const stale = state.compactionMsg;
12240
12990
  if (stale && state.chat.indexOf(stale) >= 0 && stale.phase !== 'running') {
12241
12991
  const idx = state.chat.indexOf(stale); if (idx >= 0) state.chat.splice(idx, 1);
12242
- } else if (stale && state.chat.indexOf(stale) < 0) {
12243
- for (let i = state.chat.length - 1; i >= 0; i--) {
12244
- if (state.chat[i] && state.chat[i].role === 'compaction') state.chat.splice(i, 1);
12245
- }
12246
12992
  }
12247
- state.compactionMsg = {
12248
- role: 'compaction', phase: 'running', startedAt: Date.now(),
12249
- instruction: e.instruction || '',
12250
- };
12251
- viewCur.addChat(state.compactionMsg);
12993
+ state.compacting = true;
12994
+ // Cleared so nothing tries to settle a block that was never added.
12995
+ state.compactionMsg = null;
12252
12996
  viewCur.renderSoon();
12253
12997
  return;
12254
12998
  }
12255
12999
  if (e.type === 'compacted') {
12256
13000
  state.ctxTokens = e.after || state.ctxTokens;
12257
13001
  state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || 1);
12258
- // Settle the live block rather than pushing a second row: the header
12259
- // becomes "Compaction complete (X → Y tokens)" and the summary stays
12260
- // hidden behind Ctrl+O.
12261
- const block = state.compactionMsg;
12262
- if (block) {
12263
- block.phase = 'done';
12264
- block.tokensBefore = e.before;
12265
- block.tokensAfter = e.after;
12266
- block.text = e.summary || '';
12267
- block._cache = null;
12268
- state.compactionMsg = null;
12269
- } else {
12270
- viewCur.addChat({
12271
- role: 'compaction', phase: 'done',
12272
- tokensBefore: e.before, tokensAfter: e.after, text: e.summary || '',
12273
- });
12274
- }
13002
+ // Compaction is over, so the Working row goes back to a rotating phrase.
13003
+ //
13004
+ // RE-ROLLED, not restored: an automatic compaction happens MID-TURN — the agent hit
13005
+ // the window limit between steps and carries on afterwards — so the phrase the turn
13006
+ // started with has been on screen for a while by now. Picking a new one marks the
13007
+ // resume: the row visibly starts again rather than reading as the same stalled
13008
+ // operation it was showing for the last few seconds.
13009
+ state.compacting = false;
13010
+ state.workMsg = WORKING_MESSAGES[Math.floor(Math.random() * WORKING_MESSAGES.length)];
13011
+ // The transcript keeps a RECORD of what happened, with the token counts — that is
13012
+ // information, not state, and it is what Ctrl+O expands to show the summary.
13013
+ viewCur.addChat({
13014
+ role: 'compaction', phase: 'done',
13015
+ tokensBefore: e.before, tokensAfter: e.after, text: e.summary || '',
13016
+ });
13017
+ state.compactionMsg = null;
12275
13018
  viewCur.renderSoon();
12276
13019
  return;
12277
13020
  }
12278
13021
  if (e.type === 'compaction_cancelled') {
12279
- // The user interrupted an auto-compaction. Settle the live block so it
12280
- // stops blinking and reads as cancelled (nothing was trimmed).
12281
- const block = state.compactionMsg;
12282
- if (block) { block.phase = 'cancelled'; block._cache = null; state.compactionMsg = null; }
12283
- else viewCur.addChat({ role: 'compaction', phase: 'cancelled' });
13022
+ // The user interrupted an auto-compaction: nothing was trimmed, so the transcript says so
13023
+ // and the turn resumes with its phrase intact.
13024
+ state.compacting = false;
13025
+ state.compactionMsg = null;
13026
+ viewCur.addChat({ role: 'compaction', phase: 'cancelled' });
12284
13027
  viewCur.renderSoon();
12285
13028
  return;
12286
13029
  }
@@ -12290,6 +13033,9 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12290
13033
  return;
12291
13034
  }
12292
13035
  if (e.type === 'data') {
13036
+ // A token arrived, so the turn is not stalled. Stamped here rather than in the paint path
13037
+ // because the paint path cannot tell "slow" from "quiet" — only the events can.
13038
+ state.lastTokenAt = Date.now();
12293
13039
  // Feed the answer text into the paced buffer instead of painting it. The
12294
13040
  // buffer closes any open reasoning region itself, in arrival order, so the
12295
13041
  // block settles exactly when the provider moved on rather than when the next
@@ -12306,47 +13052,69 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12306
13052
  viewCur.renderSoon(); return;
12307
13053
  }
12308
13054
  if (e.type === 'context') {
12309
- // Live context gauge: the agent reports the estimated request size once
12310
- // per step, so the header and /usage track the growing history.
12311
- state.ctxTokens = e.tokens || 0;
12312
- state.ctxMax = e.max || state.ctxMax;
13055
+ // Live context gauge: the agent reports the estimated request size once per step, so the
13056
+ // header and /usage track the growing history.
13057
+ //
13058
+ // ONE FORMULA, inlined. Every writer of `ctxTokens` — this event, session restore, /trim,
13059
+ // compaction — computes the same thing. Before that they disagreed: restore counted messages
13060
+ // alone while a live step counted messages PLUS the tool schemas, so the gauge stepped by a
13061
+ // fixed amount whenever the two paths alternated. A visible jump, caused by two formulas
13062
+ // disagreeing rather than by any noise in either.
13063
+ //
13064
+ // Inlined rather than calling startTUI's `setCtxTokens`: `runTurn` is module-level and cannot
13065
+ // see that closure, and `test/tui-scope.test.mjs` fails the build when it tries. The two must
13066
+ // stay identical.
13067
+ //
13068
+
13069
+ // THE ESTIMATE ONLY. No provider number is mixed in anywhere.
13070
+ //
13071
+ // `e.tokens` is what this app's own tokenizer model prices the request at, and it is the ONE
13072
+ // source for the gauge. `usage.input` was wired in twice and removed twice: the two count
13073
+ // different things (a report covers the request that already went out, the estimate covers the
13074
+ // one about to), so whichever ran last won and the number jumped between formulas.
13075
+ state.ctxTokens = Number(e.tokens) || 0;
13076
+ if (Number.isFinite(e.max) && e.max > 0) state.ctxMax = e.max;
12313
13077
  state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || 1);
12314
- // Keep a bounded series of step sizes so /usage can draw the trend.
12315
13078
  if (!Array.isArray(state.tokenHistory)) state.tokenHistory = [];
12316
- const h = state.tokenHistory;
12317
- if (h[h.length - 1] !== state.ctxTokens) h.push(state.ctxTokens);
12318
- if (h.length > 240) h.splice(0, h.length - 240);
12319
- // Mirror onto the session so saveSession (which persists the whole
12320
- // session object) captures it: a --resume restores the trend chart.
12321
- if (session) {
12322
- if (!Array.isArray(session.tokenHistory)) session.tokenHistory = [];
12323
- if (session.tokenHistory[session.tokenHistory.length - 1] !== state.ctxTokens) session.tokenHistory.push(state.ctxTokens);
12324
- if (session.tokenHistory.length > 240) session.tokenHistory.splice(0, session.tokenHistory.length - 240);
13079
+ if (state.tokenHistory[state.tokenHistory.length - 1] !== state.ctxTokens) {
13080
+ state.tokenHistory.push(state.ctxTokens);
13081
+ if (state.tokenHistory.length > 240) state.tokenHistory.splice(0, state.tokenHistory.length - 240);
12325
13082
  }
13083
+ if (session) session.ctxTokens = state.ctxTokens;
12326
13084
  viewCur.renderSoon(); return;
12327
13085
  }
12328
13086
  if (e.type === 'usage') {
12329
- // Real token accounting from the provider. Folded into a session total
12330
13087
  // that /usage and /cost report, and persisted with the session so a
12331
13088
  // --resume does not lose what the session has already spent.
12332
13089
  //
12333
- // The COST is recomputed from the total rather than accumulated per
12334
13090
  // event: switching models mid-session would otherwise price every
12335
13091
  // earlier request at the new model's rates.
12336
13092
  if (!state.usage) state.usage = {};
12337
13093
  state.usage = addUsage(state.usage, e.usage);
12338
13094
  if (session) session.usage = state.usage;
12339
13095
  state.lastUsage = e.usage;
12340
- // The provider's exact count for this request. It REPLACES the running estimate
12341
- // rather than adding to it, because it already covers everything the estimate was
12342
- // built from — and it also includes reasoning tokens, which the delta stream does
12343
- // not carry. `settled` accumulates the finished requests of this turn so a turn
12344
- // that calls tools sums its steps instead of showing only the last one.
12345
- if (e.usage && Number.isFinite(e.usage.output)) {
12346
- state.turnOutputSettled = (state.turnOutputSettled || 0) + e.usage.output;
12347
- state.turnOutputEstimate = 0;
12348
- state.turnOutputTokens = (state.turnOutputSettled || 0) + state.turnOutputEstimate;
12349
- }
13096
+ // The gauge is NOT updated here. `usage.input` is what the provider charged, and it has caused
13097
+ // two problems worth recording so it is not wired in a third time:
13098
+ //
13099
+ // - it counts the request that ALREADY WENT OUT, while the `context` event counts the one
13100
+ // about to. The two alternate, so the number jumps between two definitions of "context".
13101
+ // - on a cache hit some providers report only the uncached remainder, which made the gauge
13102
+ // COLLAPSE mid-turn and then jump back.
13103
+ //
13104
+ // PROVIDER REPORT ARRIVES -> the estimate it covers is done.
13105
+ //
13106
+ // The counter is `settled + estimate`: finished steps plus the one streaming now. When a usage
13107
+ // report lands, `settled` grows by that step's exact figure AND `estimate` still holds the
13108
+ // same step's guess, so the row showed roughly double — measured 25 estimated + 25 settled
13109
+ // reading 50 — and every later step built on the inflated total.
13110
+ //
13111
+ // Clearing `estimate` here is what makes the estimate a LIVE figure for the step in flight
13112
+ // rather than a running sum that never forgets.
13113
+ if (e.usage && Number.isFinite(e.usage.output)) {
13114
+ state.turnOutputSettled = (state.turnOutputSettled || 0) + e.usage.output;
13115
+ state.turnOutputEstimate = 0;
13116
+ state.turnOutputTokens = state.turnOutputSettled;
13117
+ }
12350
13118
  refreshCost(state, cfg);
12351
13119
  viewCur.renderSoon(); return;
12352
13120
  }
@@ -12395,6 +13163,11 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12395
13163
  return;
12396
13164
  }
12397
13165
  if (e.type === 'tool_start') {
13166
+ // A RUNNING TOOL IS WORK, NOT A STALL. An `npm install` that takes a minute produces no tokens
13167
+ // and looks exactly like a hung request — the row must not fade to the warning colour while
13168
+ // something is demonstrably happening.
13169
+ state.hasActiveTools = true;
13170
+ state.lastTokenAt = Date.now();
12398
13171
  // A tool call means the model finished this step's reasoning and is now
12399
13172
  // acting on it, so the turn's text has to be fully on screen first: the tool
12400
13173
  // row is drawn UNDER the reply, and text still trickling in afterwards would
@@ -12419,6 +13192,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12419
13192
  // Track the key argument as it streams so "Using Bash (cmd)" shows
12420
13193
  // the command DURING the run rather than only once it has finished.
12421
13194
  entry.toolArgs = { ...(entry.toolArgs || {}), ...entry._argStream.pairs };
13195
+ entry._cache = null; // the argument is part of the rendered line
12422
13196
  if (entry.toolName === 'Write') {
12423
13197
  entry.streamContent = argStreamValue(entry._argStream, 'content');
12424
13198
  // Incremental line count so the preview's "… N more lines" note is
@@ -12512,6 +13286,11 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12512
13286
  // as soon as they arrive instead of waiting for the next event.
12513
13287
  viewCur.renderSoon();
12514
13288
  } else if (e.type === 'tool_result') {
13289
+ // The tool is done, so the stall clock starts again from here. Stamping `lastTokenAt` stops the
13290
+ // row from fading the instant a long tool finishes — the fade should measure quiet AFTER the
13291
+ // tool, not the tool's own duration.
13292
+ state.hasActiveTools = false;
13293
+ state.lastTokenAt = Date.now();
12515
13294
  const entry = [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending && m.id === e.id)
12516
13295
  || [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending && m.toolName === e.name);
12517
13296
  // A failed run must be visible on the TOOL line too: the status bullet
@@ -12589,6 +13368,19 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12589
13368
  // A finished tool result IS a completed step, so the transcript is
12590
13369
  // persisted here too — the other half of the step-boundary save above.
12591
13370
  persistTurnProgress(messages);
13371
+ // The step just changed files, so refresh the status bar's diff.
13372
+ //
13373
+ // This is the ONLY mid-turn refresh, and the reason the 15s timer and the guard in
13374
+ // refreshGitInfo keep out of a running turn is that a poll would return a value that is
13375
+ // stale on arrival — the agent is about to write again. A STEP BOUNDARY is different: it is
13376
+ // a moment when the agent has stopped writing and is about to think, so the count is both
13377
+ // current and stable enough to look at. Waiting for the turn to end, which is what it used
13378
+ // to do, meant a long turn showed an unchanging `+0 -0` while dozens of files were edited.
13379
+ //
13380
+ // `force` because the running-turn guard would otherwise drop it, and no `renderFrame()`
13381
+ // here: the reply below already schedules one, and the async git result asks for a repaint
13382
+ // of its own when it lands.
13383
+ refreshGitInfo(state, { force: true });
12592
13384
  } else if (e.type === 'error') viewCur.addChat({ role: 'tool_result', text: (e.error && e.error.message) || String(e.error) });
12593
13385
  else if (e.type === 'truncation_recovery') {
12594
13386
  // The answer hit the output cap mid-way and the agent asked the model to
@@ -12603,7 +13395,19 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12603
13395
  // the user has no way to know the text is still readable.
12604
13396
  viewCur.addChat({ role: 'system', text: `Trimmed ${e.elided} old tool result(s) to free context (${fmtTokens(e.before)} → ${fmtTokens(e.after)}); the removed text is saved on disk. You can modify or disable this feature by using "/auto-trim"` });
12605
13397
  }
12606
- else if (e.type === 'todos') { state.todos = e.todos || []; if (session) session.todos = state.todos; try { viewCur.pubStatusField('todos', [...state.todos]); } catch { /* best-effort */ } }
13398
+ else if (e.type === 'todos') {
13399
+ // A FINISHED list is dropped outright, not stored and hidden.
13400
+ //
13401
+ // The tool sends the whole list on every call, so "all done" arrives as an ordinary update.
13402
+ // Keeping it would persist a completed plan into the session, hand it back to the model as
13403
+ // `cfg.todoState` on the next turn, and restore it on resume — three descriptions of work
13404
+ // that is already over. Emptying it here means every later reader sees "no todos", which is
13405
+ // the truth.
13406
+ const incoming = Array.isArray(e.todos) ? e.todos : [];
13407
+ state.todos = todosAllComplete(incoming) ? [] : incoming;
13408
+ if (session) session.todos = state.todos;
13409
+ try { viewCur.pubStatusField('todos', [...state.todos]); } catch { /* best-effort */ }
13410
+ }
12607
13411
  viewCur.render();
12608
13412
  },
12609
13413
  });
@@ -12746,7 +13550,8 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12746
13550
  }
12747
13551
  }
12748
13552
 
12749
- const approx = estimateMessagesTokens(messages, cfg);
13553
+ // Same formula as everywhere else: messages PLUS the tool schemas.
13554
+ const approx = estimateMessagesTokens(messages, cfg) + (cfg.toolDefTokens || 0);
12750
13555
  state.ctxTokens = approx;
12751
13556
  state.ctxMax = cfg.maxContextTokens || state.ctxMax;
12752
13557
  state.ctxPercent = usagePercent(approx, state.ctxMax);
@@ -12799,6 +13604,18 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
12799
13604
  // row and behind whatever the next turn draws.
12800
13605
  sessHost.flushStream();
12801
13606
  state.running = false;
13607
+ // Release the per-message markdown render caches. Each assistant message's
13608
+ // `_mdCache` holds a full copy of its text, the block split, AND the rendered
13609
+ // ANSI rows twice over — several times the transcript's size — and the chat
13610
+ // array is never trimmed. The rows are re-derivable (the shared bounded row
13611
+ // cache serves history), so all but the most recent messages drop theirs.
13612
+ {
13613
+ const KEEP_RECENT = 8;
13614
+ const chat = state.chat || [];
13615
+ for (let i = 0; i < chat.length - KEEP_RECENT; i++) {
13616
+ if (chat[i] && chat[i]._mdCache) chat[i]._mdCache = null;
13617
+ }
13618
+ }
12802
13619
  // The agent may have written or edited files this turn: refresh the git badge
12803
13620
  // now so the diff totals reflect it immediately, instead of waiting for the
12804
13621
  // 15s timer.
@@ -12878,6 +13695,38 @@ export async function startTUI(opts) {
12878
13695
  // hands back a new object, and every later save would otherwise keep writing the old one —
12879
13696
  // which is how /move left the session stamped with the directory it was moved away from.
12880
13697
  let session = opts.session;
13698
+
13699
+ /**
13700
+ * THE ONLY WRITER of the context gauge.
13701
+ *
13702
+ * Every source goes through here — a live `context` event, session restore, compaction, and the
13703
+ * initial load. Before this existed the gauge had seven assignment sites and two different formulas:
13704
+ * restore counted the messages alone while a live step counted messages PLUS the tool schemas, so the
13705
+ * number stepped by a fixed amount whenever the two paths alternated — a visible jump caused by two
13706
+ * formulas disagreeing, not by noise.
13707
+ *
13708
+ * Keeping one entry point is also what makes the number honest: there is exactly one definition of
13709
+ * "how much context is in use", so a future caller cannot invent a third.
13710
+ */
13711
+ function setCtxTokens(tokens, max) {
13712
+ const n = Math.max(0, Number(tokens) || 0);
13713
+ state.ctxTokens = n;
13714
+ if (Number.isFinite(max) && max > 0) state.ctxMax = max;
13715
+ state.ctxPercent = usagePercent(n, state.ctxMax || 1);
13716
+ // The trend /usage draws. Bounded, because a long session would otherwise grow it without limit.
13717
+ if (!Array.isArray(state.tokenHistory)) state.tokenHistory = [];
13718
+ const h = state.tokenHistory;
13719
+ if (h[h.length - 1] !== n) h.push(n);
13720
+ if (h.length > 240) h.splice(0, h.length - 240);
13721
+ // Mirrored on the session so saveSession captures it and --resume restores the chart.
13722
+ if (session) {
13723
+ session.ctxTokens = n;
13724
+ if (!Array.isArray(session.tokenHistory)) session.tokenHistory = [];
13725
+ if (session.tokenHistory[session.tokenHistory.length - 1] !== n) session.tokenHistory.push(n);
13726
+ if (session.tokenHistory.length > 240) session.tokenHistory.splice(0, session.tokenHistory.length - 240);
13727
+ }
13728
+ }
13729
+
12881
13730
 
12882
13731
  // FIRST: Ensure model is initialized before restoring session
12883
13732
  if (!cfg.model || !cfg.innerModel) {
@@ -12933,11 +13782,13 @@ export async function startTUI(opts) {
12933
13782
  return null;
12934
13783
  });
12935
13784
 
12936
- // Calculate initial context usage from loaded session
13785
+ // Calculate initial context usage from loaded session.
13786
+ //
13787
+ // Through `setCtxTokens` — this IS inside startTUI, where it is defined — and with the same formula as
13788
+ // a live step: messages PLUS the tool schemas. Counting the messages alone here made the gauge read a
13789
+ // different number on restore than on the first step.
12937
13790
  if (session && session.messages && session.messages.length) {
12938
- const approx = estimateMessagesTokens(session.messages, cfg);
12939
- state.ctxTokens = approx;
12940
- state.ctxPercent = usagePercent(approx, state.ctxMax);
13791
+ setCtxTokens(estimateMessagesTokens(session.messages, cfg) + (cfg.toolDefTokens || 0), state.ctxMax);
12941
13792
  }
12942
13793
 
12943
13794
  // Warm the models.dev catalogue in the background. It is what supplies PRICES
@@ -13382,6 +14233,18 @@ export async function startTUI(opts) {
13382
14233
  noticeTimer = setTimeout(() => { state.notice = ''; renderFrame(); }, 4000);
13383
14234
  }
13384
14235
 
14236
+ /**
14237
+ * Take the notice row down early.
14238
+ *
14239
+ * `notice` leaves it up for four seconds, which is right for a message about something that HAPPENED.
14240
+ * A hint tied to where the POINTER is has to go when the pointer moves away, or it stays up describing
14241
+ * a chip the pointer has left — and its timer would then blank a notice the user raised meanwhile.
14242
+ */
14243
+ function clearNotice() {
14244
+ if (noticeTimer) { clearTimeout(noticeTimer); noticeTimer = null; }
14245
+ if (state.notice) state.notice = '';
14246
+ }
14247
+
13385
14248
  function dims() {
13386
14249
  try {
13387
14250
  const [cols, rows] = stdout.getWindowSize();
@@ -13390,6 +14253,7 @@ export async function startTUI(opts) {
13390
14253
  }
13391
14254
  let lastFrame = null;
13392
14255
  let paintScheduled = false;
14256
+ let lastPaintAt = 0; // when the last frame actually went to the terminal
13393
14257
  // Deadline for the next FULL repaint. A differential painter compares against the frame
13394
14258
  // it BELIEVES it painted, never the real screen, so any divergence between the two is
13395
14259
  // permanent: it survives every later frame because the comparison keeps saying "that row
@@ -13424,6 +14288,7 @@ export async function startTUI(opts) {
13424
14288
  state._hitboxes = frame.hitboxes || [];
13425
14289
  state._composerMeta = frame.composerMeta || [];
13426
14290
  if (out) stdout.write(out);
14291
+ lastPaintAt = now;
13427
14292
  // "Painted" becomes observable here: everything that reaches the screen goes
13428
14293
  // through this one function, so the tick advancing means bytes left this process.
13429
14294
  // See src/flush-tick.js for what reads it.
@@ -13470,11 +14335,26 @@ export async function startTUI(opts) {
13470
14335
  */
13471
14336
  function countOutputTokens(n) { return sessHost.countOutputTokens(n); }
13472
14337
 
13473
- function renderSoon() {
13474
- if (paintScheduled) return;
13475
- paintScheduled = true;
13476
- setImmediate(paintNow);
13477
- }
14338
+ /**
14339
+ * The render rate this session is capped at, from `/settings` → Render FPS.
14340
+ *
14341
+ * Read on every call rather than cached: the setting can change mid-session, and a cached rate would
14342
+ * only take effect after a restart.
14343
+ */
14344
+ function renderFps() {
14345
+ const v = Number(cfg && cfg.render_fps);
14346
+ // A terminal cannot render at 0Hz; the lowest meaningful rate is one frame a second.
14347
+ return Number.isFinite(v) ? Math.max(1, Math.round(v)) : 30;
14348
+ }
14349
+
14350
+ function renderSoon() {
14351
+ if (paintScheduled) return;
14352
+ const budget = Math.max(1, Math.round(1000 / renderFps()));
14353
+ const wait = Math.max(0, (lastPaintAt || 0) + budget - Date.now());
14354
+ paintScheduled = true;
14355
+ if (wait === 0) { setImmediate(paintNow); return; }
14356
+ setTimeout(paintNow, wait);
14357
+ }
13478
14358
  function openPicker(p) {
13479
14359
  let footer = p.footer ? { focused: false, ...p.footer } : null;
13480
14360
  if (p.footerFor) {
@@ -13758,6 +14638,20 @@ export async function startTUI(opts) {
13758
14638
  * Not awaited by either caller: a teammate works in the background and reports by message,
13759
14639
  * which is the whole reason a team is more than a batch of subagents.
13760
14640
  */
14641
+ /**
14642
+ * Record a teammate's live action on the panel, if one is open.
14643
+ *
14644
+ * The panel is opened on demand, so most of the time there is nothing to write to and the stream is
14645
+ * dropped — that is the intended behaviour, not a loss: the work is in the member's own record and
14646
+ * the conversation is drawn on demand when the panel opens.
14647
+ */
14648
+ function teamPanelNoteActivity(stream) {
14649
+ if (!state.teamPanel) return;
14650
+ try {
14651
+ noteActivity(state.teamPanel, stream);
14652
+ } catch { /* a display hook must never fail a member's turn */ }
14653
+ }
14654
+
13761
14655
  async function spawnTeammates(teamName, members) {
13762
14656
  const { spawnMany } = await import('./team/spawn.js');
13763
14657
  const started = spawnMany(teamName, members, {
@@ -13768,6 +14662,61 @@ export async function startTUI(opts) {
13768
14662
  // panel, and so a failure is not silent.
13769
14663
  if (ev.type === 'start') notice(`Teammate ${ev.name} started (${ev.taskId}).`);
13770
14664
  if (ev.type === 'error') notice(`Teammate ${ev.name} failed: ${ev.error}`, 'error');
14665
+ // LIVE OUTPUT. Without this a teammate was a black box: the roster said idle→working→idle and
14666
+ // the person watching saw none of the work in between. The tool calls are the part that matters
14667
+ // most — a member that has been running the same command for a minute is different from one that
14668
+ // is idle, and the roster alone cannot show that.
14669
+ if (ev.type === 'stream') {
14670
+ teamPanelNoteActivity(ev.stream);
14671
+ renderSoon();
14672
+ }
14673
+ if (ev.type === 'tool') {
14674
+ teamPanelNoteActivity({ kind: 'tool', name: ev.name, tool: ev.tool });
14675
+ renderSoon();
14676
+ }
14677
+ renderSoon();
14678
+ },
14679
+ // A member's message to the lead is the CONVERSATION, not a status line — so it goes straight
14680
+ // into the transcript, in full, as it is sent.
14681
+ //
14682
+ // It used to go only to a file, and `formatLeadInbox` read that file at the START of a turn. The
14683
+ // effect was that an answer arrived only if the person happened to type something else first:
14684
+ // they would send an instruction, watch an empty screen, and conclude the team was dead while the
14685
+ // reply sat on disk. Nothing about that is fixable by hiding it better — the message IS what the
14686
+ // user asked to see.
14687
+ onLeadMail: (mail) => {
14688
+ // A PERMISSION REQUEST IS NOT CHAT. It becomes a prompt the person must answer; showing it as
14689
+ // transcript text would leave it looking like a teammate's remark, with a member blocked behind
14690
+ // it until its ten-minute timeout.
14691
+ const ask = asPermissionRequest(mail.text);
14692
+ if (ask) {
14693
+ if (state.teamPanel) {
14694
+ // The panel owns it while open: `y`/`n` there answer it.
14695
+ state.teamPanel.pendingPermission = ask;
14696
+ } else {
14697
+ // With no panel there is nowhere to press `y`, so it is said out loud. Silently accepting
14698
+ // would run the tool the person was never shown.
14699
+ notice(`${ask.agentId} needs permission for ${ask.toolName}: ${ask.description}`, 'error');
14700
+ try {
14701
+ answerPermission({
14702
+ teamName, memberName: ask.agentId, requestId: ask.requestId,
14703
+ approved: false, error: 'no team panel open to approve it',
14704
+ });
14705
+ } catch { /* best-effort */ }
14706
+ }
14707
+ renderSoon();
14708
+ return;
14709
+ }
14710
+ // Structured messages carry JSON, which is not worth showing a person: the tool that sent it has
14711
+ // already reported its own outcome.
14712
+ if (String(mail.text || '').trim().startsWith('{')) return;
14713
+ addChat({
14714
+ role: 'system',
14715
+ text: `[${mail.from} → team-lead] ${mail.text}`,
14716
+ });
14717
+ // Also announced, because the transcript may be scrolled away and a teammate answering is the
14718
+ // one event worth interrupting for.
14719
+ notice(`${mail.from}: ${String(mail.text).split('\n')[0].slice(0, 100)}`);
13771
14720
  renderSoon();
13772
14721
  },
13773
14722
  });
@@ -14005,6 +14954,105 @@ export async function startTUI(opts) {
14005
14954
  renderFrame();
14006
14955
  return;
14007
14956
  }
14957
+ if (res.action === 'permission') {
14958
+ // The answer goes to the member that asked, and the request is cleared only after the write
14959
+ // succeeds: a failure here would otherwise drop the prompt and leave the member blocking until its
14960
+ // ten-minute timeout, with nothing on screen to explain why.
14961
+ const req = p.pendingPermission;
14962
+ if (req) {
14963
+ try {
14964
+ answerPermission({
14965
+ teamName: p.teamName,
14966
+ memberName: req.agentId,
14967
+ requestId: req.requestId,
14968
+ approved: res.approved,
14969
+ error: res.approved ? undefined : 'denied by the user',
14970
+ });
14971
+ p.pendingPermission = null;
14972
+ p.flash = res.approved ? `allowed for ${req.agentId}` : `denied for ${req.agentId}`;
14973
+ setTimeout(() => { if (state.teamPanel === p) { p.flash = ''; renderFrame(); } }, 2500);
14974
+ } catch (e) {
14975
+ p.flash = `could not answer: ${(e && e.message) || e}`;
14976
+ }
14977
+ }
14978
+ renderFrame();
14979
+ return;
14980
+ }
14981
+
14982
+ // `k` — kill the selected member. The abort controller fires and the run unwinds.
14983
+ //
14984
+ // Distinct from closing the panel: that left the member working in the background, which is the one
14985
+ // thing a person watching a runaway agent most needs to stop.
14986
+ if (res.action === 'kill') {
14987
+ try {
14988
+ const stopped = stopTeammate(p.teamName, res.name);
14989
+ p.flash = stopped ? `killed ${res.name}` : `${res.name} was not running`;
14990
+ } catch (e) {
14991
+ p.flash = `could not kill: ${(e && e.message) || e}`;
14992
+ }
14993
+ setTimeout(() => { if (state.teamPanel === p) { p.flash = ''; renderFrame(); } }, 2500);
14994
+ renderFrame();
14995
+ return;
14996
+ }
14997
+
14998
+ // `s` — ask it to stop GRACEFULLY. It may refuse if it is mid-task, which is the reason this exists
14999
+ // beside `k`: killing a member halfway through a write leaves the file half-written.
15000
+ if (res.action === 'shutdown') {
15001
+ try {
15002
+ mailMod.writeToMailbox(p.teamName, res.name, {
15003
+ from: TEAM_LEAD_NAME,
15004
+ text: JSON.stringify(protocolMod.shutdownRequest({
15005
+ requestId: protocolMod.newRequestId('sd'),
15006
+ from: TEAM_LEAD_NAME,
15007
+ reason: 'shutdown requested from the team panel',
15008
+ })),
15009
+ });
15010
+ p.flash = `asked ${res.name} to stop`;
15011
+ } catch (e) {
15012
+ p.flash = `could not ask: ${(e && e.message) || e}`;
15013
+ }
15014
+ setTimeout(() => { if (state.teamPanel === p) { p.flash = ''; renderFrame(); } }, 2500);
15015
+ renderFrame();
15016
+ return;
15017
+ }
15018
+
15019
+ // `p` — prune every idle member. With five idle teammates the roster is mostly noise.
15020
+ if (res.action === 'prune') {
15021
+ const names = Array.isArray(res.names) ? res.names : [];
15022
+ if (!names.length) {
15023
+ p.flash = 'no idle members to prune';
15024
+ } else {
15025
+ try {
15026
+ let killed = 0;
15027
+ for (const n of names) if (stopTeammate(p.teamName, n)) killed++;
15028
+ p.flash = `pruned ${killed} idle member(s)`;
15029
+ } catch (e) {
15030
+ p.flash = `could not prune: ${(e && e.message) || e}`;
15031
+ }
15032
+ }
15033
+ setTimeout(() => { if (state.teamPanel === p) { p.flash = ''; renderFrame(); } }, 2500);
15034
+ renderFrame();
15035
+ return;
15036
+ }
15037
+
15038
+ // `m` — cycle the selected member's permission mode: ask → auto → yolo → ask.
15039
+ //
15040
+ // The mode is recorded on the roster so a respawn honours it; a running member keeps its current
15041
+ // setting until it restarts, since the hook is captured when the agent is built.
15042
+ if (res.action === 'cycleMode') {
15043
+ try {
15044
+ const order = ['ask', 'auto', 'yolo'];
15045
+ const cur = (teamMod.findMember(p.teamName, res.name) || {}).permissionMode || 'ask';
15046
+ const next = order[(order.indexOf(cur) + 1) % order.length];
15047
+ teamMod.setMemberMode(p.teamName, res.name, next);
15048
+ p.flash = `${res.name}: ${next}${next === 'ask' ? '' : ' (takes effect on restart)'}`;
15049
+ } catch (e) {
15050
+ p.flash = `could not set mode: ${(e && e.message) || e}`;
15051
+ }
15052
+ setTimeout(() => { if (state.teamPanel === p) { p.flash = ''; renderFrame(); } }, 3000);
15053
+ renderFrame();
15054
+ return;
15055
+ }
14008
15056
  renderFrame();
14009
15057
  }
14010
15058
 
@@ -14258,14 +15306,14 @@ export async function startTUI(opts) {
14258
15306
  return true;
14259
15307
  }
14260
15308
 
14261
- // Kill / abort one task by id. An agent task aborts through its controller; a
14262
- // process is killed as a tree (Windows) or by signal.
15309
+ // Kill / abort one task by id. An agent OR teammate task aborts through its
15310
+ // controller (`_abort`); a process is killed as a tree (Windows) or by signal.
14263
15311
  function stopTask(ctxLike, id) {
14264
15312
  if (!id) return;
14265
15313
  const tk = getTask(ctxLike, id) || (state.tasks || {})[id];
14266
15314
  if (!tk || tk.status !== 'running') return;
14267
15315
  try {
14268
- if (tk.kind === 'agent' && typeof tk._abort === 'function') {
15316
+ if (typeof tk._abort === 'function') {
14269
15317
  tk._abort();
14270
15318
  } else if (tk.pid) {
14271
15319
  if (process.platform === 'win32') {
@@ -14331,6 +15379,10 @@ export async function startTUI(opts) {
14331
15379
  // is the flash this ordering exists to prevent.
14332
15380
  stdout.write(alternateScreen(true));
14333
15381
  stdout.write(clearScreen());
15382
+ // From here the TUI owns stdout: every frame is painted as a whole screen, so a stray console.log from
15383
+ // anywhere — a plugin's load-time output, a stray debug line — writes over the frame. Plugins report
15384
+ // through the notice queue instead, which the TUI drains into its own transcript.
15385
+ try { setPluginLoggerTui(true); } catch { /* the logger is best-effort */ }
14334
15386
  // Adopt the session's title immediately. A RESUMED session already has one, and without
14335
15387
  // this the terminal kept whatever the shell had put there — the title only appeared once
14336
15388
  // something CHANGED it (`/title`, or a fresh auto-naming), so reopening a named session
@@ -14343,13 +15395,36 @@ export async function startTUI(opts) {
14343
15395
  // `caps.kitty === null` (nothing answered) keeps the old default of ON, so a
14344
15396
  // terminal that simply says nothing is treated exactly as before.
14345
15397
  if (process.env.HNCODE_NO_KITTY_KEYS !== '1' && kittyKeysUsable()) stdout.write('\x1b[>1u');
14346
- // Mouse modes, split so that an UNSUPPORTED one cannot take the others down:
14347
- // a terminal that rejects `?1003h` (any-motion — the least widely supported)
14348
- // would, in a single write, often drop click/drag too. Bracket-paste + SGR +
14349
- // click/drag/sgr go first (universally supported); any-motion (hover) last,
14350
- // and skippable via HNCODE_NO_MOUSE_HOVER=1 when a terminal chokes on it.
14351
- stdout.write('\x1b[?2004h\x1b[?1000h\x1b[?1002h\x1b[?1006h');
14352
- if (process.env.HNCODE_NO_MOUSE_HOVER !== '1') stdout.write('\x1b[?1003h');
15398
+ // Mouse modes, enabled per mode and only when the terminal said it KNOWS that mode.
15399
+ //
15400
+ // THE PROBE IS THE POINT. These sequences were written unconditionally, so a terminal that does not
15401
+ // implement one — or accepts it and never sends an event — left the application waiting for reports
15402
+ // that never come. The symptom is a UI whose keyboard works and whose mouse does nothing: clicks,
15403
+ // drags and the wheel all inert, with no error anywhere to explain it.
15404
+ //
15405
+ // `caps.mouseClick` and friends come from the DECRQM replies (`ESC[?1000$p` and so on). `undefined`
15406
+ // means the terminal did not answer, which is treated as YES: silence is not a refusal, and a
15407
+ // terminal that ignores DECRQM altogether has to keep working exactly as before. Only an explicit
15408
+ // "not recognised" (status 0) turns a mode off.
15409
+ //
15410
+ // HNCODE_NO_MOUSE_HOVER=1 drop any-motion regardless of the probe
15411
+ // HNCODE_NO_MOUSE=1 drop mouse reporting entirely, so the TERMINAL keeps the mouse: text
15412
+ // selection, the right-click menu and file drag-and-drop work again and
15413
+ // hncode's own click targets do not
15414
+ //
15415
+ // Bracket paste is NOT mouse reporting and stays on in every case — it is how a multi-line paste
15416
+ // arrives as one token.
15417
+ const mouseOff = process.env.HNCODE_NO_MOUSE === '1';
15418
+ const wants = (name, cap) => !mouseOff && cap !== false
15419
+ && !(name === 'hover' && process.env.HNCODE_NO_MOUSE_HOVER === '1');
15420
+ stdout.write('\x1b[?2004h');
15421
+ if (wants('click', caps.mouseClick) || wants('drag', caps.mouseDrag)) {
15422
+ // SGR (`?1006`) is the coordinate encoding for everything above; it goes with them.
15423
+ stdout.write('\x1b[?1006h');
15424
+ if (wants('click', caps.mouseClick)) stdout.write('\x1b[?1000h');
15425
+ if (wants('drag', caps.mouseDrag)) stdout.write('\x1b[?1002h');
15426
+ }
15427
+ if (wants('hover', caps.mouseHover)) stdout.write('\x1b[?1003h');
14353
15428
  // Start the PowerShell clipboard helper now: its cold start is seconds long,
14354
15429
  // and doing it lazily on the first Ctrl+Shift+V made the shortcut look dead.
14355
15430
  // Deferred by one turn of the event loop, though: `spawn` costs ~950ms here (the
@@ -14371,22 +15446,47 @@ export async function startTUI(opts) {
14371
15446
  // to the model on its next turn.
14372
15447
  if (typeof opts.mcpConnect === 'function') {
14373
15448
  Promise.resolve()
14374
- .then(() => opts.mcpConnect())
15449
+ .then(() => opts.mcpConnect({
15450
+ // Project-scope servers (the workspace's OWN .hncode/mcp.json) spawn
15451
+ // whatever command that file names, so they ask here before starting —
15452
+ // same promise-handshake as the tool-approval prompt. Esc refuses.
15453
+ approveProjectServer: (name, def) => new Promise((resolve) => {
15454
+ const target = def && def.url
15455
+ ? def.url
15456
+ : `${(def && def.command) || ''} ${((def && def.args) || []).join(' ')}`.trim();
15457
+ state.approvalPending = {
15458
+ toolName: 'MCP server',
15459
+ args: {},
15460
+ desc: `Start project MCP server "${name}" (${target})?`,
15461
+ detail: [
15462
+ 'This server is defined by THIS WORKSPACE (.hncode/mcp.json), not by you.',
15463
+ 'Approving runs its command on this machine. Esc refuses for this launch.',
15464
+ ],
15465
+ resolve,
15466
+ };
15467
+ renderSoon();
15468
+ }),
15469
+ }))
14375
15470
  .then((res) => {
14376
15471
  const servers = (res && res.servers) || [];
14377
15472
  if (!servers.length) return;
14378
15473
  // The notice line is a SINGLE status-line row, so report all servers in
14379
15474
  // one message — looping notice() per server only left the last one
14380
15475
  // visible. Failures are listed too, and the whole notice turns red when
14381
- // any server failed.
15476
+ // any server failed. Project servers the user refused are REPORTED, not
15477
+ // failures — they are a decision, not an error.
14382
15478
  const okServers = servers.filter((s) => s.ok);
14383
- const bad = servers.filter((s) => !s.ok);
15479
+ const skipped = servers.filter((s) => s.skipped);
15480
+ const bad = servers.filter((s) => !s.ok && !s.skipped);
14384
15481
  const parts = [];
14385
15482
  if (okServers.length) {
14386
15483
  const tools = okServers.reduce((n, s) => n + (s.toolCount || 0), 0);
14387
15484
  const names = okServers.map((s) => s.name).join(', ');
14388
15485
  parts.push(`MCP connected: ${names} (${tools} tool${tools === 1 ? '' : 's'})`);
14389
15486
  }
15487
+ if (skipped.length) {
15488
+ parts.push(`MCP not started (needs approval): ${skipped.map((s) => s.name).join(', ')}`);
15489
+ }
14390
15490
  if (bad.length) {
14391
15491
  parts.push(`MCP failed: ${bad.map((s) => s.name).join(', ')}`);
14392
15492
  }
@@ -14445,7 +15545,10 @@ export async function startTUI(opts) {
14445
15545
  // red phase. The SPINNER frame is derived with `% SPINNER.length` at the
14446
15546
  // call site instead.
14447
15547
  state.spin = (state.spin || 0) + 1;
14448
- renderFrame();
15548
+ // Coalesced like every other repaint trigger: a direct paintNow here ran a
15549
+ // full compose+diff on top of the 16ms stream ticks and the 33ms anim timer,
15550
+ // up to ~90 recomposes a second during a turn.
15551
+ renderSoon();
14449
15552
  }, 80);
14450
15553
 
14451
15554
  // Paced reveal tick. The 80ms spinner tick above is too coarse for this: at 12.5
@@ -14464,11 +15567,27 @@ export async function startTUI(opts) {
14464
15567
  }, STREAM_TICK_MS);
14465
15568
  if (streamTimer.unref) streamTimer.unref();
14466
15569
 
15570
+ // Animation tick: every animated row redraws at FRAME_MS.
15571
+ //
15572
+ // The 80ms spinner tick above is far too coarse for a light that must APPEAR to travel — at that rate
15573
+ // it lands a character apart and reads as cells being switched on one at a time, which is what the
15574
+ // crossing looked like before this existed. The rate lives in one named constant because the crossing
15575
+ // time is stated in frames: `CROSS_MS` at 30fps is six of them.
15576
+ //
15577
+ // It runs only while a turn is running, which is the only time the Working row or a pending tool row
15578
+ // can be on screen, so an idle session and a finished turn pay nothing. The light takes its phase from
15579
+ // the wall clock, so this timer does not advance any counter — it only asks for redraws.
15580
+ const animTimer = setInterval(() => {
15581
+ if (!state.running) return;
15582
+ renderSoon();
15583
+ }, FRAME_MS);
15584
+ if (animTimer.unref) animTimer.unref();
15585
+
14467
15586
  const TOK_WINDOW_MS = 1000;
14468
15587
  const tokTimer = setInterval(() => {
14469
15588
  const times = state._tokTimes;
14470
15589
  if (!times || !times.length) {
14471
- if (state.tokRate !== 0) { state.tokRate = 0; renderFrame(); }
15590
+ if (state.tokRate !== 0) { state.tokRate = 0; renderSoon(); }
14472
15591
  return;
14473
15592
  }
14474
15593
  const cutoff = Date.now() - TOK_WINDOW_MS;
@@ -14476,7 +15595,7 @@ export async function startTUI(opts) {
14476
15595
  while (drop < times.length && times[drop] < cutoff) drop++;
14477
15596
  if (drop) times.splice(0, drop);
14478
15597
  const rate = times.length;
14479
- if (rate !== state.tokRate) { state.tokRate = rate; renderFrame(); }
15598
+ if (rate !== state.tokRate) { state.tokRate = rate; renderSoon(); }
14480
15599
  }, 50);
14481
15600
 
14482
15601
  // ---- auto-update (background + SILENT, never blocks the TUI) --------------
@@ -14767,11 +15886,24 @@ export async function startTUI(opts) {
14767
15886
  const t = state._lastMouse;
14768
15887
  const clickCol = t ? (t.col || 1) - 1 : 1;
14769
15888
  // The column is measured from the row's PREFIX, because `composerTextIndexAt`
14770
- // subtracts `preWidth` itself. The row is ` 空│❯ text`: 0-based 0 is CPAD, 1 the wall,
15889
+ // subtracts `preWidth` itself. The row is ` |❯ text`: 0-based 0 is CPAD, 1 the wall,
14771
15890
  // 2-3 the `❯ ` prefix, and the text starts at 5. For text[i] the terminal column is
14772
15891
  // 6 + i (1-based), so `clickCol` is 5 + i and this must pass 2 + i — an offset of 3.
14773
15892
  const colInContent = Math.max(0, clickCol - 3);
14774
15893
  const idx = composerTextIndexAt(layout, hb.rowIdx, colInContent);
15894
+
15895
+ // CTRL+CLICK on a collapsed paste expands it in place.
15896
+ //
15897
+ // A marker is a chip: the whole `[paste #1 +12 lines]` stands in for content the user cannot see,
15898
+ // and until now the only way to read it back was to delete the chip. Ctrl+click is the gesture
15899
+ // that says "show me what this is" without changing what the message will contain — `expandPastes`
15900
+ // runs at submit either way, so the text that is SENT is identical.
15901
+ if (t && t.ctrl) {
15902
+ // `idx` is already the input index, so the helper does not convert again — see its comment.
15903
+ const expanded = expandPasteMarkerAt(state, idx);
15904
+ if (expanded) { renderFrame(); return true; }
15905
+ }
15906
+
14775
15907
  state.caret = idx;
14776
15908
  // Store mousedown position for potential drag selection in the composer.
14777
15909
  state._composerMouseDown = { row: hb.rowIdx, col: idx, caret: idx };
@@ -14858,8 +15990,8 @@ export async function startTUI(opts) {
14858
15990
  f.type = hb.option;
14859
15991
  // A click on the Type is the LAST decision in this form, so it submits — the
14860
15992
  // same thing Enter does on the Type row. Without this the click only selected
14861
- // the option and the user still had to reach for the keyboard, which is what
14862
- // "点对应的 type 就算完成" was about.
15993
+ // the option and the user still had to reach for the keyboard — clicking a choice
15994
+ // should complete it, the way pressing Enter on it would.
14863
15995
  if (f.typeClickSubmits) {
14864
15996
  const values = {};
14865
15997
  for (const fl of f.fields || []) values[fl.key] = String(fl.value || '').trim();
@@ -15039,8 +16171,33 @@ export async function startTUI(opts) {
15039
16171
  // The queue resize handle also gets a hover flag.
15040
16172
  const onQueueRule = !!(hb && hb.kind === 'queueResize');
15041
16173
  if (onQueueRule !== state.queueResizeHover) { state.queueResizeHover = onQueueRule; renderFrame(); }
16174
+
16175
+ // A COLLAPSED PASTE UNDER THE POINTER is highlighted and explained. This is the only hover cue in
16176
+ // the composer: the chip is otherwise drawn exactly like the text around it, so nothing says it can
16177
+ // be clicked at all, let alone with Ctrl.
16178
+ //
16179
+ // The index comes from the same conversion the Ctrl+click handler uses, so the chip that lights up
16180
+ // is always the chip that would expand.
16181
+ let pasteHover = null;
16182
+ if (hb && hb.kind === 'composerRow') {
16183
+ const { cols } = dims();
16184
+ const insideW = Math.max(0, cols - 2);
16185
+ const layout = composerLayout(state, insideW - 3);
16186
+ const clickCol = Math.max(0, (t.col || 1) - 1 - 3);
16187
+ pasteHover = pasteMarkerIdAt(state.input || '', composerTextIndexAt(layout, hb.rowIdx, clickCol));
16188
+ }
16189
+ if (pasteHover !== state.pasteHover) {
16190
+ state.pasteHover = pasteHover;
16191
+ // The notice line is the second half of the cue: the highlight says WHICH, the hint says WHAT a
16192
+ // click would do. It is cleared when the pointer leaves, so it never outlives the pointer being
16193
+ // on the chip.
16194
+ if (pasteHover != null) notice('Ctrl+Click to expand it', 'info');
16195
+ else clearNotice();
16196
+ renderFrame();
16197
+ }
16198
+
15042
16199
  // The composer rows stay click targets (mousedown places the caret), but
15043
- // they are NOT highlighted on hover — tinting the prompt you are typing
16200
+ // they are NOT otherwise highlighted on hover — tinting the prompt you are typing
15044
16201
  // is noise, not feedback.
15045
16202
  const next = (hb && hb.kind !== 'composerRow') ? { row: hb.row, col0: hb.col0, col1: hb.col1 } : null;
15046
16203
  const prev = state.hoverHit;
@@ -15189,16 +16346,34 @@ export async function startTUI(opts) {
15189
16346
  else if (r === end.row) { c0 = 0; c1 = end.col + 1; }
15190
16347
  else { c0 = 0; c1 = Infinity; }
15191
16348
 
15192
- const sliced = slicePlainByCol(plain, c0, c1);
16349
+ // COLUMN 0 IS NEVER PART OF THE SELECTION, exactly as in the highlight — see `highlightAnsiRange`.
16350
+ //
16351
+ // A row is drawn with one column of left margin, so a drag that starts at the left edge paints it
16352
+ // too, and copying a reply then puts a leading space on the clipboard on EVERY line. The highlight
16353
+ // has skipped it all along; the copy was the half that did not, which made the two disagree about
16354
+ // what "selected" meant — you could see it and then paste something wider.
16355
+ const sliced = slicePlainByCol(plain, Math.max(c0, 1), c1);
15193
16356
  const clean = sliced
15194
16357
  .replace(/<\/?thinking\b[^>]*>/gi, '')
15195
16358
  .replace(/<\/?think\b[^>]*>/gi, '');
15196
- out.push(clean.replace(/\s+$/, ''));
16359
+ // NO BLANKET TRAILING-WHITESPACE STRIP.
16360
+ //
16361
+ // It used to end with `.replace(/\s+$/, '')`, which made the copy disagree with the highlight: the
16362
+ // selection painted the space the pointer ended on, and the clipboard came back one character
16363
+ // shorter. Copying `foo ` gave `foo`, and a selection of nothing but spaces gave an empty line.
16364
+ //
16365
+ // What is highlighted is what gets copied — but only up to the selection's own end. A row is
16366
+ // right-padded to the terminal width, so slicing to the end of a row would otherwise carry that
16367
+ // padding onto the clipboard. `c1` is the bound, so the padding is already outside it.
16368
+ out.push(clean);
15197
16369
  }
15198
16370
  return out.join('\n');
15199
16371
  }
15200
16372
  function slicePlainByCol(text, c0, c1) {
15201
- if (c0 === 0 && c1 === Infinity) return text;
16373
+ // A full-row slice keeps its content but loses the RIGHT PADDING. The row is padded to the terminal
16374
+ // width, and `c1 === Infinity` means "to the end of the row" — copying that padding would put
16375
+ // invisible spaces on the clipboard that were never selected.
16376
+ if (c0 === 0 && c1 === Infinity) return text.replace(/\s+$/, '');
15202
16377
  let out = '';
15203
16378
  let col = 0;
15204
16379
  const s = String(text || '');
@@ -15269,10 +16444,10 @@ export async function startTUI(opts) {
15269
16444
  // Async so the notice can be painted BEFORE the clipboard read: a cold
15270
16445
  // PowerShell took ~3s, which read as "the shortcut does nothing".
15271
16446
  async function pasteFromClipboard(fastPathOnly = false) {
15272
- notice('Pasting…', 'info');
15273
- // One probe handles every clipboard shape: copied FILES (→ paths), plain
15274
- // TEXT (→ text), or an IMAGE (→ a temp .png path). The async version runs it
15275
- // on the PRE-WARMED helper (~10ms) instead of a cold spawn (~2.9s).
16447
+ // NO "Pasting…" FLASH. It was there to reassure during a cold PowerShell read (~3s), but the read is
16448
+ // now 7-12ms warm, so the notice did nothing except paint a frame, get replaced a moment later, and
16449
+ // make the paste feel like it was waiting for something. The text appears, or an error is reported —
16450
+ // both are visible without an intermediate state.
15276
16451
  const { text, via } = await readClipboardContentAsync();
15277
16452
  if (text) {
15278
16453
  // An image collapses to a `[paste #N image]` chip, the same way a multi-line
@@ -15524,7 +16699,7 @@ export async function startTUI(opts) {
15524
16699
  // swallowing them bought nothing and cost the one thing a long plan needs: a way to
15525
16700
  // read past the fold. Everything else still belongs to the review.
15526
16701
  if (t.key === 'wheelup' || t.key === 'wheeldown') {
15527
- scrollChat(state, t.key === 'wheelup' ? 3 : -3);
16702
+ scrollChat(state, t.key === 'wheelup' ? wheelStep(state, 'wheel') : -wheelStep(state, 'wheel'));
15528
16703
  renderFrame();
15529
16704
  return;
15530
16705
  }
@@ -15535,6 +16710,29 @@ export async function startTUI(opts) {
15535
16710
  }
15536
16711
  return; // swallow other keys while reviewing
15537
16712
  }
16713
+
16714
+ // ESC INTERRUPTS A RUNNING TURN.
16715
+ //
16716
+ // It did nothing during tool execution — only Ctrl+C interrupted, and Ctrl+C also arms the exit
16717
+ // prompt, so the one key a person reaches for when a command is taking too long was the one that
16718
+ // could quit the app instead. `interrupt()` aborts the tool (via `toolAbort`) and the model request,
16719
+ // which is exactly what the user means by pressing it.
16720
+ //
16721
+ // Placed AFTER the overlay branches above so Esc still closes a picker, a form, or an approval
16722
+ // prompt first — those own the keyboard while open, and interrupting the turn behind them would be
16723
+ // the wrong answer to "close this dialog".
16724
+ // Overlays own the keyboard while open, so Esc keeps its other job of closing them. The check is
16725
+ // inline rather than a named local because `test/esc-interrupt.test.mjs` slices this branch to verify
16726
+ // the exclusion list, and a local defined above the slice was invisible to it.
16727
+ if (t.key === 'escape' && state.running && state.agent
16728
+ && !state.menuOpen && !state.picker && !state.form && !state.panel
16729
+ && !state.question && !state.editor && !state.approvalPending) {
16730
+ try { state.agent.interrupt(); } catch { /* an already-finished turn */ }
16731
+ notice('Interrupted', 'info');
16732
+ renderFrame();
16733
+ return;
16734
+ }
16735
+
15538
16736
  if (t.key === 'c-c') {
15539
16737
  // An open overlay consumes Ctrl+C: close it instead of interrupting the
15540
16738
  // turn that is still running behind it. The modal editor belongs here too:
@@ -15724,7 +16922,7 @@ export async function startTUI(opts) {
15724
16922
  // answer, so "own ALL input" does not need them — and without this a long question
15725
16923
  // with an option list taller than the screen could not be read past at all.
15726
16924
  if (t.key === 'wheelup' || t.key === 'wheeldown') {
15727
- scrollChat(state, t.key === 'wheelup' ? 3 : -3);
16925
+ scrollChat(state, t.key === 'wheelup' ? wheelStep(state, 'wheel') : -wheelStep(state, 'wheel'));
15728
16926
  renderFrame();
15729
16927
  return;
15730
16928
  }
@@ -15746,7 +16944,7 @@ export async function startTUI(opts) {
15746
16944
  // its own arrow behaviour on the un-modified keys.
15747
16945
  if (t.key === 'c-up' || t.key === 'c-down') {
15748
16946
  if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
15749
- scrollChat(state, t.key === 'c-up' ? 3 : -3);
16947
+ scrollChat(state, t.key === 'c-up' ? wheelStep(state, "c") : -wheelStep(state, "c"));
15750
16948
  renderFrame();
15751
16949
  return;
15752
16950
  }
@@ -15755,6 +16953,21 @@ export async function startTUI(opts) {
15755
16953
  // Ctrl+L — expand / collapse the plugin log block(s). The last one is the
15756
16954
  // useful target (it is the most recent), but every pluginLog message is toggled
15757
16955
  // so a session with several reloads behaves predictably.
16956
+ if (t.key === 'alt-r') {
16957
+ // Only when nothing owns the keyboard: a picker or a dialog has its own meaning for R.
16958
+ if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
16959
+ const current = (cfg && cfg.provider) || '';
16960
+ if (!current) {
16961
+ notice('No provider selected — use /model to pick one.', 'error');
16962
+ } else {
16963
+ // Fire and forget: the notice reports the outcome, and blocking the key handler on a network
16964
+ // round-trip would freeze input for as long as the endpoint takes.
16965
+ void fetchAndRegisterModels(state, cfg, current, { notice: (m, k) => notice(m, k) });
16966
+ }
16967
+ renderFrame();
16968
+ return;
16969
+ }
16970
+ }
15758
16971
  if (t.key === 'c-l') {
15759
16972
  if (!(state.picker || state.form || state.panel || state.editor || state.question || state.menuOpen)) {
15760
16973
  const logs = (state.chat || []).filter((m) => m && m.pluginLog);
@@ -15803,6 +17016,33 @@ export async function startTUI(opts) {
15803
17016
  return;
15804
17017
  }
15805
17018
  }
17019
+ // Ctrl+A — SELECT ALL of the composer. An ordinary editor key, and its absence was conspicuous:
17020
+
17021
+ // Ctrl+A — SELECT ALL of the composer. An ordinary editor key, and its absence was conspicuous:
17022
+ // Ctrl+X, Ctrl+C and Ctrl+V all worked on a selection there was no keyboard way to make.
17023
+ //
17024
+ // Handled BEFORE the approval dialog's own `c-a` above, which is why that one tests
17025
+ // `state.approvalPending` first — while a dialog is open it owns the keyboard, and there `c-a` means
17026
+ // "approve for the rest of the session" rather than "select all".
17027
+ //
17028
+ // The selection uses the same `{anchor, head}` the mouse drag produces, so copy, cut and the
17029
+ // right-click menu treat it identically.
17030
+ if (t.key === 'c-a' && !state.approvalPending && !state.editor) {
17031
+ const cur = state.input || '';
17032
+ if (!cur) {
17033
+ // Nothing to select: say so rather than silently doing nothing, which reads as a dead key.
17034
+ notice('Nothing to select', 'error');
17035
+ renderFrame();
17036
+ return;
17037
+ }
17038
+ state.composerSel = { anchor: 0, head: cur.length };
17039
+ // The caret goes to the END, which is where an editor leaves it: the next typed character then
17040
+ // REPLACES the selection rather than being inserted at the start of it.
17041
+ state.caret = cur.length;
17042
+ refreshMenu(state);
17043
+ renderFrame();
17044
+ return;
17045
+ }
15806
17046
 
15807
17047
  // Ctrl+X / Ctrl+Shift+X — CUT. Removes the composer's selected text (or the
15808
17048
  // whole line when nothing is selected) and puts it on the clipboard, like a
@@ -15970,7 +17210,7 @@ export async function startTUI(opts) {
15970
17210
  renderFrame(); return;
15971
17211
  }
15972
17212
  if (t.key === 'wheelup' || t.key === 'wheeldown') {
15973
- scrollChat(state, t.key === 'wheelup' ? 3 : -3);
17213
+ scrollChat(state, t.key === 'wheelup' ? wheelStep(state, 'wheel') : -wheelStep(state, 'wheel'));
15974
17214
  renderFrame(); return;
15975
17215
  }
15976
17216
  if (t.key === 'up' || t.key === 'down') {
@@ -16451,12 +17691,13 @@ export async function startTUI(opts) {
16451
17691
  return;
16452
17692
  }
16453
17693
  if (t.key === 'wheelup' || t.key === 'wheeldown') {
16454
- // With the pointer ON the scrollbar, scroll one line per notch: the bar is
16455
- // the precision control, and 3-line steps there feel jumpy on a long
16456
- // transcript. Everywhere else keeps the usual 3-line step.
16457
- const onBar = !!(state._lastMouse && isOnScrollbar(state._lastMouse));
16458
- const step = onBar ? 1 : 3;
16459
- const d = t.key === 'wheelup' ? step : -step;
17694
+ // HOW HARD THE USER IS TURNING, from the interval between key events.
17695
+ //
17696
+ // One rate cannot serve both gestures: a fixed one row makes a fast spin crawl, a fixed three jumps
17697
+ // past what a careful reader wanted. The interval between events is the speed, and it is already
17698
+ // in the stream — no timer, no separate gesture detection. Slow: one row. Fast: up to eight.
17699
+ const step = wheelStep(state, "wheel");
17700
+ const d = t.key === 'wheelup' ? step : -step;
16460
17701
  if (state.panel) {
16461
17702
  const p = state.panel;
16462
17703
  p.top = Math.max(0, (p.top || 0) + (t.key === 'wheelup' ? -3 : 3));
@@ -16820,6 +18061,26 @@ export async function startTUI(opts) {
16820
18061
  // Read live, so a /external flip applies to the very next tool call.
16821
18062
  function externalAllowed() { return sessHost.externalAllowed(); }
16822
18063
 
18064
+ /**
18065
+ * How many rows one notch or keypress should move, from how fast they are arriving.
18066
+ *
18067
+ * Shared by the wheel and by Ctrl+Up/Down so the two cannot drift: they are the same gesture with
18068
+ * different hardware. The EVENT STREAM is the speed sensor — a wheel turned slowly sends notches far
18069
+ * apart and one spun sends them in a burst, and a key held down repeats at the OS repeat rate.
18070
+ *
18071
+ * `family` keeps the two clocks apart: alternating a wheel notch with a Ctrl+Up should not be read as one
18072
+ * burst of either. It also makes the FIRST event of a family one row — `Infinity` lands in the slowest
18073
+ * step, so nothing jumps on the first touch after a pause.
18074
+ */
18075
+ function wheelStep(state, family) {
18076
+ const now = Date.now();
18077
+ const at = state._stepAt || (state._stepAt = {});
18078
+ const since = at[family] ? now - at[family] : Infinity;
18079
+ at[family] = now;
18080
+ // 140ms is about the gap between deliberate notches; under 55ms they are arriving in a burst.
18081
+ return since > 140 ? 1 : since > 90 ? 2 : since > 55 ? 4 : 8;
18082
+ }
18083
+
16823
18084
  function scrollChat(state, delta) {
16824
18085
  const cols = dims().cols, rows = dims().rows;
16825
18086
  // Only the TOTAL row count is needed to clamp the scroll position. This used to
@@ -16854,6 +18115,48 @@ export async function startTUI(opts) {
16854
18115
  state.mentionOffset = 0;
16855
18116
  state.historyIdx = -1;
16856
18117
  if (!text) { renderFrame(); return; }
18118
+ // `@name message` sends STRAIGHT to a teammate, without the model.
18119
+ //
18120
+ // This is Claude Code's `parseDirectMemberMessage`, and the reason it exists: routing a one-line
18121
+ // instruction through the conversation costs a full model turn for the lead, and the lead may reword
18122
+ // it. When you already know who should do it and what to say, the mailbox is the shorter path — and
18123
+ // it is the only way to steer a member while the lead is mid-turn.
18124
+ if (text.startsWith('@')) {
18125
+ const m = /^@([\w-]+)\s+([\s\S]+)$/.exec(text);
18126
+ if (!m) {
18127
+ notice('Use `@name message` to send straight to a member.', 'error');
18128
+ renderFrame();
18129
+ return;
18130
+ }
18131
+ const [, who, body] = m;
18132
+ const teamName = leadTeam();
18133
+ if (!teamName) {
18134
+ notice('No team. Create one: /team create <name>', 'error');
18135
+ renderFrame();
18136
+ return;
18137
+ }
18138
+ const roster = teamMod.readRoster(teamName);
18139
+ const names = String(roster || '').split('\n').map((l) => l.trim().split(/\s+/)[0]).filter(Boolean);
18140
+ // The user is not on the roster, so `@team-lead` would look unknown — it is accepted because a
18141
+ // member's own sends use that alias and it is the box the person reads.
18142
+ if (!names.includes(who) && who !== TEAM_LEAD_NAME) {
18143
+ notice(`No member "${who}". On this team: ${names.join(', ') || '(nobody)'}`, 'error');
18144
+ renderFrame();
18145
+ return;
18146
+ }
18147
+ try {
18148
+ mailMod.writeToMailbox(teamName, who, { from: TEAM_LEAD_NAME, text: body.trim() });
18149
+ // Shown in the transcript so the instruction is part of the record: a message that only exists in
18150
+ // someone's inbox is one the person cannot see themselves having sent.
18151
+ addChat({ role: 'system', text: `[you → ${who}] ${body.trim()}` });
18152
+ notice(`sent to ${who}`);
18153
+ } catch (e) {
18154
+ notice(`could not send: ${(e && e.message) || e}`, 'error');
18155
+ }
18156
+ renderFrame();
18157
+ return;
18158
+ }
18159
+
16857
18160
  if (!state.running) {
16858
18161
  state.turnStart = Date.now();
16859
18162
  // The turn's own output tokens, summed across its requests. Reset HERE so the
@@ -16868,7 +18171,7 @@ export async function startTUI(opts) {
16868
18171
  // spinner happens to be in when the start animation ends.
16869
18172
  state.pulseStart = null;
16870
18173
  }
16871
-
18174
+
16872
18175
  if (state.history[state.history.length - 1] !== text) {
16873
18176
  state.history.push(text);
16874
18177
  saveHistory(state.history); // persist so ↑ survives a restart
@@ -17031,7 +18334,48 @@ export async function startTUI(opts) {
17031
18334
  // calls, which is what keeps the two front-ends behaviourally identical.
17032
18335
  // Straight through to the core. It validates and throws in the same shape as before, so
17033
18336
  // the HTTP layer (which maps a throw to 409) is unaffected.
17034
- hub.actions.submit = (text) => core.submit(text);
18337
+ // `opts.new` starts a FRESH session before the prompt runs.
18338
+ //
18339
+ // The caller does not know the new session's id in advance, so asking them to create one first
18340
+ // means two requests and a round trip to learn an id that this side invents anyway. Passing
18341
+ // `new: true` lets the daemon accept a prompt against any session URL and have it run in a new
18342
+ // session instead — the same thing `/new` does, minus the slash command.
18343
+ hub.actions.submit = async (text, opts) => {
18344
+ if (opts && opts.new) {
18345
+ // Flush the outgoing session first: its todos and transcript belong to the id being replaced,
18346
+ // and `dispatch` does exactly what `/new` does, so the two cannot drift apart.
18347
+ await core.dispatch('new', '');
18348
+ // The hub's rows and event ring belong to the session that just ended. Clearing them keeps a
18349
+ // viewer from showing the previous conversation under the new id.
18350
+ hub.rows = [];
18351
+ hub.events = [];
18352
+ }
18353
+ // `core.submit` AWAITS THE WHOLE TURN, so its result is the finished turn — no polling needed.
18354
+ const out = await core.submit(text);
18355
+ // `opts.return` asks for the transcript of what just ran:
18356
+ //
18357
+ // "turn" the rows of this turn, tool results included
18358
+ // "full" every row the session holds
18359
+ // "none" (default) whatever `core.submit` returned, unchanged
18360
+ //
18361
+ // Without this a caller had to submit, wait an unknown time, then issue a second request for
18362
+ // `/api/state` — two round trips and a guess at how long the turn would take.
18363
+ const want = opts && opts.return;
18364
+ if (!want || want === 'none') return out;
18365
+ // FORCE ONE SYNC: `syncChat` does nothing while no browser is subscribed, so `hub.rows` would be
18366
+ // empty here and the caller would get an empty transcript from a turn that plainly ran.
18367
+ try { hub.syncChat(state.chat || []); } catch { /* the reply still carries `result` */ }
18368
+ // The turn begins at the last user row; everything after it is what the prompt produced.
18369
+ const rows = Array.isArray(hub.rows) ? hub.rows : [];
18370
+ let from = 0;
18371
+ for (let k = rows.length - 1; k >= 0; k--) { if (rows[k] && rows[k].role === 'user') { from = k; break; } }
18372
+ const slice = want === 'full' ? rows : rows.slice(from);
18373
+ const detail = (opts && opts.detail) || 'full';
18374
+ const shaped = detail === 'minimal' ? slice.filter((r) => r.role !== 'tool')
18375
+ : detail === 'summary' ? slice.map((r) => (r.role === 'tool' ? { ...r, text: '', liveOutput: '', diff: undefined } : r))
18376
+ : slice;
18377
+ return { result: out, rows: shaped, detail, sessionId: session && session.id };
18378
+ };
17035
18379
  hub.actions.dispatch = async (cmd, arg) => {
17036
18380
  const out = await core.dispatch(cmd, arg);
17037
18381
  // The core runs the command; the repaint is this front end's business, so it stays
@@ -17808,8 +19152,10 @@ export async function startTUI(opts) {
17808
19152
  }
17809
19153
 
17810
19154
  // The one-shot namer. Sentence-case, 3-7 words, with worked examples — a bare
17811
- // "at most 50 characters" let the model echo "你好" back as a title for a
17812
- // greeting, which is not a name anyone can find in a list.
19155
+ // "at most 50 characters" let the model echo a bare greeting back as a title,
19156
+ // which is not a name anyone can find in a list. The Chinese example is
19157
+ // deliberate: in a Chinese conversation the failing case IS that greeting, and
19158
+ // an English-only example did not stop it.
17813
19159
  const TITLE_PROMPT = 'You name coding-agent sessions. Reply with ONLY the title: one line, 3 to 7 words, sentence case (capitalize only the first word and proper nouns), at most 50 characters. No quotes, no trailing punctuation, no "Title:" label, no explanation.\n'
17814
19160
  + '\n'
17815
19161
  + 'Capture the main topic or goal so the user recognises the session in a list.\n'
@@ -17867,6 +19213,16 @@ export async function startTUI(opts) {
17867
19213
  if (spinTimer) clearInterval(spinTimer);
17868
19214
  if (streamTimer) clearInterval(streamTimer);
17869
19215
  if (tokTimer) clearInterval(tokTimer);
19216
+ // The rest of the intervals created by startTUI. Leaving any of them armed
19217
+ // kept the process alive — and worse, `animTimer` kept PAINTING after the
19218
+ // alternate screen was released (diff frames onto the primary screen), and
19219
+ // `gitTimer`/`autoCheckTimer` could spawn a child during shutdown.
19220
+ if (gitTimer) clearInterval(gitTimer);
19221
+ if (scheduleTimer) clearInterval(scheduleTimer);
19222
+ if (animTimer) clearInterval(animTimer);
19223
+ if (autoCheckTimer) clearInterval(autoCheckTimer);
19224
+ if (noticeTimer) clearTimeout(noticeTimer);
19225
+ if (escTimer) clearTimeout(escTimer);
17870
19226
  if (confirmTimer) clearTimeout(confirmTimer);
17871
19227
  // The /tasks panel owns a flash timer; leaving it armed keeps the process
17872
19228
  // alive until it fires after exit.
@@ -17882,6 +19238,9 @@ export async function startTUI(opts) {
17882
19238
  stdout.write('\x1b[<u\x1b[?2004l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l');
17883
19239
  stdout.write(alternateScreen(false));
17884
19240
  stdout.write(showCursor());
19241
+ // The TUI no longer owns stdout, so plugin logging goes back to the console. Without this a later
19242
+ // load — a second /reload, or a command run after the screen is released — would be swallowed.
19243
+ try { setPluginLoggerTui(false); } catch { /* the logger is best-effort */ }
17885
19244
  const id = session && session.id;
17886
19245
  const work = session && session.workspace;
17887
19246
  if (id) {
@@ -17913,6 +19272,10 @@ function quit() {
17913
19272
  bye();
17914
19273
  }
17915
19274
  process.on('SIGWINCH', () => renderFrame());
19275
+ // Windows never delivers SIGWINCH — Node reports tty resizes there (and on
19276
+ // every platform) as the stream's own 'resize' event. Without this a terminal
19277
+ // resize left the screen stale until an unrelated repaint happened to fire.
19278
+ if (process.stdout.isTTY) process.stdout.on('resize', () => renderFrame());
17916
19279
  process.on('SIGINT', () => quit());
17917
19280
 
17918
19281
  /**
@@ -17983,6 +19346,18 @@ function quit() {
17983
19346
  // delete them here so they cannot pile up in the temp dir. Synchronous, and it
17984
19347
  // runs on the graceful AND the crash path because 'exit' always fires.
17985
19348
  try { cleanupPastedImages(); } catch { /* never block the exit */ }
19349
+ // The clipboard helper is a PowerShell child. Without this it would outlive the TUI, and worse, a
19350
+ // helper that exits during shutdown would be respawned by the keep-alive in dropHelper.
19351
+ try { stopClipboardHelper(); } catch { /* never block the exit */ }
19352
+ // Language servers and MCP stdio children outlive the process unless they are
19353
+ // killed here — the exit handler cannot await the polite shutdown handshake,
19354
+ // so they are killed outright. Each can hold hundreds of MB.
19355
+ try { killLspClientsSync(); } catch { /* never block the exit */ }
19356
+ try {
19357
+ for (const c of getLiveConnections()) {
19358
+ if (c && c.transport && c.transport.child) { try { c.transport.child.kill(); } catch { /* gone */ } }
19359
+ }
19360
+ } catch { /* never block the exit */ }
17986
19361
  noteExit(`exit code=${code}`);
17987
19362
  if (stopped) return;
17988
19363
  try { stdout.write('\x1b[<u\x1b[?2004l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l' + alternateScreen(false) + showCursor()); } catch {}