@hncode/hncode 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/agent.js +172 -19
- package/src/cache.js +175 -130
- package/src/clipboard.js +69 -2
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +54 -6
- package/src/mcp.js +419 -384
- package/src/plugin.js +95 -10
- package/src/remote.js +307 -289
- package/src/shell-safety.js +610 -562
- package/src/swarm-progress.js +1 -1
- package/src/team/mailbox.js +216 -139
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +127 -12
- package/src/team/spawn.js +63 -5
- package/src/team/store.js +31 -4
- package/src/team/tasks.js +15 -2
- package/src/team/team.js +51 -16
- package/src/team-panel.js +418 -20
- package/src/term-caps.js +457 -410
- package/src/term.js +142 -26
- package/src/tool-result.js +50 -2
- package/src/tools/agent.js +464 -436
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +813 -590
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +17 -3
- package/src/tools/read.js +2 -1
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +13 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +88 -88
- package/src/tui.js +1815 -628
- package/src/update.js +38 -3
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
|
|
|
@@ -588,6 +592,352 @@ export function sweepSettle(dist, half, edge = 'cosine') {
|
|
|
588
592
|
return edge === 'linear' ? x : 0.5 * (1 - Math.cos(Math.PI * x));
|
|
589
593
|
}
|
|
590
594
|
|
|
595
|
+
/**
|
|
596
|
+
* A sweep that PASSES: a character lights up under the band and returns to 0 after it.
|
|
597
|
+
*
|
|
598
|
+
* The counterpart to `sweepSettle`, and the difference is the whole point:
|
|
599
|
+
*
|
|
600
|
+
* sweepSettle a character the band has passed STAYS at 1 — paint being laid down
|
|
601
|
+
* sweepPulse a character returns to 0 once the band leaves — a light moving over
|
|
602
|
+
*
|
|
603
|
+
* `half` is the HALF-width of the light, so `2 * half` is its full width, and `flat` is the half-width
|
|
604
|
+
* of its FULLY LIT CORE. The shape is:
|
|
605
|
+
*
|
|
606
|
+
* |dist| <= flat the core, at 1
|
|
607
|
+
* flat < |dist| < half a shoulder, falling from 1 to 0
|
|
608
|
+
*
|
|
609
|
+
* Both outer ends reach 0, which is what makes the light arrive and leave without a seam. `flat = 0`
|
|
610
|
+
* ramps across the whole width and leaves a single bright point in the middle; `flat = half` is a solid
|
|
611
|
+
* block with no fade at all.
|
|
612
|
+
*
|
|
613
|
+
* `dist` is `head - position`, the same convention `sweepSettle` uses.
|
|
614
|
+
*/
|
|
615
|
+
export function sweepPulse(dist, half, edge = 'cosine', flat = 0) {
|
|
616
|
+
const h = Math.max(1e-6, half);
|
|
617
|
+
if (dist <= -h || dist >= h) return 0; // outside the light
|
|
618
|
+
// A FLAT CORE: the middle `2 * flat` of the light is fully lit, and the cosine shapes only the
|
|
619
|
+
// shoulders either side of it. `flat` is a half-width, so `flat = h` is the old behaviour — a ramp
|
|
620
|
+
// across the whole light with a single bright point in the middle.
|
|
621
|
+
const core = Math.max(0, Math.min(h, flat));
|
|
622
|
+
const a = Math.abs(dist);
|
|
623
|
+
if (a <= core) return 1;
|
|
624
|
+
const x = (a - core) / (h - core); // 0 where the core ends, 1 at the outer edge
|
|
625
|
+
if (edge === 'linear') return 1 - x;
|
|
626
|
+
return 0.5 * (1 + Math.cos(Math.PI * x)); // raised cosine from 1 down to 0
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* The geometry of a passing light, shared by the Working row and the Using row.
|
|
631
|
+
*
|
|
632
|
+
* Both rows show the same gesture — a light running left to right, then a pause — so the numbers live
|
|
633
|
+
* here rather than being repeated at each row and drifting apart.
|
|
634
|
+
*
|
|
635
|
+
* The light is a QUARTER OF THE ROW at full brightness, with a cosine fade either side, and it scales
|
|
636
|
+
* with the text: `Working...` is ten characters, so its core is two; a 60-character line gets fifteen.
|
|
637
|
+
*
|
|
638
|
+
* It crosses in a fixed `CROSS_MS` whatever the row, so a long line is swept faster — see that constant
|
|
639
|
+
* for why, and for what it costs.
|
|
640
|
+
*/
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* The light's CORE width as a fraction of the row, floored to one character.
|
|
644
|
+
*
|
|
645
|
+
* Claude Code's shimmer has no core at all — a single index is bright. The floor of 1 is the same idea: a
|
|
646
|
+
* glint is a point travelling along the row, not a wash sweeping over it. It stops at one rather than
|
|
647
|
+
* shrinking to zero because a two-character row still needs something to see.
|
|
648
|
+
*/
|
|
649
|
+
export const LIGHT_FRACTION = 1 / 4;
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Milliseconds per unit of `state.spin`.
|
|
653
|
+
*
|
|
654
|
+
* The spinner advances `spin` every 80ms (see the spinTimer in startTUI). The light's geometry is in
|
|
655
|
+
* real time, so a caller holding only a tick count needs this to convert — named here so the two
|
|
656
|
+
* cannot drift apart silently: the timer and this constant describe the same interval.
|
|
657
|
+
*/
|
|
658
|
+
export const SPIN_TICK_MS = 80;
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* The light's FULLY LIT CORE, in characters: a quarter of the row, rounded down.
|
|
662
|
+
*
|
|
663
|
+
* Rounding down is deliberate: `Working...` is ten characters, so its core is two. Rounding up would make
|
|
664
|
+
* it grow on rows where it should not, and a fractional width is something no terminal draws.
|
|
665
|
+
*
|
|
666
|
+
* The floor of 2 keeps a short row visible — a three-character row would otherwise round to one, and a
|
|
667
|
+
* one-character core with a fade either side is a lit cell that barely registers.
|
|
668
|
+
*
|
|
669
|
+
* This is the core, NOT the total extent: `TAIL_CHARS` of cosine fade is added either side of it.
|
|
670
|
+
*/
|
|
671
|
+
export function lightWidth(n) {
|
|
672
|
+
return Math.max(2, Math.floor(n * LIGHT_FRACTION));
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* The fade either side of the core, in characters.
|
|
676
|
+
* TWO, not one: a band of one character is wide enough in truecolor but vanishes under 256 colours, where
|
|
677
|
+
* the light has only two indices and a single lit cell is a blink. Two keeps the sweep legible in both
|
|
678
|
+
* palettes: the peak moves one letter at a time, and the row reads as a lamp travelling rather than as a
|
|
679
|
+
* single pixel flashing.
|
|
680
|
+
*/
|
|
681
|
+
export const TAIL_CHARS = 2;
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The light's half-width, INCLUDING the fade. This is the extent `lightAt` sweeps.
|
|
686
|
+
*
|
|
687
|
+
* THE TAILS ARE CAPPED so the light stays narrower than the row: the total is held to `n - 1`, which
|
|
688
|
+
* keeps at least one dark column at each end. Those dark columns are what make the light read as an
|
|
689
|
+
* object with edges instead of as a row that brightens and dims.
|
|
690
|
+
*
|
|
691
|
+
* The CORE gives way first — `coreHalf` shrinks with the cap — because the fade is what makes the light
|
|
692
|
+
* arrive and leave smoothly, and a short row needs that more than it needs a wide middle.
|
|
693
|
+
*/
|
|
694
|
+
function totalHalf(n) {
|
|
695
|
+
const want = lightWidth(n) + 2 * TAIL_CHARS;
|
|
696
|
+
const capped = Math.min(want, Math.max(2, n - 1));
|
|
697
|
+
return capped / 2;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** The half-width of the fully lit core, for `sweepPulse`'s `flat` parameter. */
|
|
701
|
+
function coreHalf(n) {
|
|
702
|
+
// Never wider than the light itself, and never more than half the total: `sweepPulse` requires it.
|
|
703
|
+
return Math.min(lightWidth(n), totalHalf(n) * 2) / 2;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* The animation's frame rate: 30fps, the whole program's rate for animated rows.
|
|
708
|
+
*
|
|
709
|
+
* Rendering one frame measures 0.17ms, so a much higher rate is physically possible — the earlier tick
|
|
710
|
+
* ran at 60fps — but the light's smoothness is not the only cost: every animated row redraws, and the
|
|
711
|
+
* rate is a choice about the whole UI rather than about this one light.
|
|
712
|
+
*
|
|
713
|
+
* At 30fps a frame is 33ms, which is what makes the crossing in `CROSS_MS` six frames long.
|
|
714
|
+
*/
|
|
715
|
+
export const ANIM_FPS = 30;
|
|
716
|
+
export const FRAME_MS = 1000 / ANIM_FPS;
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* How long one step of the light lasts, in milliseconds.
|
|
720
|
+
*
|
|
721
|
+
* Claude Code runs two rates — `glimmerSpeed` is 50ms while a request is in flight and 200ms otherwise —
|
|
722
|
+
* but only ONE of them is ever used here: both animated rows (Working, and a pending tool's name) are
|
|
723
|
+
* shown while something is happening, so both take the fast rate. A second, unused rate was carried for a
|
|
724
|
+
* while "for parity"; it was dead weight, and it made every timing assertion twice as long.
|
|
725
|
+
*/
|
|
726
|
+
export const STEP_MS = 50;
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* How far outside the row the light starts and ends, in characters.
|
|
733
|
+
*
|
|
734
|
+
* TEN, exactly as Claude Code's `messageWidth + 20` splits into ten either side. This is what sets the
|
|
735
|
+
* pace of a round: a 10-character row travels 30 cells, so a round is 1500ms — the same as Claude Code.
|
|
736
|
+
*
|
|
737
|
+
* A shorter margin made the sweep finish sooner and read as a twitch. It must not be shorter than the
|
|
738
|
+
* light's own half-width either, or the light would still be lighting the last characters when the round
|
|
739
|
+
* ended; `lightMargin` takes the larger of the two.
|
|
740
|
+
*/
|
|
741
|
+
const ENTRY_EXIT_CHARS = 10;
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* The margin for a row of `n` characters: the target ten, or the light's half-width if that is larger.
|
|
745
|
+
*
|
|
746
|
+
* One function so `lightAt` and `crossMs` cannot disagree about how far the light travels. When they did,
|
|
747
|
+
* the sweep finished before the light had left the row and the tail stayed lit through the rest.
|
|
748
|
+
*/
|
|
749
|
+
function lightMargin(n) {
|
|
750
|
+
return Math.max(ENTRY_EXIT_CHARS, totalHalf(Math.max(1, n)));
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* The crossing time for a row of `count` characters — ONE FULL ROUND, entry and exit included.
|
|
755
|
+
*
|
|
756
|
+
* `(count + 2 * lightMargin(count)) * stepMs`. The light is over the visible text for only `count * stepMs`
|
|
757
|
+
* of it; the rest is the entry and exit runs, which is what keeps the sweep from snapping on and off.
|
|
758
|
+
*/
|
|
759
|
+
export function crossMs(count) {
|
|
760
|
+
const cells = Math.max(1, count) + 2 * lightMargin(count);
|
|
761
|
+
return cells * STEP_MS;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* The rest between rounds, in milliseconds.
|
|
766
|
+
*
|
|
767
|
+
* NOT zero. With `REST_MS = 0` the sweep wrapped straight back to the start the instant it left the right
|
|
768
|
+
* edge, so the row went from "light on the last letter" to "light on nothing" to "light on the first
|
|
769
|
+
* letter" within two frames — which reads as the animation resetting rather than restarting.
|
|
770
|
+
*
|
|
771
|
+
* Claude Code can use zero because its entry run is ten cells: the light is already off the row for a
|
|
772
|
+
* second before it comes back. Ours enters from three cells away, too short to cover the wrap.
|
|
773
|
+
*/
|
|
774
|
+
export const REST_MS = 250;
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* The full cycle for a row: one crossing, then the rest.
|
|
778
|
+
*
|
|
779
|
+
* `count` enters into it: a longer row has more characters to light, so its cycle is longer. It used to be
|
|
780
|
+
* constant, because a fixed-time crossing spread itself over however many cells there happened to be.
|
|
781
|
+
*/
|
|
782
|
+
export function cycleMs(count) {
|
|
783
|
+
return crossMs(count) + REST_MS;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* The wall-clock time the light's animation began, for a row anchored at this tick.
|
|
788
|
+
*
|
|
789
|
+
* `spin` advances every SPIN_TICK_MS while the render path redraws far more often, so a phase taken
|
|
790
|
+
* straight from the tick count steps in 80ms jumps. Converting the anchor ONCE to a wall-clock origin
|
|
791
|
+
* lets every later frame measure real elapsed time instead.
|
|
792
|
+
*
|
|
793
|
+
* The store is MODULE-LEVEL, and deliberately not a field on `state`. The caller for a pending tool
|
|
794
|
+
* row is `formatToolLine`, a module-level function with no access to `state` — reading it there threw
|
|
795
|
+
* "state is not defined" and took the whole TUI down through the unhandledRejection handler. The key
|
|
796
|
+
* is the anchor tick, so a new turn gets a new origin.
|
|
797
|
+
*/
|
|
798
|
+
const lightOrigins = new Map();
|
|
799
|
+
/**
|
|
800
|
+
* @param {string} key identifies the animation — `tool:12`, `work:12` — so two rows anchored on
|
|
801
|
+
* the same tick keep separate origins.
|
|
802
|
+
* @param {number} anchor the tick the animation began on.
|
|
803
|
+
* @param {number} now the tick it is being drawn at.
|
|
804
|
+
*
|
|
805
|
+
* `key` and `anchor` are separate arguments on purpose. An earlier version took only the anchor and
|
|
806
|
+
* derived the key from it, so a caller passing an already-prefixed `tool:0` produced
|
|
807
|
+
* `(0 - 'tool:0') * 80` — arithmetic on a string, which is NaN. The origin then became NaN, every
|
|
808
|
+
* colour became `38;5;NaN`, and the terminal drew the row in the default colour: a silent failure
|
|
809
|
+
* that looked like the animation simply not running.
|
|
810
|
+
*/
|
|
811
|
+
function lightOrigin(key, anchor, now) {
|
|
812
|
+
let origin = lightOrigins.get(key);
|
|
813
|
+
if (origin === undefined) {
|
|
814
|
+
// THE ORIGIN IS "NOW", NOT BACK-DATED FROM THE TICK COUNT.
|
|
815
|
+
//
|
|
816
|
+
// This used to be `Date.now() - (now - anchor) * SPIN_TICK_MS`, so a row that began mid-animation
|
|
817
|
+
// picked up the phase its anchor implied. That is wrong for this row: the first frame it is DRAWN is
|
|
818
|
+
// not the tick its anchor was set on — the start animation runs first, several ticks earlier. Back-
|
|
819
|
+
// dating by that gap made the sweep begin part-way across, so the light appeared to jump forward and
|
|
820
|
+
// then restart from the left.
|
|
821
|
+
//
|
|
822
|
+
// Starting at "now" means a new animation always begins at the left edge.
|
|
823
|
+
//
|
|
824
|
+
// `anchor` and `now` stay in the signature because callers pass them and the shape is part of the
|
|
825
|
+
// contract; the phase no longer depends on their difference.
|
|
826
|
+
void anchor;
|
|
827
|
+
void now;
|
|
828
|
+
origin = Date.now();
|
|
829
|
+
if (!Number.isFinite(origin)) origin = Date.now();
|
|
830
|
+
// EVICT THE OLDEST, never clear the map.
|
|
831
|
+
//
|
|
832
|
+
// This used to be `if (size > 32) clear()`. The anchor changes every turn and for every restored tool
|
|
833
|
+
// row, so the map filled quickly — and clearing it dropped the origin of the row currently on screen,
|
|
834
|
+
// which was then recreated with a FRESH timestamp. The light restarted from the left edge whenever the
|
|
835
|
+
// table filled, roughly once a second: the sweep looked cut off mid-stroke rather than moving.
|
|
836
|
+
//
|
|
837
|
+
// A Map iterates in insertion order, so the first key is the least recently added — exactly the entry
|
|
838
|
+
// no row is asking about any more.
|
|
839
|
+
if (lightOrigins.size > 64) {
|
|
840
|
+
const oldest = lightOrigins.keys().next().value;
|
|
841
|
+
lightOrigins.delete(oldest);
|
|
842
|
+
}
|
|
843
|
+
lightOrigins.set(key, origin);
|
|
844
|
+
}
|
|
845
|
+
return origin;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* How lit one character is, given its index and the row length.
|
|
850
|
+
*
|
|
851
|
+
* Returns 0..1. The position arrives as an INDEX and the row length rather than a pre-divided
|
|
852
|
+
* fraction, because the light's width is itself a fraction of the row — both are then in the same
|
|
853
|
+
* unit of "characters", so the light cannot be scaled twice by accident.
|
|
854
|
+
*
|
|
855
|
+
* `phaseMs` is MILLISECONDS within the cycle, not a tick count.
|
|
856
|
+
*
|
|
857
|
+
* Time is the right unit because the light's smoothness depends on how far it moves per RENDERED
|
|
858
|
+
* frame, and the frame rate is not the caller's 80ms spinner tick. Stepping by that tick means the
|
|
859
|
+
* crossing has to finish in a whole number of 80ms frames, so keeping each step small enough to look
|
|
860
|
+
* smooth forced a many-second crossing on a long row — measured, 12 seconds for 60 characters, and
|
|
861
|
+
* 1.9 seconds for `Working...`. Taking milliseconds decouples the two: the caller may redraw as often
|
|
862
|
+
* as it likes, and the light's speed is stated directly rather than implied by a frame count.
|
|
863
|
+
*/
|
|
864
|
+
export function lightAt(phaseMs, index, count, edge = 'cosine') {
|
|
865
|
+
const n = Math.max(1, count);
|
|
866
|
+
// Two widths, and the distinction is the shape of the light: `half` is the OUTER edge (core plus the
|
|
867
|
+
// fade either side) and `core` is the part that stays at full brightness.
|
|
868
|
+
const half = totalHalf(n);
|
|
869
|
+
const core = coreHalf(n);
|
|
870
|
+
// The character's centre, in characters from the start of the row. Measuring against the left edge
|
|
871
|
+
// would make the light peak BETWEEN characters, so each would flash at less than full strength.
|
|
872
|
+
const centre = index + 0.5;
|
|
873
|
+
// The centre crosses from fully off the left of the row to fully off its right, so the light slides
|
|
874
|
+
// in and out rather than popping into existence on the first character.
|
|
875
|
+
//
|
|
876
|
+
// THE REST HALF NEEDS NO SPECIAL CASE. Past the crossing the progress exceeds 1 and the head travels
|
|
877
|
+
// further past the row's right edge — where `sweepPulse` is already 0 — so the light is dark by the
|
|
878
|
+
// same arithmetic that makes it leave. A guard was written for this and then removed after 9450
|
|
879
|
+
// (phase, character) combinations showed it never once changed a value.
|
|
880
|
+
// THE HEAD IS CLAUDE CODE'S `glimmerIndex`, cell for cell, and the run matches its `messageWidth + 20`.
|
|
881
|
+
//
|
|
882
|
+
// Ten cells of margin at each end is what makes the timing match: a 10-character row takes 30 steps, so
|
|
883
|
+
// 1500ms a round — the same as Claude Code. An earlier version used the light's own half-width as the
|
|
884
|
+
// margin, which was tighter and faster, and read as a twitch rather than a sweep.
|
|
885
|
+
//
|
|
886
|
+
// The margin is `max(10, half)`: ten is the target, but the light must clear its own width or it would
|
|
887
|
+
// still be lighting the last characters when the round ended — the failure the half-width version was
|
|
888
|
+
// written to fix, and which a flat ten reintroduces on a wide band.
|
|
889
|
+
const margin = Math.max(ENTRY_EXIT_CHARS, half);
|
|
890
|
+
const travel = n + 2 * margin;
|
|
891
|
+
const steps = Math.floor((phaseMs / crossMs(n)) * travel);
|
|
892
|
+
const head = Math.floor((-margin + steps) / STEP_CHARS) * STEP_CHARS;
|
|
893
|
+
// `edge` shapes the shoulder: a raised cosine leaves its outer end gentle, a linear one is sharpest
|
|
894
|
+
// where it meets the core. It arrives as a PARAMETER rather than being read from config, because this
|
|
895
|
+
// is a module-level function and the TUI state lives inside startTUI's closure — the trap that once
|
|
896
|
+
// made a pending tool row throw a ReferenceError and take the whole TUI down with it.
|
|
897
|
+
return sweepPulse(head - centre, half, edge, core);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Paint `text` with the travelling light already mixed into each glyph.
|
|
902
|
+
*
|
|
903
|
+
* ONE FUNCTION FOR BOTH ANIMATED ROWS — the Working row and a pending tool's "Using <Tool>" — because they
|
|
904
|
+
* were two hand-written copies of the same loop. That is how they drifted: the Working row passed `busy` to
|
|
905
|
+
* `lightAt` and the tool row did not, so one stepped at 50ms/cell inside a 1050ms cycle while the other
|
|
906
|
+
* stepped at 200ms/cell inside a 4050ms cycle. The slow row could not finish before its own modulus
|
|
907
|
+
* wrapped, which is why its light only ever reached the fourth letter before restarting.
|
|
908
|
+
*
|
|
909
|
+
* Everything about the light is decided here — the rate, the shoulder shape, the band width — so a change to
|
|
910
|
+
* the animation lands on both rows at once.
|
|
911
|
+
*
|
|
912
|
+
* @param {string} text the row's text
|
|
913
|
+
* @param {number} phaseMs milliseconds into the cycle
|
|
914
|
+
* @param {number[]} base resting colour, [r, g, b]
|
|
915
|
+
* @param {number[]} glint the colour the light lifts the text toward
|
|
916
|
+
* @param {string} edge shoulder shape: 'cosine' (default) or 'linear'
|
|
917
|
+
* @returns {string} ANSI, ready to splice into a row
|
|
918
|
+
*/
|
|
919
|
+
function shimmerText(text, phaseMs, base, glint, edge = 'cosine') {
|
|
920
|
+
const s = String(text == null ? '' : text);
|
|
921
|
+
const n = s.length;
|
|
922
|
+
if (!n) return '';
|
|
923
|
+
const out = [];
|
|
924
|
+
for (let i = 0; i < n; i++) {
|
|
925
|
+
const t = Math.max(0, Math.min(1, lightAt(phaseMs, i, n, edge, true)));
|
|
926
|
+
const r = Math.round(base[0] + (glint[0] - base[0]) * t);
|
|
927
|
+
const g = Math.round(base[1] + (glint[1] - base[1]) * t);
|
|
928
|
+
const b = Math.round(base[2] + (glint[2] - base[2]) * t);
|
|
929
|
+
out.push(col(s[i], lerpColor(r, g, b, r, g, b, 0)));
|
|
930
|
+
}
|
|
931
|
+
return out.join('');
|
|
932
|
+
}
|
|
933
|
+
// How far the light advances in one step, in characters.
|
|
934
|
+
//
|
|
935
|
+
// One character per step is the most literal reading of "move by letters", and it is the right shape:
|
|
936
|
+
// the light lands on a glyph, lights it and its neighbours, then moves to the next. A larger step would
|
|
937
|
+
// move in pairs, which reads as a coarser crawl without looking any more deliberate.
|
|
938
|
+
export const STEP_CHARS = 1;
|
|
939
|
+
|
|
940
|
+
|
|
591
941
|
// ---------------------------------------------------------------------------
|
|
592
942
|
// Sweep parameters — ONE set of numbers for every animated row
|
|
593
943
|
// ---------------------------------------------------------------------------
|
|
@@ -636,291 +986,254 @@ export function framesPerChar(n, ticks = SWEEP_TICKS) {
|
|
|
636
986
|
const DETACH_HINT_DELAY_MS = 10_000;
|
|
637
987
|
const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background';
|
|
638
988
|
|
|
989
|
+
// The phrase the Working row shows while a compaction is running, in place of a
|
|
990
|
+
// rotating "Working..." one.
|
|
991
|
+
// Compaction belongs on the Working row rather than in the transcript because it is a
|
|
992
|
+
// STATE OF THE TURN, not something that happened in it: the agent is busy, it is doing
|
|
993
|
+
// this one thing, and the row above the composer is where "busy" is already shown. A
|
|
994
|
+
// separate transcript block duplicated that, and left a row behind that scrolls away
|
|
995
|
+
// while the turn carries on.
|
|
996
|
+
//
|
|
997
|
+
// The ellipsis matches the rotating phrases, so the row does not change shape mid-word.
|
|
998
|
+
export const COMPACTING_MSG = 'Compacting Context...';
|
|
999
|
+
|
|
639
1000
|
// Rotating status messages for the "Working..." line — all mean "the agent is
|
|
640
1001
|
// thinking / working", but vary the wording so it doesn't look stuck.
|
|
641
1002
|
const WORKING_MESSAGES = [
|
|
642
1003
|
// ---- A (100) ----
|
|
643
|
-
'Accelerating
|
|
644
|
-
'Accumulating
|
|
645
|
-
'Addressing
|
|
646
|
-
'Affording
|
|
647
|
-
'Altering
|
|
648
|
-
'Answering
|
|
649
|
-
'Architecting
|
|
650
|
-
'Assigning
|
|
651
|
-
'Augmenting
|
|
652
|
-
'Acquiescing
|
|
1004
|
+
'Accelerating', 'Accessing', 'Acclaiming', 'Acclimating', 'Accommodating', 'Accompanying', 'Accomplishing', 'Accounting', 'Accrediting', 'Accruing',
|
|
1005
|
+
'Accumulating', 'Achieving', 'Acknowledging', 'Acquainting', 'Acquiring', 'Acting', 'Activating', 'Actualizing', 'Adapting', 'Adding',
|
|
1006
|
+
'Addressing', 'Adhering', 'Adjusting', 'Administering', 'Admiring', 'Adopting', 'Adorning', 'Advancing', 'Advocating', 'Affirming',
|
|
1007
|
+
'Affording', 'Aggregating', 'Agreeing', 'Aiding', 'Aiming', 'Aligning', 'Alleviating', 'Allocating', 'Allowing', 'Alluring',
|
|
1008
|
+
'Altering', 'Amalgamating', 'Amassing', 'Amending', 'Amplifying', 'Analyzing', 'Anchoring', 'Animating', 'Annotating', 'Announcing',
|
|
1009
|
+
'Answering', 'Anticipating', 'Appealing', 'Applauding', 'Applying', 'Appointing', 'Appraising', 'Appreciating', 'Approaching', 'Approving',
|
|
1010
|
+
'Architecting', 'Archiving', 'Arranging', 'Articulating', 'Ascending', 'Ascertaining', 'Aspiring', 'Assembling', 'Asserting', 'Assessing',
|
|
1011
|
+
'Assigning', 'Assimilating', 'Assisting', 'Assuring', 'Astonishing', 'Attaining', 'Attempting', 'Attending', 'Attracting', 'Auditing',
|
|
1012
|
+
'Augmenting', 'Authenticating', 'Authoring', 'Authorizing', 'Automating', 'Awakening', 'Awarding', 'Attuning', 'Absorbing', 'Accenting',
|
|
1013
|
+
'Acquiescing', 'Adjudicating', 'Adventuring', 'Alerting', 'Ameliorating', 'Allying', 'Annexing', 'Aping', 'Availing', 'Averaging',
|
|
653
1014
|
// ---- B (100) ----
|
|
654
|
-
'Babbling
|
|
655
|
-
'Batching
|
|
656
|
-
'Bedazzling
|
|
657
|
-
'Bequeathing
|
|
658
|
-
'Blasting
|
|
659
|
-
'Blurring
|
|
660
|
-
'Bootstrapping
|
|
661
|
-
'Braiding
|
|
662
|
-
'Briefing
|
|
663
|
-
'Budgeting
|
|
1015
|
+
'Babbling', 'Backing', 'Backtracking', 'Balancing', 'Ballooning', 'Banking', 'Bargaining', 'Bartering', 'Basing', 'Basking',
|
|
1016
|
+
'Batching', 'Bathing', 'Battling', 'Beaching', 'Beaming', 'Bearing', 'Beating', 'Beautifying', 'Beckoning', 'Becoming',
|
|
1017
|
+
'Bedazzling', 'Befriending', 'Beginning', 'Behaving', 'Beholding', 'Believing', 'Belonging', 'Benchmarking', 'Bending', 'Benefiting',
|
|
1018
|
+
'Bequeathing', 'Beseeching', 'Bestowing', 'Bettering', 'Bidding', 'Biding', 'Billowing', 'Binding', 'Birthing', 'Biting',
|
|
1019
|
+
'Blasting', 'Blazing', 'Bleaching', 'Bleeding', 'Blending', 'Blessing', 'Blinking', 'Blooming', 'Blossoming', 'Blowing',
|
|
1020
|
+
'Blurring', 'Blushing', 'Boarding', 'Boasting', 'Boiling', 'Bolstering', 'Bonding', 'Booking', 'Booming', 'Boosting',
|
|
1021
|
+
'Bootstrapping', 'Borrowing', 'Bottling', 'Bouncing', 'Bounding', 'Bowing', 'Bowling', 'Boxing', 'Bracing', 'Brainstorming',
|
|
1022
|
+
'Braiding', 'Branching', 'Branding', 'Braving', 'Breaking', 'Breathing', 'Breeding', 'Breezing', 'Brewing', 'Bridging',
|
|
1023
|
+
'Briefing', 'Brightening', 'Bringing', 'Broadcasting', 'Broadening', 'Bronzing', 'Browsing', 'Brushing', 'Bubbling', 'Budding',
|
|
1024
|
+
'Budgeting', 'Buffering', 'Building', 'Bulking', 'Bundling', 'Buoying', 'Burnishing', 'Bustling', 'Buying', 'Buzzing',
|
|
664
1025
|
// ---- C (100) ----
|
|
665
|
-
'Caching
|
|
666
|
-
'
|
|
667
|
-
'
|
|
668
|
-
'
|
|
669
|
-
'
|
|
670
|
-
'
|
|
671
|
-
'
|
|
672
|
-
'
|
|
673
|
-
'
|
|
674
|
-
'
|
|
1026
|
+
'Caching', 'Cajoling', 'Caking', 'Calibrating', 'Calling', 'Calming', 'Campaigning', 'Camping', 'Canoodling', 'Canvasing',
|
|
1027
|
+
'Capitalizing', 'Captioning', 'Captivating', 'Capturing', 'Caring', 'Carving', 'Cascading', 'Cashing', 'Casting', 'Cataloging',
|
|
1028
|
+
'Catapulting', 'Catching', 'Categorizing', 'Catering', 'Cautioning', 'Ceasing', 'Celebrating', 'Cementing', 'Centralizing', 'Certifying',
|
|
1029
|
+
'Chaining', 'Chairing', 'Chalking', 'Challenging', 'Championing', 'Channeling', 'Chanting', 'Chaperoning', 'Charging', 'Charting',
|
|
1030
|
+
'Chasing', 'Chatting', 'Cheering', 'Cherishing', 'Chewing', 'Chilling', 'Chiming', 'Chipping', 'Chirping', 'Chiseling',
|
|
1031
|
+
'Choosing', 'Chopping', 'Choreographing', 'Chronicling', 'Chugging', 'Ciphering', 'Circling', 'Circulating', 'Citing', 'Civilizing',
|
|
1032
|
+
'Clamping', 'Clanging', 'Clapping', 'Clarifying', 'Clashing', 'Classifying', 'Clawing', 'Cleaning', 'Cleansing', 'Clearing',
|
|
1033
|
+
'Cleaving', 'Climbing', 'Clinching', 'Clipping', 'Cloaking', 'Clocking', 'Cloning', 'Closing', 'Clouding', 'Clustering',
|
|
1034
|
+
'Clutching', 'Coaching', 'Coalescing', 'Coating', 'Coaxing', 'Cocooning', 'Codifying', 'Coexisting', 'Cogitating', 'Cohering',
|
|
1035
|
+
'Coiling', 'Coinciding', 'Collaborating', 'Collapsing', 'Collating', 'Collecting', 'Coloring', 'Combining', 'Comforting', 'Dabbling',
|
|
675
1036
|
// ---- D (100) ----
|
|
676
|
-
'
|
|
677
|
-
'
|
|
678
|
-
'
|
|
679
|
-
'
|
|
680
|
-
'
|
|
681
|
-
'
|
|
682
|
-
'
|
|
683
|
-
'
|
|
684
|
-
'
|
|
685
|
-
'
|
|
686
|
-
'
|
|
687
|
-
'
|
|
688
|
-
'
|
|
689
|
-
'
|
|
1037
|
+
'Dampening', 'Dancing', 'Daring', 'Darting', 'Dashing', 'Dating', 'Dawdling', 'Dazzling', 'Deactivating', 'Debugging',
|
|
1038
|
+
'Debuting', 'Decanting', 'Decelerating', 'Decentralizing', 'Deciding', 'Deciphering', 'Decking', 'Declaring', 'Decoding', 'Decomposing',
|
|
1039
|
+
'Decorating', 'Decoupling', 'Dedicating', 'Deducing', 'Deepening', 'Defeating', 'Defending', 'Defining', 'Deflecting', 'Defragmenting',
|
|
1040
|
+
'Defusing', 'Degreasing', 'Dehydrating', 'Delegating', 'Deliberating', 'Delighting', 'Delivering', 'Delving', 'Demanding', 'Demarcating',
|
|
1041
|
+
'Demonstrating', 'Demystifying', 'Denoting', 'Densifying', 'Departing', 'Depending', 'Deploying', 'Depicting', 'Depositing', 'Depressurizing',
|
|
1042
|
+
'Deriving', 'Descending', 'Describing', 'Desiring', 'Despatching', 'Detailing', 'Detecting', 'Deterring', 'Determining', 'Detoxing',
|
|
1043
|
+
'Devaluing', 'Developing', 'Deviating', 'Devising', 'Devoting', 'Diagnosing', 'Diagramming', 'Dialing', 'Digesting', 'Digging',
|
|
1044
|
+
'Digitizing', 'Diluting', 'Dimensioning', 'Directing', 'Disarming', 'Disbursing', 'Discarding', 'Discerning', 'Discharging', 'Disciplining',
|
|
1045
|
+
'Disclosing', 'Disconnecting', 'Discounting', 'Discovering', 'Discrediting', 'Discussing', 'Disentangling', 'Disguising', 'Disinfecting', 'Dislodging',
|
|
1046
|
+
'Dismantling', 'Dismissing', 'Dispatching', 'Dispensing', 'Dispersing', 'Displaying', 'Disposing', 'Disproving', 'Dissecting', 'Disseminating',
|
|
1047
|
+
'Dissipating', 'Dissolving', 'Distilling', 'Distinguishing', 'Distributing', 'Diversifying', 'Diverting', 'Dividing', 'Divining', 'Divulging',
|
|
1048
|
+
'Docking', 'Documenting', 'Dodging', 'Domesticating', 'Dominating', 'Donating', 'Doodling', 'Dosing', 'Doubling', 'Dovetailing',
|
|
1049
|
+
'Downloading', 'Drafting', 'Draining', 'Dramatizing', 'Drawing', 'Dreaming', 'Dredging', 'Dressing', 'Drifting', 'Drilling',
|
|
1050
|
+
'Driving', 'Dropping', 'Drumming', 'Drying', 'Ducking', 'Dusting', 'Dwelling', 'Dyeing', 'Earning', 'Easing',
|
|
690
1051
|
// ---- E (100) ----
|
|
691
|
-
'
|
|
692
|
-
'
|
|
693
|
-
'
|
|
694
|
-
'
|
|
695
|
-
'
|
|
696
|
-
'
|
|
697
|
-
'
|
|
698
|
-
'
|
|
699
|
-
'
|
|
700
|
-
'
|
|
701
|
-
'
|
|
702
|
-
|
|
703
|
-
'
|
|
704
|
-
'
|
|
705
|
-
'
|
|
706
|
-
'
|
|
707
|
-
'
|
|
708
|
-
'
|
|
709
|
-
'
|
|
710
|
-
'
|
|
711
|
-
'
|
|
712
|
-
'
|
|
713
|
-
|
|
714
|
-
'
|
|
715
|
-
'
|
|
716
|
-
'
|
|
717
|
-
'
|
|
718
|
-
'
|
|
719
|
-
'
|
|
720
|
-
'
|
|
721
|
-
'
|
|
722
|
-
'
|
|
723
|
-
'
|
|
724
|
-
|
|
725
|
-
'
|
|
726
|
-
'
|
|
727
|
-
'
|
|
728
|
-
'
|
|
729
|
-
'
|
|
730
|
-
'
|
|
731
|
-
'
|
|
732
|
-
'
|
|
733
|
-
'
|
|
734
|
-
'
|
|
735
|
-
|
|
736
|
-
'
|
|
737
|
-
'
|
|
738
|
-
'
|
|
739
|
-
'
|
|
740
|
-
'
|
|
741
|
-
'
|
|
742
|
-
'
|
|
743
|
-
'
|
|
744
|
-
'
|
|
745
|
-
'
|
|
746
|
-
|
|
747
|
-
'
|
|
748
|
-
'
|
|
749
|
-
'
|
|
750
|
-
'
|
|
751
|
-
'
|
|
752
|
-
'
|
|
753
|
-
'
|
|
754
|
-
'
|
|
755
|
-
'
|
|
756
|
-
'
|
|
757
|
-
'
|
|
758
|
-
'
|
|
759
|
-
'
|
|
760
|
-
|
|
761
|
-
'
|
|
762
|
-
'
|
|
763
|
-
'
|
|
764
|
-
'
|
|
765
|
-
'
|
|
766
|
-
'
|
|
767
|
-
'
|
|
768
|
-
'
|
|
769
|
-
'
|
|
770
|
-
'
|
|
771
|
-
'
|
|
772
|
-
'
|
|
773
|
-
'
|
|
774
|
-
'
|
|
775
|
-
'
|
|
776
|
-
'
|
|
777
|
-
|
|
778
|
-
'
|
|
779
|
-
'
|
|
780
|
-
'
|
|
781
|
-
'
|
|
782
|
-
'
|
|
783
|
-
'
|
|
784
|
-
'
|
|
785
|
-
'
|
|
786
|
-
'
|
|
787
|
-
'
|
|
788
|
-
|
|
789
|
-
'
|
|
790
|
-
'
|
|
791
|
-
'
|
|
792
|
-
'
|
|
793
|
-
'
|
|
794
|
-
'
|
|
795
|
-
'
|
|
796
|
-
'
|
|
797
|
-
'
|
|
798
|
-
'
|
|
799
|
-
'
|
|
800
|
-
'
|
|
801
|
-
'
|
|
802
|
-
'
|
|
803
|
-
'
|
|
804
|
-
'
|
|
805
|
-
'
|
|
806
|
-
'
|
|
807
|
-
'
|
|
808
|
-
'
|
|
809
|
-
'
|
|
810
|
-
'
|
|
811
|
-
|
|
812
|
-
'
|
|
813
|
-
'
|
|
814
|
-
'
|
|
815
|
-
'
|
|
816
|
-
'
|
|
817
|
-
'
|
|
818
|
-
'
|
|
819
|
-
'
|
|
820
|
-
|
|
821
|
-
'
|
|
822
|
-
'
|
|
823
|
-
'
|
|
824
|
-
'
|
|
825
|
-
'
|
|
826
|
-
'
|
|
827
|
-
'
|
|
828
|
-
'
|
|
829
|
-
'
|
|
830
|
-
'
|
|
831
|
-
'
|
|
832
|
-
'
|
|
833
|
-
'
|
|
834
|
-
'
|
|
835
|
-
|
|
836
|
-
'
|
|
837
|
-
'
|
|
838
|
-
'
|
|
839
|
-
'
|
|
840
|
-
'
|
|
841
|
-
'
|
|
842
|
-
'
|
|
843
|
-
'
|
|
844
|
-
'
|
|
845
|
-
'
|
|
846
|
-
'
|
|
847
|
-
'
|
|
848
|
-
'
|
|
849
|
-
'
|
|
850
|
-
'
|
|
851
|
-
'
|
|
852
|
-
'
|
|
853
|
-
'
|
|
854
|
-
'
|
|
855
|
-
'
|
|
856
|
-
'
|
|
857
|
-
'
|
|
858
|
-
'
|
|
859
|
-
'
|
|
860
|
-
'
|
|
861
|
-
'
|
|
862
|
-
'
|
|
863
|
-
'
|
|
864
|
-
'
|
|
865
|
-
'
|
|
866
|
-
'
|
|
867
|
-
'
|
|
868
|
-
'
|
|
869
|
-
'
|
|
870
|
-
'
|
|
871
|
-
'
|
|
872
|
-
'
|
|
873
|
-
|
|
874
|
-
'
|
|
875
|
-
'
|
|
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...',
|
|
1052
|
+
'Echoing', 'Eclipsing', 'Economizing', 'Edging', 'Editing', 'Educating', 'Effecting', 'Effervescing', 'Elaborating', 'Elating',
|
|
1053
|
+
'Electing', 'Electrifying', 'Elevating', 'Eliciting', 'Eliminating', 'Elucidating', 'Emancipating', 'Embarking', 'Embedding', 'Embellishing',
|
|
1054
|
+
'Embodying', 'Embracing', 'Embroidering', 'Emerging', 'Emitting', 'Empathizing', 'Emphasizing', 'Empowering', 'Emulating', 'Enabling',
|
|
1055
|
+
'Enacting', 'Encapsulating', 'Enchanting', 'Encircling', 'Enclosing', 'Encoding', 'Encouraging', 'Encrypting', 'Endearing', 'Endeavoring',
|
|
1056
|
+
'Endorsing', 'Endowing', 'Enduring', 'Energizing', 'Enforcing', 'Engaging', 'Engineering', 'Engraving', 'Enhancing', 'Enjoying',
|
|
1057
|
+
'Enlarging', 'Enlightening', 'Enlisting', 'Enlivening', 'Enriching', 'Enrolling', 'Ensuring', 'Entering', 'Entertaining', 'Enthralling',
|
|
1058
|
+
'Enthusing', 'Enticing', 'Entitling', 'Enumerating', 'Enveloping', 'Envisioning', 'Equipping', 'Equalizing', 'Erecting', 'Erasing',
|
|
1059
|
+
'Escalating', 'Escaping', 'Escorting', 'Establishing', 'Esteeming', 'Estimating', 'Eulogizing', 'Evaluating', 'Evaporating', 'Evolving',
|
|
1060
|
+
'Examining', 'Exceeding', 'Excelling', 'Exchanging', 'Exciting', 'Excluding', 'Executing', 'Exercising', 'Exhaling', 'Exhibiting',
|
|
1061
|
+
'Exhilarating', 'Existing', 'Expanding', 'Expecting', 'Expediting', 'Experiencing', 'Experimenting', 'Explaining', 'Exploring', 'Exporting',
|
|
1062
|
+
'Exposing', 'Expressing', 'Extending', 'Externalizing', 'Extracting', 'Extrapolating', 'Eyeing', 'Fabricating', 'Facilitating', 'Facing',
|
|
1063
|
+
'Factoring', 'Fading', 'Farming', 'Fascinating', 'Fashioning', 'Fastening', 'Fathoming', 'Favoring', 'Feathering', 'Featuring',
|
|
1064
|
+
'Feeding', 'Feeling', 'Fencing', 'Fermenting', 'Ferrying', 'Fertilizing', 'Fetching', 'Fiddling', 'Fielding', 'Figuring',
|
|
1065
|
+
'Filing', 'Filling', 'Filtering', 'Financing', 'Finding', 'Fingerprinting', 'Finishing', 'Firing', 'Firming', 'Fishing',
|
|
1066
|
+
'Fitting', 'Fixing', 'Flagging', 'Flanking', 'Flashing', 'Flattening', 'Flattering', 'Flavoring', 'Fleshing', 'Flexing',
|
|
1067
|
+
'Flicking', 'Flinging', 'Flipping', 'Floating', 'Flooding', 'Flooring', 'Flourishing', 'Flowing', 'Flushing', 'Fluttering',
|
|
1068
|
+
'Flying', 'Foaming', 'Focusing', 'Folding', 'Following', 'Forging', 'Forgiving', 'Formalizing', 'Forming', 'Formulating',
|
|
1069
|
+
'Fortifying', 'Forwarding', 'Fostering', 'Founding', 'Fragmenting', 'Framing', 'Freeing', 'Freezing', 'Freshening', 'Frosting',
|
|
1070
|
+
'Frying', 'Fueling', 'Fulfilling', 'Functioning', 'Funding', 'Furnishing', 'Furthering', 'Fusing', 'Fussing', 'Factorizing',
|
|
1071
|
+
'Fantasizing', 'Faultfinding', 'Federating', 'Fending', 'Ferreting', 'Filming', 'Finalizing', 'Finessing', 'Fireproofing', 'Fizzing',
|
|
1072
|
+
'Flameproofing', 'Fleecing', 'Flickering', 'Flitting', 'Fluctuating', 'Fluorescing', 'Freewheeling', 'Gaining', 'Galloping', 'Galvanizing',
|
|
1073
|
+
'Gaming', 'Gardening', 'Garlanding', 'Garnering', 'Gathering', 'Gauging', 'Gazing', 'Gearing', 'Generalizing', 'Generating',
|
|
1074
|
+
'Gesticulating', 'Gesturing', 'Getting', 'Gifting', 'Giggling', 'Gilding', 'Girding', 'Giving', 'Glancing', 'Glazing',
|
|
1075
|
+
'Gleaming', 'Gleaning', 'Glimmering', 'Glimpsing', 'Gliding', 'Glistening', 'Glittering', 'Glowing', 'Glueing', 'Goading',
|
|
1076
|
+
'Gobbling', 'Golfing', 'Governing', 'Grabbing', 'Graduating', 'Grafting', 'Granting', 'Graphing', 'Grasping', 'Gratifying',
|
|
1077
|
+
'Grating', 'Greasing', 'Greeting', 'Gridding', 'Grilling', 'Grinding', 'Gripping', 'Grooving', 'Grooming', 'Grounding',
|
|
1078
|
+
'Grouping', 'Growing', 'Guaranteeing', 'Guarding', 'Guessing', 'Guiding', 'Gushing', 'Gusting', 'Gutting', 'Guzzling',
|
|
1079
|
+
'Gamboling', 'Gating', 'Gazetting', 'Gelling', 'Gentrifying', 'Germinating', 'Gestating', 'Ghosting', 'Glaciating', 'Gladdening',
|
|
1080
|
+
'Globalizing', 'Glorifying', 'Glossing', 'Gonging', 'Gouging', 'Grandstanding', 'Granulating', 'Gravitating', 'Grazing', 'Greening',
|
|
1081
|
+
'Grimacing', 'Grubbing', 'Gurgling', 'Gyrating', 'Gabbing', 'Gaffing', 'Gallivanting', 'Galumphing', 'Garbling', 'Garrisoning',
|
|
1082
|
+
'Gasping', 'Gentling', 'Geotagging', 'Gimballing', 'Glinting', 'Gorging', 'Grinning', 'Habilitating', 'Hacking', 'Haggling',
|
|
1083
|
+
'Hailing', 'Hammering', 'Handcrafting', 'Handing', 'Handling', 'Hanging', 'Happening', 'Harboring', 'Hardening', 'Harmonizing',
|
|
1084
|
+
'Harnessing', 'Harvesting', 'Hashing', 'Hastening', 'Hatching', 'Hauling', 'Having', 'Heading', 'Healing', 'Heaping',
|
|
1085
|
+
'Hearing', 'Heartening', 'Heating', 'Heaving', 'Hedging', 'Heeding', 'Helping', 'Heralding', 'Herding', 'Hesitating',
|
|
1086
|
+
'Hibernating', 'Hiding', 'Highlighting', 'Hiking', 'Hinging', 'Hinting', 'Hiring', 'Hitting', 'Hoarding', 'Hoisting',
|
|
1087
|
+
'Hollowing', 'Homing', 'Honing', 'Honoring', 'Hooking', 'Hopping', 'Hoping', 'Horsing', 'Hosting', 'Hotfixing',
|
|
1088
|
+
'Hovering', 'Howling', 'Huddling', 'Hugging', 'Hulling', 'Humanizing', 'Humbling', 'Humming', 'Hunting', 'Hurling',
|
|
1089
|
+
'Hurrying', 'Hushing', 'Hustling', 'Hybridizing', 'Hydrating', 'Hydroplaning', 'Hymning', 'Hyping', 'Hypothesizing', 'Habituating',
|
|
1090
|
+
'Hairdressing', 'Hallowing', 'Halving', 'Handholding', 'Handpicking', 'Handwriting', 'Hankering', 'Hardcoding', 'Harkening', 'Hawking',
|
|
1091
|
+
'Haymaking', 'Headhunting', 'Headlining', 'Heartwarming', 'Heightening', 'Heehawing', 'Homesteading', 'Homeschooling', 'Honeying', 'Hooping',
|
|
1092
|
+
'Hornswoggling', 'Hosing', 'Hounding', 'Huckstering', 'Humoring', 'Idealizing', 'Identifying', 'Igniting', 'Ignoring', 'Illuminating',
|
|
1093
|
+
'Illustrating', 'Imagining', 'Imitating', 'Immersing', 'Immortalizing', 'Impacting', 'Imparting', 'Impersonating', 'Implementing', 'Implicating',
|
|
1094
|
+
'Importing', 'Imposing', 'Impressing', 'Imprinting', 'Improving', 'Improvising', 'Inaugurating', 'Incentivizing', 'Inching', 'Including',
|
|
1095
|
+
'Incorporating', 'Increasing', 'Incrementing', 'Incubating', 'Incurring', 'Indenting', 'Indexing', 'Indicating', 'Inducting', 'Indulging',
|
|
1096
|
+
'Industrializing', 'Inferring', 'Infiltrating', 'Inflating', 'Influencing', 'Informing', 'Infusing', 'Ingesting', 'Inhabiting', 'Inhaling',
|
|
1097
|
+
'Inheriting', 'Inhibiting', 'Initializing', 'Initiating', 'Injecting', 'Injuring', 'Inking', 'Innovating', 'Inoculating', 'Inquiring',
|
|
1098
|
+
'Inscribing', 'Inserting', 'Insisting', 'Inspecting', 'Inspiring', 'Installing', 'Instantiating', 'Instigating', 'Instilling', 'Instituting',
|
|
1099
|
+
'Instructing', 'Insulating', 'Insuring', 'Integrating', 'Intending', 'Intensifying', 'Interacting', 'Intercepting', 'Interfacing', 'Interleaving',
|
|
1100
|
+
'Interlinking', 'Interlacing', 'Interlocking', 'Interning', 'Interpolating', 'Interpreting', 'Interrogating', 'Intersecting', 'Intervening', 'Interviewing',
|
|
1101
|
+
'Interweaving', 'Intoning', 'Introducing', 'Intuiting', 'Inventing', 'Inventorying', 'Inverting', 'Investing', 'Investigating', 'Invigorating',
|
|
1102
|
+
'Inviting', 'Involving', 'Ionizing', 'Iterating', 'Jabbing', 'Jacketing', 'Jailing', 'Jamming', 'Jangling', 'Jarring',
|
|
1103
|
+
'Jaunting', 'Jazzing', 'Jeering', 'Jelling', 'Jeopardizing', 'Jerking', 'Jesting', 'Jettisoning', 'Jeweling', 'Jibing',
|
|
1104
|
+
'Jiggling', 'Jigsawing', 'Jilting', 'Jingling', 'Jiving', 'Jobbing', 'Jockeying', 'Jogging', 'Joining', 'Joking',
|
|
1105
|
+
'Jollying', 'Jolting', 'Jostling', 'Jotting', 'Journeying', 'Jousting', 'Joyriding', 'Judging', 'Juggling', 'Julienning',
|
|
1106
|
+
'Jumping', 'Junketing', 'Justifying', 'Jutting', 'Juxtaposing', 'Jabbering', 'Jading', 'Jalousing', 'Japing', 'Jargonizing',
|
|
1107
|
+
'Jauncing', 'Jawboning', 'Jawing', 'Jeopardying', 'Jellifying', 'Jemmying', 'Jettying', 'Jewelling', 'Jibbing', 'Jigging',
|
|
1108
|
+
'Jitterbugging', 'Jinxing', 'Jittering', 'Jobhunting', 'Joggling', 'Joisting', 'Jokifying', 'Jollifying', 'Jonesing', 'Joshing',
|
|
1109
|
+
'Jouking', 'Journalling', 'Jovializing', 'Jowing', 'Joying', 'Joypopping', 'Jubilating', 'Juddering', 'Judoing', 'Juking',
|
|
1110
|
+
'Jumbling', 'Jumpstarting', 'Junking', 'Jurifying', 'Jurying', 'Justling', 'Juicing', 'Jouncing', 'Javelining', 'Jocundizing',
|
|
1111
|
+
'Japering', 'Japonicaing', 'Jasperizing', 'Jazzercising', 'Jellyfishing', 'Jerrybuilding', 'Jerseying', 'Jessying', 'Jobseeking', 'Jointing',
|
|
1112
|
+
'Junketeering', 'Jawbreaking', 'Jaywalking', 'Keeling', 'Keening', 'Keeping', 'Kenning', 'Kerbing', 'Kerneling', 'Kettling',
|
|
1113
|
+
'Keyboarding', 'Keying', 'Keynoting', 'Kibitzing', 'Kicking', 'Kidding', 'Kidnapping', 'Kilning', 'Kindling', 'Kinging',
|
|
1114
|
+
'Kinking', 'Kissing', 'Kitting', 'Kneading', 'Kneeling', 'Knifing', 'Knighting', 'Knitting', 'Knocking', 'Knotting',
|
|
1115
|
+
'Knowing', 'Knuckling', 'Koshering', 'Kowtowing', 'Kvetching', 'Karaoking', 'Kayoing', 'Kecking', 'Kedging', 'Keeking',
|
|
1116
|
+
'Kelping', 'Kenneling', 'Keratinizing', 'Kerfing', 'Kernelling', 'Kibbutzing', 'Kiboshing', 'Kickboxing', 'Kickstarting', 'Kiddying',
|
|
1117
|
+
'Kiltering', 'Kippering', 'Kiting', 'Kittening', 'Klaxoning', 'Knapping', 'Kneecapping', 'Knelling', 'Knobbling', 'Knolling',
|
|
1118
|
+
'Kodaking', 'Konking', 'Kooking', 'Koreanizing', 'Kremlining', 'Krumping', 'Kudosing', 'Kurbling', 'Kvelling', 'Kingmaking',
|
|
1119
|
+
'Kyanising', 'Kyboshing', 'Kything', 'Kaolinizing', 'Kaputing', 'Katalysing', 'Kathoding', 'Keshing', 'Keypunching', 'Khediving',
|
|
1120
|
+
'Kalsomining', 'Kamiing', 'Kantianing', 'Karaokeing', 'Karateing', 'Karstifying', 'Katabolizing', 'Kathing', 'Kazaching', 'Kebabbing',
|
|
1121
|
+
'Kebobbing', 'Keeving', 'Keelhauling', 'Keenlying', 'Keepnetting', 'Keltering', 'Kerseying', 'Ketling', 'Keyframing', 'Keyseating',
|
|
1122
|
+
'Kibbling', 'Kickboarding', 'Kickflipping', 'Kidneystoning', 'Kieving', 'Kimchifying', 'Kimmering', 'Kindergartening', 'Kinescoping', 'Kingfishing',
|
|
1123
|
+
'Kirkifying', 'Kirshening', 'Kitbagging', 'Kitchening', 'Kitesurfing', 'Klondiking', 'Kludging', 'Knackering', 'Kneippering', 'Knobkerrying',
|
|
1124
|
+
'Knowledging', 'Kohlrabiing', 'Koorifying', 'Kotowing', 'Kronaing', 'Krytroning', 'Kugelhopfing', 'Kumquating', 'Kurrajonging', 'Kyanizing',
|
|
1125
|
+
'Labeling', 'Laboring', 'Lacing', 'Laddering', 'Lading', 'Lagging', 'Lambasting', 'Laminating', 'Landing', 'Languishing',
|
|
1126
|
+
'Lapping', 'Lapsing', 'Larding', 'Lashing', 'Lasting', 'Latching', 'Lathering', 'Lauding', 'Laughing', 'Launching',
|
|
1127
|
+
'Laundering', 'Lavishing', 'Laying', 'Lazing', 'Leaching', 'Leading', 'Leafing', 'Leaking', 'Leaning', 'Leaping',
|
|
1128
|
+
'Learning', 'Leasing', 'Leathering', 'Leaving', 'Lecturing', 'Ledgering', 'Leeching', 'Leering', 'Legitimizing', 'Lending',
|
|
1129
|
+
'Lengthening', 'Lessening', 'Letting', 'Leveling', 'Leveraging', 'Levying', 'Liberating', 'Licensing', 'Licking', 'Lifting',
|
|
1130
|
+
'Lightening', 'Lighting', 'Liking', 'Limbering', 'Lining', 'Linking', 'Lionizing', 'Liquidating', 'Lisping', 'Listening',
|
|
1131
|
+
'Litigating', 'Littering', 'Living', 'Loading', 'Loafing', 'Loaning', 'Lobbying', 'Lobing', 'Localizing', 'Locating',
|
|
1132
|
+
'Locking', 'Lodging', 'Lofting', 'Logging', 'Longing', 'Looking', 'Looping', 'Loosening', 'Looting', 'Loping',
|
|
1133
|
+
'Losing', 'Lounging', 'Loving', 'Lowering', 'Lubricating', 'Lugging', 'Lulling', 'Lumbering', 'Lumping', 'Lunching',
|
|
1134
|
+
'Lunging', 'Luring', 'Lurking', 'Lustering', 'Lustrating', 'Luxuriating', 'Lying', 'Lynching', 'Lyricizing', 'Machining',
|
|
1135
|
+
'Magnetizing', 'Magnifying', 'Maintaining', 'Mainstreaming', 'Making', 'Managing', 'Mandating', 'Maneuvering', 'Mangling', 'Manifesting',
|
|
1136
|
+
'Manipulating', 'Manning', 'Manufacturing', 'Mapping', 'Marching', 'Marketing', 'Marking', 'Marrying', 'Marshaling', 'Marveling',
|
|
1137
|
+
'Mashing', 'Masking', 'Massaging', 'Massing', 'Mastering', 'Matching', 'Materializing', 'Matriculating', 'Mattering', 'Maximizing',
|
|
1138
|
+
'Meaning', 'Measuring', 'Mediating', 'Meditating', 'Meeting', 'Melding', 'Mellowing', 'Melting', 'Memorizing', 'Mending',
|
|
1139
|
+
'Mentioning', 'Mentoring', 'Merging', 'Meriting', 'Meshing', 'Mesmerizing', 'Messaging', 'Metabolizing', 'Metalworking', 'Metamorphosing',
|
|
1140
|
+
'Metering', 'Microblogging', 'Microchipping', 'Microfilming', 'Micromanaging', 'Migrating', 'Milking', 'Milling', 'Mimicking', 'Mincing',
|
|
1141
|
+
'Minding', 'Mingling', 'Miniaturizing', 'Minimizing', 'Mining', 'Ministering', 'Minting', 'Mirroring', 'Misaligning', 'Miscalculating',
|
|
1142
|
+
'Misdiagnosing', 'Misfiring', 'Misjudging', 'Mislaying', 'Mismatching', 'Misplacing', 'Misquoting', 'Misreading', 'Misspelling', 'Mistaking',
|
|
1143
|
+
'Misting', 'Misunderstanding', 'Mitigating', 'Mixing', 'Moaning', 'Mobilizing', 'Mocking', 'Modeling', 'Moderating', 'Modernizing',
|
|
1144
|
+
'Modifying', 'Modularizing', 'Modulating', 'Moistening', 'Moisturizing', 'Molding', 'Mollifying', 'Monitoring', 'Monetizing', 'Monogramming',
|
|
1145
|
+
'Monologuing', 'Monopolizing', 'Mooring', 'Moralizing', 'Morphing', 'Mortgaging', 'Moseying', 'Motivating', 'Motoring', 'Mottling',
|
|
1146
|
+
'Moulding', 'Mounting', 'Mountaineering', 'Mourning', 'Mousetrapping', 'Mouthing', 'Moving', 'Mowing', 'Muckraking', 'Muddling',
|
|
1147
|
+
'Mudslinging', 'Muffling', 'Mulching', 'Multiplying', 'Multiplexing', 'Multitasking', 'Mumbling', 'Mummifying', 'Munching', 'Murmuring',
|
|
1148
|
+
'Muscling', 'Musicalizing', 'Musing', 'Musseling', 'Mustering', 'Mutating', 'Muting', 'Mutinying', 'Muttering', 'Mutualizing',
|
|
1149
|
+
'Muzzling', 'Mystifying', 'Mailing', 'Mamboing', 'Manacling', 'Manhandling', 'Manicuring', 'Mansplaining', 'Mantling', 'Marinating',
|
|
1150
|
+
'Marooning', 'Marring', 'Masquerading', 'Masterminding', 'Masticating', 'Mauling', 'Meandering', 'Mechanizing', 'Meddling', 'Meliorating',
|
|
1151
|
+
'Melodizing', 'Memorializing', 'Menacing', 'Mercerizing', 'Merchandising', 'Metallizing', 'Mildewing', 'Militating', 'Mimeographing', 'Mindmapping',
|
|
1152
|
+
'Minesweeping', 'Misappropriating', 'Misconstruing', 'Misrepresenting', 'Nabbing', 'Nailing', 'Naming', 'Nanosizing', 'Napping', 'Narrating',
|
|
1153
|
+
'Narrowing', 'Nattering', 'Naturalizing', 'Navigating', 'Neatening', 'Nebulizing', 'Necessitating', 'Necking', 'Necropsying', 'Needling',
|
|
1154
|
+
'Negating', 'Negotiating', 'Nerving', 'Nesting', 'Nestling', 'Netting', 'Nettling', 'Networking', 'Neutralizing', 'Newscasting',
|
|
1155
|
+
'Nibbling', 'Nicking', 'Nicknaming', 'Nictitating', 'Niggling', 'Nipping', 'Nitpicking', 'Nobbling', 'Nocking', 'Nodding',
|
|
1156
|
+
'Noising', 'Nominating', 'Nonplussing', 'Noodling', 'Normalizing', 'Nosediving', 'Nosing', 'Notarizing', 'Notating', 'Notching',
|
|
1157
|
+
'Noticing', 'Notifying', 'Noting', 'Nourishing', 'Novelizing', 'Nucleating', 'Nudging', 'Nuggetting', 'Nuking', 'Nullifying',
|
|
1158
|
+
'Numbering', 'Numbing', 'Nursemaiding', 'Nursing', 'Nurturing', 'Nutmegging', 'Nuzzling', 'Narrowcasting', 'Obeying', 'Objecting',
|
|
1159
|
+
'Objectivizing', 'Obligating', 'Obliging', 'Obliterating', 'Obnubilating', 'Obscuring', 'Observing', 'Obsessing', 'Obsoleting', 'Obstructing',
|
|
1160
|
+
'Obtaining', 'Obtruding', 'Obviating', 'Occasioning', 'Occupying', 'Occurring', 'Offering', 'Officiating', 'Offloading', 'Offsetting',
|
|
1161
|
+
'Ogling', 'Oiling', 'Okaying', 'Ominating', 'Omitting', 'Onboarding', 'Oozing', 'Opacifying', 'Opalescing', 'Opening',
|
|
1162
|
+
'Operating', 'Opining', 'Opposing', 'Optimizing', 'Optioning', 'Orating', 'Orbiting', 'Orchestrating', 'Ordaining', 'Ordering',
|
|
1163
|
+
'Organizing', 'Orienting', 'Originating', 'Ornamenting', 'Orphaning', 'Oscillating', 'Ossifying', 'Ostracizing', 'Outbidding', 'Outclassing',
|
|
1164
|
+
'Outdoing', 'Outfitting', 'Outgrowing', 'Outlasting', 'Outlining', 'Outmaneuvering', 'Outpacing', 'Outperforming', 'Outreaching', 'Outsmarting',
|
|
1165
|
+
'Outsourcing', 'Outstripping', 'Outwitting', 'Overachieving', 'Overarching', 'Overbalancing', 'Overbooking', 'Overbuilding', 'Overcoming', 'Overcompensating',
|
|
1166
|
+
'Overdelivering', 'Overdoing', 'Overeating', 'Overemphasizing', 'Overestimating', 'Overflowing', 'Overhauling', 'Overhearing', 'Overheating', 'Overlaying',
|
|
1167
|
+
'Overloading', 'Overlooking', 'Overpowering', 'Overprinting', 'Overproducing', 'Overreaching', 'Overriding', 'Overruling', 'Overseeing', 'Overshadowing',
|
|
1168
|
+
'Overshooting', 'Oversimplifying', 'Oversleeping', 'Overstating', 'Overstepping', 'Overstocking', 'Overstretching', 'Overtaking', 'Overthinking', 'Overthrowing',
|
|
1169
|
+
'Overturning', 'Overvaluing', 'Overwatching', 'Overwhelming', 'Owing', 'Owning', 'Oxidizing', 'Oxygenating', 'Ozonizing', 'Obolizing',
|
|
1170
|
+
'Pacing', 'Packaging', 'Packing', 'Padding', 'Paddling', 'Paging', 'Painting', 'Pairing', 'Palatalizing', 'Palling',
|
|
1171
|
+
'Palming', 'Palpating', 'Pampering', 'Panelizing', 'Panicking', 'Pantomiming', 'Papering', 'Parachuting', 'Parading', 'Paragliding',
|
|
1172
|
+
'Paralysing', 'Paraphrasing', 'Parceling', 'Pardoning', 'Parenting', 'Pariking', 'Parking', 'Parleying', 'Parodying', 'Paroling',
|
|
1173
|
+
'Parrying', 'Parsing', 'Partaking', 'Partitioning', 'Partnering', 'Partying', 'Passing', 'Passivizing', 'Pasting', 'Pastoring',
|
|
1174
|
+
'Patching', 'Patenting', 'Patrolling', 'Patronizing', 'Patterning', 'Paving', 'Pawing', 'Paying', 'Peaking', 'Pealing',
|
|
1175
|
+
'Pecking', 'Pedaling', 'Peeking', 'Peeling', 'Peering', 'Pegging', 'Pelting', 'Penalizing', 'Penciling', 'Penetrating',
|
|
1176
|
+
'Pensioning', 'Peppering', 'Perceiving', 'Perching', 'Perfecting', 'Perforating', 'Performing', 'Perfuming', 'Perishing', 'Permeating',
|
|
1177
|
+
'Permitting', 'Permuting', 'Perpetuating', 'Perplexing', 'Persevering', 'Persisting', 'Personalizing', 'Personifying', 'Persuading', 'Perving',
|
|
1178
|
+
'Pestering', 'Petitioning', 'Petrifying', 'Pettifogging', 'Phaseing', 'Philandering', 'Philosophizing', 'Phoneing', 'Photocopying', 'Photographing',
|
|
1179
|
+
'Photosynthesizing', 'Phrasing', 'Picking', 'Picnicking', 'Picturing', 'Piecing', 'Piercing', 'Piggybacking', 'Pilfering', 'Piloting',
|
|
1180
|
+
'Pinching', 'Pinging', 'Pinning', 'Pioneering', 'Piping', 'Pitching', 'Pitying', 'Pivoting', 'Placating', 'Placing',
|
|
1181
|
+
'Plagiarizing', 'Plaiting', 'Planning', 'Planting', 'Plastering', 'Plating', 'Playing', 'Pleading', 'Pleasuring', 'Pledging',
|
|
1182
|
+
'Plodding', 'Plotting', 'Ploughing', 'Plowing', 'Plucking', 'Plugging', 'Plumbing', 'Plummeting', 'Plumping', 'Plundering',
|
|
1183
|
+
'Plunging', 'Pluralizing', 'Plying', 'Poaching', 'Pocketing', 'Podcasting', 'Poeticizing', 'Pointing', 'Poising', 'Poisoning',
|
|
1184
|
+
'Poking', 'Polarizing', 'Policing', 'Polishing', 'Politicizing', 'Pollinating', 'Polling', 'Pondering', 'Pooling', 'Popping',
|
|
1185
|
+
'Popularizing', 'Populating', 'Poring', 'Porting', 'Portioning', 'Portraying', 'Posing', 'Positioning', 'Positing', 'Possessing',
|
|
1186
|
+
'Posting', 'Postponing', 'Postulating', 'Posturing', 'Potting', 'Pouncing', 'Pounding', 'Pouring', 'Pouting', 'Powdering',
|
|
1187
|
+
'Powering', 'Practicing', 'Praising', 'Prancing', 'Prattling', 'Praying', 'Preaching', 'Preambleing', 'Preceding', 'Precipitating',
|
|
1188
|
+
'Precluding', 'Precooking', 'Predicting', 'Predisposing', 'Prefacing', 'Preferring', 'Prefiguring', 'Prefixing', 'Preheating', 'Prejudging',
|
|
1189
|
+
'Preluding', 'Premising', 'Preoccupying', 'Preordaining', 'Preparing', 'Prepaving', 'Prepaying', 'Preponderating', 'Prerecording', 'Prescribing',
|
|
1190
|
+
'Presenting', 'Preserving', 'Presetting', 'Presiding', 'Pressuring', 'Pressurizing', 'Prestressing', 'Presuming', 'Pretending', 'Prettifying',
|
|
1191
|
+
'Prevailing', 'Preventing', 'Previewing', 'Preying', 'Pricing', 'Pricking', 'Pridiing', 'Priming', 'Printing', 'Prioritizing',
|
|
1192
|
+
'Privatizing', 'Privileging', 'Prizing', 'Probing', 'Proceeding', 'Processing', 'Proclaiming', 'Procrastinating', 'Procuring', 'Prodding',
|
|
1193
|
+
'Producing', 'Profaning', 'Professing', 'Profiling', 'Profiting', 'Programming', 'Progressing', 'Prohibiting', 'Projecting', 'Proliferating',
|
|
1194
|
+
'Prolonging', 'Promenading', 'Promising', 'Promoting', 'Prompting', 'Promulgating', 'Pronouncing', 'Proofing', 'Proofreading', 'Propagating',
|
|
1195
|
+
'Propelling', 'Prophesying', 'Proposing', 'Propping', 'Proroguing', 'Prosecuting', 'Proselytizing', 'Prospecting', 'Prospering', 'Protecting',
|
|
1196
|
+
'Protesting', 'Protruding', 'Proving', 'Providing', 'Provisioning', 'Provoking', 'Prowling', 'Pruning', 'Psyching', 'Publicizing',
|
|
1197
|
+
'Publishing', 'Puckering', 'Puffing', 'Pulling', 'Pulping', 'Pulsating', 'Pulverizing', 'Pummeling', 'Pumping', 'Punching',
|
|
1198
|
+
'Puncturing', 'Punishing', 'Punning', 'Purging', 'Purifying', 'Purloining', 'Purporting', 'Purring', 'Pursuing', 'Pushing',
|
|
1199
|
+
'Putting', 'Puzzling', 'Pyramiding', 'Quacking', 'Quadrupling', 'Quaffing', 'Quailing', 'Qualifying', 'Quantifying', 'Quarreling',
|
|
1200
|
+
'Quarrying', 'Quartering', 'Quashing', 'Quavering', 'Queening', 'Quelling', 'Quenching', 'Querying', 'Questing', 'Questioning',
|
|
1201
|
+
'Queueing', 'Quickening', 'Quieting', 'Quilting', 'Quipping', 'Quivering', 'Quizzing', 'Quoting', 'Racing', 'Racking',
|
|
1202
|
+
'Radiating', 'Raffling', 'Rafting', 'Raging', 'Raining', 'Raising', 'Rallying', 'Rambling', 'Ramifying', 'Ramping',
|
|
1203
|
+
'Ransacking', 'Ransoming', 'Ranting', 'Rapping', 'Rapproching', 'Rapturing', 'Raring', 'Rasping', 'Ratifying', 'Rating',
|
|
1204
|
+
'Rationalizing', 'Rationing', 'Rattling', 'Ravaging', 'Raving', 'Reaching', 'Reacting', 'Readjusting', 'Readying', 'Realigning',
|
|
1205
|
+
'Realizing', 'Reallocating', 'Reanimating', 'Reaping', 'Reappearing', 'Reapplying', 'Rearranging', 'Reasoning', 'Reassembling', 'Reasserting',
|
|
1206
|
+
'Reassessing', 'Reassigning', 'Reassuring', 'Rebalancing', 'Rebating', 'Rebelling', 'Rebooting', 'Rebounding', 'Rebranding', 'Rebuilding',
|
|
1207
|
+
'Rebuking', 'Recalculating', 'Recalibrating', 'Recalling', 'Recanting', 'Recapping', 'Recapturing', 'Receding', 'Receiving', 'Recentering',
|
|
1208
|
+
'Recharging', 'Rechecking', 'Rechristening', 'Recirculating', 'Reciting', 'Reclaiming', 'Reclassifying', 'Reclining', 'Recognizing', 'Recoiling',
|
|
1209
|
+
'Recollecting', 'Recommending', 'Recommissioning', 'Recompiling', 'Reconciling', 'Reconfiguring', 'Reconsidering', 'Reconstituting', 'Reconstructing', 'Recording',
|
|
1210
|
+
'Recounting', 'Recouping', 'Recreating', 'Recruiting', 'Rectifying', 'Recuperating', 'Recurring', 'Recycling', 'Redacting', 'Redefining',
|
|
1211
|
+
'Redeploying', 'Redesigning', 'Redeveloping', 'Redialing', 'Redirecting', 'Rediscovering', 'Redistributing', 'Redoing', 'Redoubling', 'Redrafting',
|
|
1212
|
+
'Reducing', 'Reeling', 'Refactoring', 'Referencing', 'Referring', 'Refilling', 'Refinancing', 'Refining', 'Refitting', 'Reflecting',
|
|
1213
|
+
'Reformatting', 'Reforming', 'Refracting', 'Refraining', 'Refreshing', 'Refueling', 'Refunding', 'Refurbishing', 'Refuting', 'Regaining',
|
|
1214
|
+
'Regaling', 'Regarding', 'Regenerating', 'Registering', 'Regressing', 'Regretting', 'Regrouping', 'Regulating', 'Rehabilitating', 'Rehearsing',
|
|
1215
|
+
'Reigning', 'Reimbursing', 'Reinforcing', 'Reining', 'Reinstalling', 'Reinstating', 'Reintegrating', 'Reinterpreting', 'Reintroducing', 'Reinventing',
|
|
1216
|
+
'Reinvesting', 'Reissuing', 'Reiterating', 'Rejecting', 'Rejoicing', 'Rejoining', 'Rejuvenating', 'Rekindling', 'Relating', 'Relaxing',
|
|
1217
|
+
'Relaying', 'Releasing', 'Relegating', 'Relenting', 'Relieving', 'Relighting', 'Relishing', 'Reliving', 'Reloading', 'Relocating',
|
|
1218
|
+
'Relying', 'Remaining', 'Remanding', 'Remarking', 'Remarrying', 'Remastering', 'Remedying', 'Remembering', 'Reminding', 'Reminiscing',
|
|
1219
|
+
'Remitting', 'Remodeling', 'Remolding', 'Remonstrating', 'Removing', 'Rendering', 'Rendezvousing', 'Reneging', 'Renegotiating', 'Renewing',
|
|
1220
|
+
'Renouncing', 'Renovating', 'Renting', 'Reopening', 'Reorganizing', 'Repacking', 'Repairing', 'Repaying', 'Repealing', 'Repeating',
|
|
1221
|
+
'Repelling', 'Repenting', 'Repercussing', 'Replacing', 'Replaying', 'Replenishing', 'Replicating', 'Replying', 'Repointing', 'Reporting',
|
|
1222
|
+
'Reposing', 'Repositioning', 'Representing', 'Repressing', 'Reprimanding', 'Reprinting', 'Reproaching', 'Reprocessing', 'Reproducing', 'Reprogramming',
|
|
1223
|
+
'Reproving', 'Repulsing', 'Repurposing', 'Requesting', 'Requiring', 'Requisitioning', 'Requiting', 'Rescinding', 'Rescuing', 'Researching',
|
|
1224
|
+
'Reselling', 'Resembling', 'Resenting', 'Reserving', 'Resetting', 'Resettling', 'Reshaping', 'Residing', 'Resigning', 'Resisting',
|
|
1225
|
+
'Reskilling', 'Resoling', 'Resolving', 'Resonating', 'Resorting', 'Resounding', 'Respecting', 'Respiring', 'Responding', 'Restarting',
|
|
1226
|
+
'Restating', 'Resting', 'Restocking', 'Restoring', 'Restraining', 'Restricting', 'Restructuring', 'Resulting', 'Resuming', 'Resurfacing',
|
|
1227
|
+
'Resurging', 'Resurrecting', 'Retailing', 'Retaining', 'Retaliating', 'Retelling', 'Rethinking', 'Retiring', 'Retorting', 'Retouching',
|
|
1228
|
+
'Retracing', 'Retracting', 'Retraining', 'Retranslating', 'Retreating', 'Retrieving', 'Retrofitting', 'Returning', 'Reunifying', 'Reuniting',
|
|
1229
|
+
'Reusing', 'Revealing', 'Reveling', 'Revenging', 'Reverberating', 'Revering', 'Reversing', 'Reverting', 'Reviewing', 'Reviling',
|
|
1230
|
+
'Revising', 'Revisiting', 'Revitalizing', 'Reviving', 'Revoking', 'Revolting', 'Revolutionizing', 'Revolving', 'Rewarding', 'Rewiring',
|
|
1231
|
+
'Rewording', 'Reworking', 'Rewriting', 'Rhapsodizing', 'Rhyming', 'Ribbing', 'Ridding', 'Riding', 'Riffling', 'Rifling',
|
|
1232
|
+
'Rigging', 'Righting', 'Rimming', 'Ringing', 'Rinsing', 'Rioting', 'Ripping', 'Rippling', 'Rising', 'Risking',
|
|
1233
|
+
'Rivaling', 'Riveting', 'Roaming', 'Roaring', 'Roasting', 'Robbing', 'Robing', 'Rocking', 'Rolling', 'Romancing',
|
|
1234
|
+
'Romanticizing', 'Roofing', 'Rooming', 'Rooting', 'Roping', 'Rorting', 'Rostering', 'Rotating', 'Roting', 'Roughening',
|
|
1235
|
+
'Rounding', 'Rousing', 'Rousting', 'Roving', 'Rowing', 'Rubbing', 'Ruining', 'Ruling', 'Rumbling', 'Ruminating',
|
|
1236
|
+
'Rummaging', 'Running', 'Ruptioning', 'Rushing', 'Rusticating', 'Rustling', 'Reticulating',
|
|
924
1237
|
];
|
|
925
1238
|
|
|
926
1239
|
// ---- ANSI helpers ----
|
|
@@ -1334,10 +1647,70 @@ export function expandPastes(text, pastes) {
|
|
|
1334
1647
|
// style, so it also wiped the foreground the composer had already applied to the text AFTER the
|
|
1335
1648
|
// chip — the rest of the line came out in the terminal's default grey instead of the prompt's
|
|
1336
1649
|
// colour. `highlightSelection` below documents this exact hazard; this function was missing it.
|
|
1337
|
-
|
|
1650
|
+
//
|
|
1651
|
+
// `hoverId` is the paste number the pointer is over, when it is over one. That chip keeps its
|
|
1652
|
+
// background and takes a BRIGHTER FOREGROUND instead — `C.hover` is a foreground (bold + bright), not a
|
|
1653
|
+
// background, so using it as one would collide with the chip's own `selBg` and the two would fight.
|
|
1654
|
+
// Without any change the chip looks like ordinary text and Ctrl+click stays undiscoverable.
|
|
1655
|
+
export function highlightPasteMarkers(row, hoverId = null) {
|
|
1338
1656
|
const s = String(row);
|
|
1339
1657
|
if (!s.includes('[paste #')) return s;
|
|
1340
|
-
return s.replace(PASTE_MARKER_RE, (m) =>
|
|
1658
|
+
return s.replace(PASTE_MARKER_RE, (m, id) => {
|
|
1659
|
+
if (Number(id) === hoverId) return C.hover + C.selBg + m + C.bgReset + C.reset;
|
|
1660
|
+
return C.selBg + m + C.bgReset;
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Which paste number, if any, occupies this input index. Returns null when none does.
|
|
1665
|
+
* Which paste number, if any, occupies this input index. Returns null when none does.
|
|
1666
|
+
*
|
|
1667
|
+
* The companion to `expandPasteMarkerAt`: that one acts on the chip under the pointer, this one merely
|
|
1668
|
+
* names it so the row can be drawn differently. Both need the same bounds, and a chip is inclusive of its
|
|
1669
|
+
* end because the caret index rounds to the position after the last character.
|
|
1670
|
+
*/
|
|
1671
|
+
export function pasteMarkerIdAt(input, idx) {
|
|
1672
|
+
const cur = String(input == null ? '' : input);
|
|
1673
|
+
if (!cur.includes('[paste #')) return null;
|
|
1674
|
+
for (const m of cur.matchAll(PASTE_MARKER_RE)) {
|
|
1675
|
+
const at = m.index;
|
|
1676
|
+
if (idx >= at && idx <= at + m[0].length) return Number(m[1]);
|
|
1677
|
+
}
|
|
1678
|
+
return null;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* Expand the collapsed paste the caret index lands on, in place. Returns true when one was expanded.
|
|
1684
|
+
*
|
|
1685
|
+
* Takes the ALREADY-CONVERTED index. An earlier version took the row and column and called
|
|
1686
|
+
* `composerTextIndexAt` itself, which subtracts the row's prefix width — but the caller had already
|
|
1687
|
+
* subtracted it, so every click landed two columns to the left of where the pointer was and a click on
|
|
1688
|
+
* the chip's first character expanded nothing.
|
|
1689
|
+
*
|
|
1690
|
+
* `state.pastes` keeps its entry: `expandPastes` is a no-op for text with no marker, so leaving it costs
|
|
1691
|
+
* nothing, and dropping it would break a later marker that still refers to the same paste.
|
|
1692
|
+
*/
|
|
1693
|
+
export function expandPasteMarkerAt(state, idx) {
|
|
1694
|
+
const cur = state.input || '';
|
|
1695
|
+
if (!cur.includes('[paste #')) return false;
|
|
1696
|
+
|
|
1697
|
+
for (const m of cur.matchAll(PASTE_MARKER_RE)) {
|
|
1698
|
+
const at = m.index;
|
|
1699
|
+
const end = at + m[0].length;
|
|
1700
|
+
// The caret index is where the click landed, and clicking any character of the chip targets it —
|
|
1701
|
+
// including the character after the last one, which is where a click on the closing bracket rounds to.
|
|
1702
|
+
if (idx < at || idx > end) continue;
|
|
1703
|
+
const entry = state.pastes && state.pastes.get(Number(m[1]));
|
|
1704
|
+
if (!entry) return false;
|
|
1705
|
+
state.input = cur.slice(0, at) + entry.text + cur.slice(end);
|
|
1706
|
+
// The caret goes AFTER the expanded text, so the next keystroke continues where it ends rather than
|
|
1707
|
+
// landing in the middle of the paste the user just revealed.
|
|
1708
|
+
state.caret = at + entry.text.length;
|
|
1709
|
+
state.composerSel = null;
|
|
1710
|
+
refreshMenu(state);
|
|
1711
|
+
return true;
|
|
1712
|
+
}
|
|
1713
|
+
return false;
|
|
1341
1714
|
}
|
|
1342
1715
|
|
|
1343
1716
|
// Apply selection background to the text portion of a composer row.
|
|
@@ -1642,9 +2015,28 @@ const KEY_ARG = {
|
|
|
1642
2015
|
TaskStop: ['task_id'],
|
|
1643
2016
|
};
|
|
1644
2017
|
const MAX_ARG = 60;
|
|
1645
|
-
|
|
2018
|
+
/**
|
|
2019
|
+
* The one argument worth showing next to a tool's name. `expanded` lifts the length cut.
|
|
2020
|
+
*
|
|
2021
|
+
* The cut exists so the single `● Used Bash (…)` row stays readable: a `node -e "…"` one-liner is
|
|
2022
|
+
* routinely hundreds of characters, and printing it whole wrapped the row across the screen for
|
|
2023
|
+
* every call. Ctrl+O is the escape hatch — whoever expands tool output to read it wants to see the
|
|
2024
|
+
* command they are reading the output OF, and the command is what they asked for in the first place.
|
|
2025
|
+
*/
|
|
2026
|
+
function keyArgument(name, args, workspace, expanded = false) {
|
|
1646
2027
|
if (!args || typeof args !== 'object') return '';
|
|
1647
2028
|
const keys = KEY_ARG[name] || Object.keys(args);
|
|
2029
|
+
// Expanded: the whole value, with newlines kept, so a multi-line script shows as the lines it was
|
|
2030
|
+
// written as rather than being flattened to its first line.
|
|
2031
|
+
if (expanded) {
|
|
2032
|
+
for (const k of keys) {
|
|
2033
|
+
const v = args[k];
|
|
2034
|
+
if (typeof v !== 'string' || !v.length) continue;
|
|
2035
|
+
const full = sanitizeText(v).replace(/\r\n/g, '\n');
|
|
2036
|
+
return (k === 'path' || k === 'file_path') ? full.replace(/\\/g, '/') : full;
|
|
2037
|
+
}
|
|
2038
|
+
return '';
|
|
2039
|
+
}
|
|
1648
2040
|
for (const k of keys) {
|
|
1649
2041
|
const v = args[k];
|
|
1650
2042
|
if (typeof v !== 'string' || !v.length) continue;
|
|
@@ -1683,95 +2075,68 @@ function keyArgument(name, args, workspace) {
|
|
|
1683
2075
|
}
|
|
1684
2076
|
return '';
|
|
1685
2077
|
}
|
|
1686
|
-
export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge) {
|
|
2078
|
+
export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded = false) {
|
|
1687
2079
|
const name = msg.toolName || '';
|
|
1688
|
-
const arg = keyArgument(name, msg.toolArgs, workspace);
|
|
2080
|
+
const arg = keyArgument(name, msg.toolArgs, workspace, expanded);
|
|
1689
2081
|
const verb = msg.pending ? 'Using' : 'Used';
|
|
1690
2082
|
// Pending tool calls have a sweep animation (like "Working...")
|
|
1691
2083
|
// Once the tool finishes (pending=false) the name shows in plain cyan.
|
|
1692
2084
|
let nameDisplay;
|
|
1693
2085
|
if (msg.pending) {
|
|
1694
|
-
//
|
|
1695
|
-
//
|
|
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.
|
|
2086
|
+
// A light flashes across the tool name roughly once a second: the name stays plain
|
|
2087
|
+
// cyan, and a lamp slightly whiter than cyan runs over it from left to right.
|
|
1703
2088
|
//
|
|
1704
|
-
//
|
|
1705
|
-
//
|
|
1706
|
-
//
|
|
1707
|
-
//
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
//
|
|
1714
|
-
//
|
|
1715
|
-
//
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1718
|
-
//
|
|
1719
|
-
//
|
|
1720
|
-
//
|
|
1721
|
-
//
|
|
1722
|
-
//
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
//
|
|
1733
|
-
//
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
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('');
|
|
2089
|
+
// This replaces a two-phase colour sweep (cyan → pale cyan → back, then cyan →
|
|
2090
|
+
// cyan-blue → back) whose band was half the row wide. That band left every character
|
|
2091
|
+
// changed colour behind it, so the name ended up entirely pale or entirely blue — a
|
|
2092
|
+
// wash rather than a highlight, and on a long tool name the moving edge was hard to
|
|
2093
|
+
// see at all because so much of the row was lit at once.
|
|
2094
|
+
//
|
|
2095
|
+
// The light is now a QUARTER of a character wide, which is what makes it read as a
|
|
2096
|
+
// glint travelling along the name rather than a region changing colour.
|
|
2097
|
+
//
|
|
2098
|
+
// Phase is measured from the tick the row began on, so a spinner already running does
|
|
2099
|
+
// not drop the light into the middle of its travel.
|
|
2100
|
+
//
|
|
2101
|
+
// `??`, NOT `||`: `pulseStart` is legitimately 0 at the START of a turn (that is where
|
|
2102
|
+
// the spinner begins), and `0 || spin` evaluates to `spin`, making `spin - spin === 0`
|
|
2103
|
+
// forever — the light would then sit still instead of moving. Only `null`/`undefined`
|
|
2104
|
+
// mean "no anchor yet".
|
|
2105
|
+
// The phase comes from a WALL CLOCK, not from the tick count.
|
|
2106
|
+
//
|
|
2107
|
+
// That is what makes the light smooth. The spinner advances `spin` every 80ms while the render path
|
|
2108
|
+
// redraws far more often, so a phase derived from `spin` steps in 80ms jumps however often it is
|
|
2109
|
+
// redrawn — which is exactly the per-cell hopping this replaced. The tick count still says WHERE the
|
|
2110
|
+
// animation began (a spinner already running must not drop the light into mid-travel); it is turned
|
|
2111
|
+
// once into a wall-clock origin that later frames measure against.
|
|
2112
|
+
//
|
|
2113
|
+
// The origin is held in a module-level WeakMap keyed by nothing but the anchor, rather than on
|
|
2114
|
+
// `state`. This function is module-level and `state` lives inside startTUI's closure: reading it
|
|
2115
|
+
// here threw "state is not defined" the moment a pending tool row painted, surfaced as an
|
|
2116
|
+
// unhandledRejection that exited the whole TUI. The origin is the only per-row animation state, and
|
|
2117
|
+
// it belongs to the module for exactly that reason.
|
|
2118
|
+
//
|
|
2119
|
+
// `??`, NOT `||`: tick 0 is a legitimate anchor, and `0 || spin` would collapse the elapsed time to
|
|
2120
|
+
// zero so the light never moved at all.
|
|
2121
|
+
const anchorSpin = pulseStart ?? spin;
|
|
2122
|
+
// THE KEY MUST NOT REPEAT. `pulseStart` is a tick count within a turn, so two different rows can
|
|
2123
|
+
// reach the same value; when that happened, the second row found the first one's origin still in the
|
|
2124
|
+
// map and inherited an elapsed time far past its own start, snapping the light to a position mid-row.
|
|
2125
|
+
//
|
|
2126
|
+
// The message's own tool-call id is unique per row and stable across frames, which is exactly what a
|
|
2127
|
+
// key needs. `anchorSpin` stays in it so the same message re-animated on a new tick gets a new origin.
|
|
2128
|
+
const rowKey = msg && (msg.id || msg.toolId) ? `tool:${msg.id || msg.toolId}:${anchorSpin}` : `tool:${anchorSpin}`;
|
|
2129
|
+
const elapsedMs = Math.max(0, Date.now() - lightOrigin(rowKey, anchorSpin, spin));
|
|
2130
|
+
// ONE shimmer for both rows — see `shimmerText`.
|
|
2131
|
+
const phaseMs = elapsedMs % cycleMs(name.length);
|
|
2132
|
+
|
|
2133
|
+
// The same restraint as the Working row's glint: the light is the base colour with a small lift in
|
|
2134
|
+
// the SAME hue, not a jump to white. `[150, 255, 255]` against `[0, 215, 255]` read as "the name is
|
|
2135
|
+
// now pale" — a state change — where this reads as a sheen passing over it.
|
|
2136
|
+
const BASE = [0, 215, 255]; // cyan: the name's resting colour
|
|
2137
|
+
const GLINT = [110, 240, 255]; // the same hue, a shade brighter
|
|
2138
|
+
|
|
2139
|
+
nameDisplay = shimmerText(name, phaseMs, BASE, GLINT, shimmerEdge || 'cosine');
|
|
1775
2140
|
} else {
|
|
1776
2141
|
// Non-pending: show in plain cyan
|
|
1777
2142
|
nameDisplay = col(name, C.cyan);
|
|
@@ -1795,10 +2160,23 @@ export function formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge) {
|
|
|
1795
2160
|
diffStr = ' ' + (adds ? col(`+${adds}`, C.green) : '') + ' ' + (deletes ? col(`-${deletes}`, C.red) : '');
|
|
1796
2161
|
}
|
|
1797
2162
|
}
|
|
1798
|
-
//
|
|
1799
|
-
//
|
|
1800
|
-
//
|
|
1801
|
-
|
|
2163
|
+
// COLORED PER LINE, not around the whole string.
|
|
2164
|
+
//
|
|
2165
|
+
// `col(text)` writes one escape pair around everything, so a multi-line argument — exactly what Ctrl+O
|
|
2166
|
+
// reveals, the body of a script — had its colour open on line 1 and, after the newline, no colour at all:
|
|
2167
|
+
// the terminal's reset made every line below the first render in the DEFAULT foreground, one shade
|
|
2168
|
+
// brighter than the grey the parentheses were given. Splitting on the newline and colouring each piece
|
|
2169
|
+
// keeps the whole block the same grey. The parentheses still wrap the WHOLE value, so a multi-line script
|
|
2170
|
+
// reads as one bracketed argument rather than as several.
|
|
2171
|
+
let argText = '';
|
|
2172
|
+
if (arg) {
|
|
2173
|
+
const lines = `${arg}`.split('\n');
|
|
2174
|
+
const painted = lines.map((l, i) => col(
|
|
2175
|
+
(i === 0 ? '(' : '') + l + (i === lines.length - 1 ? ')' : ''),
|
|
2176
|
+
C.gray,
|
|
2177
|
+
));
|
|
2178
|
+
argText = ' ' + painted.join('\n');
|
|
2179
|
+
}
|
|
1802
2180
|
return col(verb, C.white) + ' ' + nameDisplay + argText + col(sizeStr, C.gray) + diffStr;
|
|
1803
2181
|
}
|
|
1804
2182
|
|
|
@@ -1851,6 +2229,10 @@ function todoRow(todo, w) {
|
|
|
1851
2229
|
function renderTodoPanel(state, w, hoverTop, dragTop) {
|
|
1852
2230
|
const todos = state.todos || [];
|
|
1853
2231
|
if (!todos.length) return [];
|
|
2232
|
+
// A finished list draws NOTHING, matching `todoPanelHeight` returning 0 for the same state. Both ask
|
|
2233
|
+
// the same question, so the reserved height and the drawn rows cannot disagree — a mismatch would
|
|
2234
|
+
// shift every row below the panel.
|
|
2235
|
+
if (todoListFinished(state)) return [];
|
|
1854
2236
|
const out = [];
|
|
1855
2237
|
const ruleColor = dragTop ? C.scrollThumbActive : (hoverTop ? C.hover : C.border);
|
|
1856
2238
|
out.push(col('─'.repeat(w), ruleColor));
|
|
@@ -1947,9 +2329,31 @@ export function queuePanelHeight(state) {
|
|
|
1947
2329
|
// * `state.todoRows` (set by dragging the top rule) wins when present;
|
|
1948
2330
|
// * otherwise the automatic heuristic (selectVisibleTodos) decides.
|
|
1949
2331
|
// Always clamped to [1, todos.length]: at least ONE row, never more than exist.
|
|
2332
|
+
/**
|
|
2333
|
+
* Is this list FINISHED — every item done, and at least one item?
|
|
2334
|
+
*
|
|
2335
|
+
* A finished list is DROPPED, not merely hidden: it has stopped being a list of what is left to do, so
|
|
2336
|
+
* the data goes as well as the panel. Hiding alone left the array to be persisted with the session and
|
|
2337
|
+
* handed back to the model as `cfg.todoState`, which is a finished plan being described as current work.
|
|
2338
|
+
*
|
|
2339
|
+
* `every` on an EMPTY array is true, so the length check is load-bearing: this must not fire for "no
|
|
2340
|
+
* todos", which is a different state with its own handling everywhere else.
|
|
2341
|
+
*/
|
|
2342
|
+
export function todosAllComplete(list) {
|
|
2343
|
+
const todos = Array.isArray(list) ? list : [];
|
|
2344
|
+
return todos.length > 0 && todos.every((t) => t && t.status === 'done');
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
/** The same question, asked of a TUI state object rather than a bare array. */
|
|
2348
|
+
export function todoListFinished(state) {
|
|
2349
|
+
return todosAllComplete(state && state.todos);
|
|
2350
|
+
}
|
|
2351
|
+
|
|
1950
2352
|
export function todoRowCount(state) {
|
|
1951
2353
|
const todos = state.todos || [];
|
|
1952
2354
|
if (!todos.length) return 0;
|
|
2355
|
+
// A finished list has no rows: see `todoListFinished`.
|
|
2356
|
+
if (todoListFinished(state)) return 0;
|
|
1953
2357
|
const manual = state.todoRows;
|
|
1954
2358
|
let rows;
|
|
1955
2359
|
if (typeof manual === 'number' && Number.isFinite(manual)) {
|
|
@@ -1964,6 +2368,10 @@ export function todoRowCount(state) {
|
|
|
1964
2368
|
export function todoPanelHeight(state) {
|
|
1965
2369
|
const todos = state.todos || [];
|
|
1966
2370
|
if (!todos.length) return 0;
|
|
2371
|
+
// Zero once everything is done, so the chrome shrinks by the panel's whole height rather than leaving
|
|
2372
|
+
// a blank gap where it used to be. Both this and the renderer ask the same predicate, so the reserved
|
|
2373
|
+
// height and the drawn rows cannot disagree — a mismatch there shifts every row below.
|
|
2374
|
+
if (todoListFinished(state)) return 0;
|
|
1967
2375
|
const rows = todoRowCount(state);
|
|
1968
2376
|
const extra = todos.length > rows ? 1 : 0; // the "… +N more" hint
|
|
1969
2377
|
return 2 + rows + extra; // rule + heading + rows (+ hint)
|
|
@@ -1989,20 +2397,33 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
|
|
|
1989
2397
|
// Tool calls render as a single "● Using/Used Name (arg)" line (kimi style).
|
|
1990
2398
|
// The body already carries its own ANSI colours, so mark it pre-colored.
|
|
1991
2399
|
if (msg.role === 'tool') {
|
|
1992
|
-
const body = formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge);
|
|
2400
|
+
const body = formatToolLine(msg, workspace, spin, pulseStart, shimmerEdge, expanded);
|
|
1993
2401
|
// Red bullet for a failed call (a Bash non-zero exit / [error: …]).
|
|
1994
2402
|
const failed = msg.failed === true || (msg.role === 'tool' && msg.failed === true);
|
|
1995
2403
|
const pre = col('● ', msg.pending ? C.orange : (failed ? C.red : C.green));
|
|
1996
2404
|
const bodyW = Math.max(1, width - visualCol('● '));
|
|
1997
|
-
|
|
1998
|
-
//
|
|
1999
|
-
//
|
|
2000
|
-
//
|
|
2001
|
-
//
|
|
2002
|
-
//
|
|
2003
|
-
//
|
|
2405
|
+
// EXPANDED: break the body at the newlines the command actually has, so a multi-line script renders
|
|
2406
|
+
// as the lines it was written as. `wrapAnsiWords` alone flattens a newline into a space, turning
|
|
2407
|
+
// `set -e\nnpm ci\nnpm test` into one long reflowed line — and the reason to expand is to read the
|
|
2408
|
+
// script, not a reflow of it.
|
|
2409
|
+
//
|
|
2410
|
+
// Collapsed, there are no newlines to honour: `keyArgument` already reduced the value to one line
|
|
2411
|
+
// plus a count, so this is a single chunk and the wrap behaves exactly as before.
|
|
2412
|
+
const segments = expanded && body.includes('\n') ? body.split('\n') : [body];
|
|
2413
|
+
// Mark every CONTINUATION boundary with a trailing `…` so a wrapped command never looks like it
|
|
2414
|
+
// ended at a quote or `;`. Without this, a command like
|
|
2415
|
+
// `$ProgressPreference='SilentlyContinue'; Invoke-WebRequest …` broke right after the closing
|
|
2416
|
+
// quote, and the first row read as the whole command. A boundary that is the command's OWN newline
|
|
2417
|
+
// is not a continuation and gets no mark — the break is already unmistakable.
|
|
2418
|
+
const wrapped = [];
|
|
2419
|
+
for (const segment of segments) {
|
|
2420
|
+
const parts = wrapAnsiWords(segment, bodyW);
|
|
2421
|
+
for (let i = 0; i < parts.length; i++) {
|
|
2422
|
+
wrapped.push(i < parts.length - 1 ? parts[i] + col('…', C.gray) : parts[i]);
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2004
2425
|
const out = wrapped.map((t, i) => ({
|
|
2005
|
-
text:
|
|
2426
|
+
text: t,
|
|
2006
2427
|
ind: i === 0 ? pre : ' ',
|
|
2007
2428
|
raw: true, // text already contains ANSI; do not wrap in another colour
|
|
2008
2429
|
}));
|
|
@@ -2073,16 +2494,24 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
|
|
|
2073
2494
|
// one the trailing newline produced (an intentionally blank last line survives
|
|
2074
2495
|
// as long as the content does not end with a newline).
|
|
2075
2496
|
if (lines.length > 1 && lines[lines.length - 1] === '' && /\n$/.test(head)) lines.pop();
|
|
2076
|
-
|
|
2497
|
+
// CTRL+O EXPANDS IT, like every other body in the transcript. This block was
|
|
2498
|
+
// the one place that hard-coded a preview and never consulted `expanded`, so
|
|
2499
|
+
// a 500-line file could only ever show its first 20 lines — the summary said
|
|
2500
|
+
// "… 480 more lines" and there was no key that would reveal them.
|
|
2501
|
+
//
|
|
2502
|
+
// The expanded cap is the number of lines actually read, not `Infinity`: the
|
|
2503
|
+
// 64 KB prefix above is what exists, and claiming more would print nothing
|
|
2504
|
+
// extra while looking like it should.
|
|
2505
|
+
const MAX = expanded ? lines.length : 20;
|
|
2077
2506
|
// The TRUE line count comes from the message: `lines` only covers the scanned
|
|
2078
2507
|
// prefix, so counting it would understate a large file's size.
|
|
2079
|
-
const totalLines = msg._streamLineCount || lines.length;
|
|
2508
|
+
const totalLines = expanded ? lines.length : (msg._streamLineCount || lines.length);
|
|
2080
2509
|
const wNum = String(totalLines).length;
|
|
2081
2510
|
lines.slice(0, MAX).forEach((ln, i) => {
|
|
2082
2511
|
out.push({ text: col(`${String(i + 1).padStart(wNum)} ${ln}`, C.gray), ind: i === 0 ? '↳ ' : indent, raw: true });
|
|
2083
2512
|
});
|
|
2084
2513
|
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 });
|
|
2514
|
+
if (totalLines > shown) out.push({ text: col(`… ${totalLines - shown} more lines, ctrl+o to expand`, C.gray), ind: indent, raw: true });
|
|
2086
2515
|
}
|
|
2087
2516
|
|
|
2088
2517
|
// The Edit diff is NOT drawn here: it rides on the tool_result message and is
|
|
@@ -2150,13 +2579,16 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
|
|
|
2150
2579
|
const indent = ' ';
|
|
2151
2580
|
const avail = Math.max(1, width - visualCol(indent));
|
|
2152
2581
|
const lines = body.replace(/\r\n/g, '\n').split('\n');
|
|
2153
|
-
|
|
2582
|
+
// No cap once expanded. 200 was a silent ceiling: a long result stopped there with a hint telling
|
|
2583
|
+
// the user to press the key they had already pressed.
|
|
2584
|
+
const MAX = expanded ? lines.length : 6;
|
|
2154
2585
|
for (const ln of lines.slice(0, MAX)) {
|
|
2155
2586
|
for (const w of (ln === '' ? [''] : wrapWords(ln, avail))) {
|
|
2156
2587
|
out.push({ text: col(w, C.gray), ind: indent, raw: true });
|
|
2157
2588
|
}
|
|
2158
2589
|
}
|
|
2159
2590
|
if (lines.length > MAX) {
|
|
2591
|
+
// Only reachable while COLLAPSED: expanded, `MAX` is the whole list and this cannot fire.
|
|
2160
2592
|
out.push({ text: col(`… ${lines.length - MAX} more lines, ctrl+o to expand`, C.gray), ind: indent, raw: true });
|
|
2161
2593
|
}
|
|
2162
2594
|
}
|
|
@@ -2260,7 +2692,15 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
|
|
|
2260
2692
|
});
|
|
2261
2693
|
});
|
|
2262
2694
|
if (visible.length > MAX_RESULT_LINES) {
|
|
2263
|
-
|
|
2695
|
+
// The hint says HOW to see the rest. It used to read only "… N more lines", so a truncated
|
|
2696
|
+
// result gave the user no way to know Ctrl+O would reveal it — the key existed and was
|
|
2697
|
+
// undiscoverable from the one place that needed it. Only reachable while collapsed: expanded,
|
|
2698
|
+
// `MAX_RESULT_LINES` is Infinity and this cannot fire.
|
|
2699
|
+
diffRows.push({
|
|
2700
|
+
text: `… ${visible.length - MAX_RESULT_LINES} more lines, ctrl+o to expand`,
|
|
2701
|
+
ind: cont,
|
|
2702
|
+
color: failed ? C.red : C.gray,
|
|
2703
|
+
});
|
|
2264
2704
|
}
|
|
2265
2705
|
}
|
|
2266
2706
|
|
|
@@ -2447,6 +2887,24 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
|
|
|
2447
2887
|
}
|
|
2448
2888
|
return lines;
|
|
2449
2889
|
}
|
|
2890
|
+
// A MESSAGE FROM A TEAMMATE.
|
|
2891
|
+
//
|
|
2892
|
+
// Drawn like a user row but labelled with the sender, because these arrive OUTSIDE the turn loop —
|
|
2893
|
+
// a member writes whenever it has something to say, and the person must be able to read it without
|
|
2894
|
+
// the model choosing to repeat it. `from` is the member's roster name.
|
|
2895
|
+
//
|
|
2896
|
+
// The body is wrapped to the same inner width the user boxes use, so a long answer does not run past
|
|
2897
|
+
// the right edge and take the row's colour with it.
|
|
2898
|
+
if (msg.role === 'team') {
|
|
2899
|
+
const who = String(msg.from || 'team');
|
|
2900
|
+
lines.push({ text: prefix + C.bold + col(who, C.cyan) + col(' → you', C.gray), ind: '', raw: true });
|
|
2901
|
+
const w = Math.max(1, width - 2);
|
|
2902
|
+
for (const ln of String(msg.text || '').replace(/\r\n/g, '\n').split('\n')) {
|
|
2903
|
+
if (ln === '') { lines.push({ text: '', ind: ' ', raw: true }); continue; }
|
|
2904
|
+
for (const seg of wrapWords(ln, w)) lines.push({ text: seg, ind: ' ', color: fg });
|
|
2905
|
+
}
|
|
2906
|
+
return lines;
|
|
2907
|
+
}
|
|
2450
2908
|
// A pasted IMAGE. The row NAMES the file: that is the receipt for what the model received,
|
|
2451
2909
|
// and it is the only thing shown, because the terminal is not asked to draw a picture.
|
|
2452
2910
|
if (msg.role === 'image') {
|
|
@@ -2702,7 +3160,7 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
|
|
|
2702
3160
|
const out = [];
|
|
2703
3161
|
const t = line.replace(/\s+$/, '');
|
|
2704
3162
|
|
|
2705
|
-
// Headings: `#
|
|
3163
|
+
// Headings: `# Title`
|
|
2706
3164
|
let m = /^(\s*)(#{1,6})\s+(.*)$/.exec(t);
|
|
2707
3165
|
if (m && !isContinuation) {
|
|
2708
3166
|
const hashCount = m[2].length;
|
|
@@ -3377,8 +3835,9 @@ export function renderChatLines(state, w, viewport) {
|
|
|
3377
3835
|
let ccc = msg._cache;
|
|
3378
3836
|
if (!ccc || !ccc.rows || ccc.rows.length !== len - (bordered ? (shared ? 1 : 2) : 0)) {
|
|
3379
3837
|
const bw = bordered ? boxText : innerW;
|
|
3380
|
-
const
|
|
3381
|
-
state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge))
|
|
3838
|
+
const src = messageLines(msg, bw, state.cwd, expanded, state.spin, state.pulseStart, state.rawMode,
|
|
3839
|
+
state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge));
|
|
3840
|
+
const built = src.map(rowToLine);
|
|
3382
3841
|
ccc = { rows: built, spinRows: null };
|
|
3383
3842
|
for (let i = 0; i < built.length; i++) {
|
|
3384
3843
|
if (built[i].indexOf(SPIN_PLACEHOLDER) >= 0) {
|
|
@@ -3394,8 +3853,9 @@ export function renderChatLines(state, w, viewport) {
|
|
|
3394
3853
|
if (bordered) {
|
|
3395
3854
|
const pad = ' '.repeat(boxPad);
|
|
3396
3855
|
const wall = col('│', boxColor);
|
|
3397
|
-
for (let i = 0; i < ccc.rows.length; i++)
|
|
3856
|
+
for (let i = 0; i < ccc.rows.length; i++) {
|
|
3398
3857
|
out.push(PAD + wall + pad + fitAnsi(liveAt(ccc.rows[i], i), boxText) + pad + wall);
|
|
3858
|
+
}
|
|
3399
3859
|
} else {
|
|
3400
3860
|
for (let i = 0; i < ccc.rows.length; i++) out.push(PAD + liveAt(ccc.rows[i], i));
|
|
3401
3861
|
}
|
|
@@ -3405,6 +3865,10 @@ export function renderChatLines(state, w, viewport) {
|
|
|
3405
3865
|
}
|
|
3406
3866
|
cursor += len;
|
|
3407
3867
|
}
|
|
3868
|
+
// NO SELECTION HERE. `state.selection` is in SCREEN rows and this function does not know where its
|
|
3869
|
+
// rows will land — the chat body starts below whatever a plan table or prompt box has already drawn.
|
|
3870
|
+
// `composeFrame` paints the selection instead, at the point where both the body's origin and the
|
|
3871
|
+
// selection's rows are known. See `applySelection`.
|
|
3408
3872
|
return out;
|
|
3409
3873
|
}
|
|
3410
3874
|
|
|
@@ -3414,7 +3878,12 @@ export function renderChatLines(state, w, viewport) {
|
|
|
3414
3878
|
export function metricsSignature(state, n, w, expanded, innerW) {
|
|
3415
3879
|
let h = 2166136261 >>> 0;
|
|
3416
3880
|
const mix = (v) => { h ^= (v | 0); h = Math.imul(h, 16777619) >>> 0; };
|
|
3417
|
-
|
|
3881
|
+
// NOT `state.spin`. The spin changes four times a second and only ever picks which glyph an animation
|
|
3882
|
+
// shows; it never changes the row count. Including it invalidated the memo on every tick, so each wheel
|
|
3883
|
+
// event walked and re-rendered the whole transcript just to read back the total it already had — the
|
|
3884
|
+
// stall when scrolling a long session. A spinner frame is a per-row concern, handled by the rows' own
|
|
3885
|
+
// `valid` checks, which still run.
|
|
3886
|
+
mix(n); mix(w); mix(innerW); mix(expanded ? 1 : 0);
|
|
3418
3887
|
// THE PALETTE. This signature decides whether the WHOLE transcript is walked at all, so a
|
|
3419
3888
|
// theme switch that left it alone skipped the per-message `valid` check too — every cached
|
|
3420
3889
|
// row kept the colours it was rendered with, which is why the chrome repainted and the
|
|
@@ -3442,6 +3911,7 @@ export function metricsSignature(state, n, w, expanded, innerW) {
|
|
|
3442
3911
|
return h;
|
|
3443
3912
|
}
|
|
3444
3913
|
|
|
3914
|
+
|
|
3445
3915
|
/**
|
|
3446
3916
|
* Total transcript rows WITHOUT building a single rendered string. Reuses the
|
|
3447
3917
|
* memo from the last renderChatLines when the transcript and layout are unchanged
|
|
@@ -3759,10 +4229,41 @@ function proseSummary(value) {
|
|
|
3759
4229
|
return `${lines.length} line${lines.length === 1 ? '' : 's'}, ${text.length} chars`;
|
|
3760
4230
|
}
|
|
3761
4231
|
|
|
4232
|
+
/**
|
|
4233
|
+
* The `[2s | ↓40]` tail shown at the end of the Working row.
|
|
4234
|
+
*
|
|
4235
|
+
* The arrow says which direction the count is: tokens this turn has RECEIVED, as opposed to the context
|
|
4236
|
+
* gauge, which also counts what has been sent.
|
|
4237
|
+
*/
|
|
3762
4238
|
function elapsedTail(ms, tokens) {
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
4239
|
+
// The token count is shown FROM THE FIRST FRAME, with a downward arrow to say what it is: what the
|
|
4240
|
+
// turn has received. It used to be hidden for thirty seconds, on the theory that a small number is
|
|
4241
|
+
// noise on a short turn — but the row is the only place that number exists, and a count that appears
|
|
4242
|
+
// partway through reads as something that just started rather than as a running total.
|
|
4243
|
+
return `${fmtDuration(ms)} | ↓ ${fmtTokensShort(tokens)}`;
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4246
|
+
/**
|
|
4247
|
+
* Has the turn gone quiet, and how much should that show?
|
|
4248
|
+
*
|
|
4249
|
+
* A turn that produces no tokens for a few seconds is either thinking, waiting on a tool, or stuck — and
|
|
4250
|
+
* from outside those look identical. The one case that matters is the last: a request that hangs leaves
|
|
4251
|
+
* the row cheerfully spinning forever with no way to tell it apart from a long answer.
|
|
4252
|
+
*
|
|
4253
|
+
* Mirrors Claude Code's `useStalledAnimation`: AFTER 3s with no new output, fade toward the warning colour
|
|
4254
|
+
* over another 2s, and NEVER while a tool is running — a `npm install` that takes a minute is working, not
|
|
4255
|
+
* stalled, and flashing red at it would train the user to ignore the signal.
|
|
4256
|
+
*
|
|
4257
|
+
* Returns 0 while healthy and 1 at full stall, so the caller can interpolate rather than switch abruptly.
|
|
4258
|
+
*/
|
|
4259
|
+
const STALL_AFTER_MS = 3000;
|
|
4260
|
+
const STALL_FADE_MS = 2000;
|
|
4261
|
+
|
|
4262
|
+
function stallIntensity({ sinceLastTokenMs, hasActiveTools }) {
|
|
4263
|
+
if (hasActiveTools) return 0;
|
|
4264
|
+
const quiet = Number(sinceLastTokenMs) || 0;
|
|
4265
|
+
if (quiet <= STALL_AFTER_MS) return 0;
|
|
4266
|
+
return Math.min((quiet - STALL_AFTER_MS) / STALL_FADE_MS, 1);
|
|
3766
4267
|
}
|
|
3767
4268
|
|
|
3768
4269
|
// Left/right justify: left text, then padding, then right text flush to `w`.
|
|
@@ -3970,16 +4471,30 @@ function applySelectionToRow(lines, idx, sel, w) {
|
|
|
3970
4471
|
return lines;
|
|
3971
4472
|
}
|
|
3972
4473
|
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
4474
|
+
/**
|
|
4475
|
+
* Paint the selection background over visible columns [c0, c1) of one rendered row.
|
|
4476
|
+
*
|
|
4477
|
+
* COLUMN 0 IS NEVER SELECTION. Every chat row is drawn with one column of left margin — the `PAD` the
|
|
4478
|
+
* renderer prepends — so a drag that begins at the left edge of the text paints that space too, and
|
|
4479
|
+
* copying a reply puts a leading space on the clipboard on every line. Skipping exactly column 0 is the
|
|
4480
|
+
* whole rule.
|
|
4481
|
+
*
|
|
4482
|
+
* It is one column and no more, and applied to every row regardless of what sits there. Measuring a
|
|
4483
|
+
* per-row "indent width" instead — a tool row's ` ● ` bullet, a user box's `│ ❯ ` wall — was the first
|
|
4484
|
+
* attempt and it was wrong: those columns are part of what the row DRAWS, and a selection that begins on
|
|
4485
|
+
* one of them is a selection the user made deliberately. The margin is different only because it is not
|
|
4486
|
+
* the content at all.
|
|
4487
|
+
*
|
|
4488
|
+
* The column is still drawn and still occupies its space; it is simply not selection.
|
|
4489
|
+
*/
|
|
3977
4490
|
function highlightAnsiRange(row, c0, c1, w) {
|
|
3978
4491
|
const s = String(row);
|
|
3979
4492
|
let out = '';
|
|
3980
4493
|
let col = 0;
|
|
3981
4494
|
let i = 0;
|
|
3982
4495
|
let inSel = false;
|
|
4496
|
+
// A range that begins at the margin starts one column later.
|
|
4497
|
+
const lo = Math.max(c0, 1);
|
|
3983
4498
|
|
|
3984
4499
|
while (i < s.length) {
|
|
3985
4500
|
if (s[i] === '\x1b') {
|
|
@@ -4000,7 +4515,7 @@ function highlightAnsiRange(row, c0, c1, w) {
|
|
|
4000
4515
|
const cp = s.codePointAt(i);
|
|
4001
4516
|
const ch = String.fromCodePoint(cp);
|
|
4002
4517
|
const cw = visualWidth(ch);
|
|
4003
|
-
const want = col >=
|
|
4518
|
+
const want = col >= lo && col < c1;
|
|
4004
4519
|
|
|
4005
4520
|
if (want && !inSel) {
|
|
4006
4521
|
// Selection changes only the BACKGROUND; the foreground (documented colour)
|
|
@@ -4027,6 +4542,44 @@ function highlightAnsiRange(row, c0, c1, w) {
|
|
|
4027
4542
|
return out;
|
|
4028
4543
|
}
|
|
4029
4544
|
|
|
4545
|
+
/**
|
|
4546
|
+
* Paint the mouse selection over a finished set of chat rows.
|
|
4547
|
+
*
|
|
4548
|
+
* `sel` is the selection ITSELF — `{ anchor: {row, col}, head: {row, col} }` — not `state`. It used to
|
|
4549
|
+
* take `state` and read `state.selection` out of it while the only caller passed the selection
|
|
4550
|
+
* directly, so `sel` was undefined and the function returned at its first line: the highlight silently
|
|
4551
|
+
* never appeared. Taking the range as an argument also means the caller converts screen rows to body
|
|
4552
|
+
* rows once, where both coordinate systems are known.
|
|
4553
|
+
*
|
|
4554
|
+
* Rows are indices into `rows`; a cross-row drag extends the first and last rows to the whole line,
|
|
4555
|
+
* which is what a text selection in a terminal looks like.
|
|
4556
|
+
*/
|
|
4557
|
+
function applySelection(rows, sel) {
|
|
4558
|
+
if (!sel || !sel.anchor || !sel.head) return rows;
|
|
4559
|
+
const a = sel.anchor;
|
|
4560
|
+
const h = sel.head;
|
|
4561
|
+
if (!Number.isFinite(a.row) || !Number.isFinite(h.row)) return rows;
|
|
4562
|
+
|
|
4563
|
+
// Whichever end is earlier on screen, so dragging upwards works like dragging downwards.
|
|
4564
|
+
const start = (h.row < a.row || (h.row === a.row && h.col < a.col)) ? h : a;
|
|
4565
|
+
const end = start === a ? h : a;
|
|
4566
|
+
|
|
4567
|
+
for (let i = 0; i < rows.length; i++) {
|
|
4568
|
+
if (i < start.row || i > end.row) continue;
|
|
4569
|
+
let c0;
|
|
4570
|
+
let c1;
|
|
4571
|
+
// +1: the end column is the cell under the pointer and is selected too.
|
|
4572
|
+
if (start.row === end.row) { c0 = start.col; c1 = end.col + 1; }
|
|
4573
|
+
else if (i === start.row) { c0 = start.col; c1 = Infinity; }
|
|
4574
|
+
else if (i === end.row) { c0 = 0; c1 = end.col + 1; }
|
|
4575
|
+
else { c0 = 0; c1 = Infinity; }
|
|
4576
|
+
// The width bound is the row's own length: `highlightAnsiRange` stops there anyway, and passing a
|
|
4577
|
+
// number derived from the terminal would be wrong for a row rendered at a different width.
|
|
4578
|
+
rows[i] = highlightAnsiRange(rows[i], c0, c1, Infinity);
|
|
4579
|
+
}
|
|
4580
|
+
return rows;
|
|
4581
|
+
}
|
|
4582
|
+
|
|
4030
4583
|
// Truncate an ANSI string to exactly `width` visible columns (no padding).
|
|
4031
4584
|
function sliceAnsi(s, width) {
|
|
4032
4585
|
const str = String(s);
|
|
@@ -4335,9 +4888,14 @@ export function refreshGitInfo(state, opts = {}) {
|
|
|
4335
4888
|
if (!cwd) return;
|
|
4336
4889
|
const now = Date.now();
|
|
4337
4890
|
const force = opts.force === true;
|
|
4338
|
-
// Mid-turn the agent is about to write more files, so a
|
|
4339
|
-
//
|
|
4340
|
-
//
|
|
4891
|
+
// Mid-turn the agent is about to write more files, so a POLL would only produce a value that is
|
|
4892
|
+
// stale on arrival. The turn-end refresh covers it. Explicit callers (the timer between turns,
|
|
4893
|
+
// /git) still get through with force.
|
|
4894
|
+
//
|
|
4895
|
+
// A step boundary is the exception, and it is deliberately the only one: the tool_result handler
|
|
4896
|
+
// calls this with `force` once a step has finished, because at that moment the agent has stopped
|
|
4897
|
+
// writing and the count is both current and worth looking at (see the call site in the event
|
|
4898
|
+
// handler). Without it a long turn showed an unchanging `+0 -0` while files were being edited.
|
|
4341
4899
|
if (!force && state.running) return;
|
|
4342
4900
|
state._gitCwd = cwd;
|
|
4343
4901
|
state._gitAt = now;
|
|
@@ -4941,30 +5499,17 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
4941
5499
|
const chat = state._forceFullRender
|
|
4942
5500
|
? renderChatLines(state, w)
|
|
4943
5501
|
: renderChatLines(state, w, { bodyH, scroll: state.scroll || 0 });
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
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
|
-
}
|
|
5502
|
+
// THE SELECTION IS ALREADY PAINTED, inside `renderChatLines`.
|
|
5503
|
+
//
|
|
5504
|
+
// There used to be a second pass here that re-walked the rows and highlighted them again. It is
|
|
5505
|
+
// gone, for two reasons. It re-painted rows `renderChatLines` had just painted, and its `cols`
|
|
5506
|
+
// argument named a variable that does not exist in this scope — a `ReferenceError` waiting for the
|
|
5507
|
+
// first user who dragged across a row. The body path never reached it because `chat` was empty in
|
|
5508
|
+
// the tests that exercised the window, and the crash would have surfaced as the whole TUI exiting
|
|
5509
|
+
// through the unhandledRejection handler.
|
|
5510
|
+
//
|
|
5511
|
+
// One place owns the selection now, which is also why `renderChatLines` takes no selection argument:
|
|
5512
|
+
// it reads `state.selection` itself.
|
|
4968
5513
|
const total = chat.length;
|
|
4969
5514
|
const maxScroll = Math.max(0, total - bodyH);
|
|
4970
5515
|
const scroll = Math.min(maxScroll, Math.max(0, state.scroll || 0));
|
|
@@ -4977,7 +5522,16 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
4977
5522
|
// body, so `lines.length` here IS the body's origin; deriving it afterwards as
|
|
4978
5523
|
// `lines.length - bodyH` assumes nothing came before it, which is false.
|
|
4979
5524
|
const bodyScreenTop = lines.length;
|
|
4980
|
-
|
|
5525
|
+
// The selection, in SCREEN rows. `state.selection` comes from mouse coordinates, which are screen
|
|
5526
|
+
// `state.selection` rows are ALREADY transcript indices — `mouseToCell` converts the pointer's
|
|
5527
|
+
// screen row into a body row before recording the anchor and head, subtracting the body's origin and
|
|
5528
|
+
// adding the transcript's start.
|
|
5529
|
+
//
|
|
5530
|
+
// Converting again here is what broke the highlight completely: the row went negative, no row matched,
|
|
5531
|
+
// and nothing was painted. The selection goes to `applySelection` unchanged.
|
|
5532
|
+
if (state.selection && state.selection.anchor && state.selection.head) {
|
|
5533
|
+
applySelection(chat, state.selection);
|
|
5534
|
+
}
|
|
4981
5535
|
for (let i = 0; i < bodyH; i++) {
|
|
4982
5536
|
const idx = start + i;
|
|
4983
5537
|
let row = '';
|
|
@@ -5285,11 +5839,11 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
5285
5839
|
//
|
|
5286
5840
|
// Layout:
|
|
5287
5841
|
// |Q1| Q2 <- question tabs (Tab switches), current lit
|
|
5288
|
-
//
|
|
5289
|
-
// > A
|
|
5290
|
-
// B
|
|
5842
|
+
// Which permission mode? <- the question text
|
|
5843
|
+
// > A Run reads automatically <- option: marker + label + dim desc on the same row
|
|
5844
|
+
// B Ask about everything
|
|
5291
5845
|
// Other
|
|
5292
|
-
//
|
|
5846
|
+
// up/down move · Enter select · Esc cancel
|
|
5293
5847
|
// Single-select marker is `> `; multi-select uses an empty/filled square.
|
|
5294
5848
|
if (state.question && !takeover) {
|
|
5295
5849
|
const qs = state.question;
|
|
@@ -5417,7 +5971,14 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
5417
5971
|
: '';
|
|
5418
5972
|
// The wording was chosen once at turn start; the gradient below loops
|
|
5419
5973
|
// independently, so the phrase stays put while the colours sweep.
|
|
5420
|
-
|
|
5974
|
+
//
|
|
5975
|
+
// A RUNNING COMPACTION OWNS THE PHRASE. The turn is not "working", it is compacting —
|
|
5976
|
+
// one specific long operation — and saying so is the whole point of putting it here.
|
|
5977
|
+
// `state.workMsg` is left untouched underneath, so the rotating phrase the turn began
|
|
5978
|
+
// with comes back the moment compaction ends rather than being lost.
|
|
5979
|
+
const workMsg = state.compacting
|
|
5980
|
+
? COMPACTING_MSG
|
|
5981
|
+
: (state.workMsg || WORKING_MESSAGES[0]);
|
|
5421
5982
|
const spin = state.spin || 0;
|
|
5422
5983
|
|
|
5423
5984
|
// ---- reduced motion: the resting row ----
|
|
@@ -5433,14 +5994,18 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
5433
5994
|
);
|
|
5434
5995
|
}
|
|
5435
5996
|
// ---- turn-start animation ----
|
|
5436
|
-
// 0.
|
|
5437
|
-
// 0.
|
|
5438
|
-
// After
|
|
5997
|
+
// 0.25s: type out "Working..." in grey (chars appear left→right)
|
|
5998
|
+
// 0.25s: fill with orange (chars change colour left→right)
|
|
5999
|
+
// After 0.5s total: start the normal pulse animation
|
|
5439
6000
|
else if (state.startAnim) {
|
|
5440
|
-
//
|
|
6001
|
+
// 0.5s total: 0.25s types `Working... [0s]` out in grey, then 0.25s sweeps the
|
|
5441
6002
|
// PHRASE grey → orange. The `[0s]` tail types out WITH the phrase but is
|
|
5442
6003
|
// EXCLUDED from the colour sweep — it stays grey the whole time.
|
|
5443
|
-
|
|
6004
|
+
//
|
|
6005
|
+
// Halved from 0.5s each. A full second of the row still typing itself out delayed the point
|
|
6006
|
+
// where the user could tell the turn had started, and the swaths of time are what the pulse
|
|
6007
|
+
// below is for — the entry animation only has to get the phrase on screen.
|
|
6008
|
+
const DUR = 500;
|
|
5444
6009
|
const t = Math.min(1, (Date.now() - state.startAnim.start) / DUR);
|
|
5445
6010
|
const phase = t < 0.5 ? 'type' : 'color';
|
|
5446
6011
|
const phaseT = t < 0.5 ? t * 2 : (t - 0.5) * 2; // 0→1 within each phase
|
|
@@ -5488,7 +6053,10 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
5488
6053
|
}
|
|
5489
6054
|
lines.push(col(frame, orangeEsc) + ' ' + out.join('') + elapsed);
|
|
5490
6055
|
}
|
|
5491
|
-
|
|
6056
|
+
// The cutoff has to be the SAME number as `DUR`. They were separate literals (1000 here, 1000
|
|
6057
|
+
// above) and changing one alone left the preview exiting early or the animation running on into
|
|
6058
|
+
// the pulse — the row would paint one frame of pulse with `startAnim` still set.
|
|
6059
|
+
if ((Date.now() - state.startAnim.start) >= DUR) {
|
|
5492
6060
|
state.startAnim = null;
|
|
5493
6061
|
// The colour phase has just finished painting the phrase orange. Anchor
|
|
5494
6062
|
// the pulse to THIS tick so its first frame is the orange start of the
|
|
@@ -5529,79 +6097,68 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
5529
6097
|
const animColor = lerpColor(r, g, b, r, g, b, 0);
|
|
5530
6098
|
lines.push(col(frame, animColor) + ' ' + col('[', animColor) + col(word, animColor) + col(tail, greyEsc));
|
|
5531
6099
|
} else {
|
|
5532
|
-
|
|
5533
|
-
//
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
//
|
|
5543
|
-
//
|
|
5544
|
-
//
|
|
5545
|
-
//
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
//
|
|
5551
|
-
//
|
|
5552
|
-
|
|
5553
|
-
const
|
|
5554
|
-
|
|
6100
|
+
// A light flashes across the phrase roughly once a second: the text stays orange and
|
|
6101
|
+
// a yellow lamp runs over it from left to right.
|
|
6102
|
+
//
|
|
6103
|
+
// This replaces a four-phase colour pulse (orange → yellow → orange → red → orange)
|
|
6104
|
+
// whose band was half the row wide. Two things were wrong with it. The band left every
|
|
6105
|
+
// character changed colour behind it, so what the eye saw was the WHOLE PHRASE swinging
|
|
6106
|
+
// between yellow and red — a slow breathing, not a movement. And the red half read as
|
|
6107
|
+
// an error state on a row that only means "working".
|
|
6108
|
+
//
|
|
6109
|
+
//
|
|
6110
|
+
// The light is a QUARTER OF THE PHRASE WIDE, over a yellow highlight running left to right.
|
|
6111
|
+
// The phase comes from a WALL CLOCK, not the tick count — see the same block in formatToolLine.
|
|
6112
|
+
// The render path redraws far more often than the 80ms spinner tick, and a phase derived from
|
|
6113
|
+
// `spin` steps in 80ms jumps however often it is redrawn, which is the per-cell hopping this
|
|
6114
|
+
// replaced.
|
|
6115
|
+
//
|
|
6116
|
+
// The anchor is prefixed so the Working row cannot share an origin with a tool row that happened to
|
|
6117
|
+
// start on the same tick — the two animations are independent and must not be tied together.
|
|
6118
|
+
//
|
|
6119
|
+
// `??`, NOT `||`: tick 0 is a legitimate anchor, and `0 || spin` would collapse the elapsed time
|
|
6120
|
+
// to zero so the light never moved at all.
|
|
6121
|
+
const anchorSpin = state.pulseStart ?? spin;
|
|
6122
|
+
// THE KEY MUST NOT REPEAT ACROSS TURNS.
|
|
6123
|
+
//
|
|
6124
|
+
// `pulseStart` is the tick count within a turn, so two turns can both reach `pulseStart = 6`. With the
|
|
6125
|
+
// key built from the anchor alone, the second turn FOUND the first turn's origin still in the map and
|
|
6126
|
+
// started its sweep from an origin minutes in the past — a phase far past the cycle, so the light
|
|
6127
|
+
// snapped to a position mid-row. Keying on the turn's own start stamp makes every animation unique.
|
|
6128
|
+
const turnKey = state.turnStart || 0;
|
|
6129
|
+
const phaseMs = Math.max(0, Date.now() - lightOrigin(`work:${turnKey}:${anchorSpin}`, anchorSpin, spin)) % cycleMs(workMsg.length);
|
|
6130
|
+
|
|
6131
|
+
// THE GLINT IS A STEP AWAY FROM THE BASE, not a jump to another colour.
|
|
6132
|
+
//
|
|
6133
|
+
// `[255, 240, 120]` against orange `[255, 140, 0]` is a different hue entirely — the eye reads it as
|
|
6134
|
+
// "this text is yellow now", which is a status change rather than a ripple passing over. Claude Code
|
|
6135
|
+
// keeps the two within a few percent of each other and lets the MOVEMENT carry the effect; measured
|
|
6136
|
+
// against its `messageColor`/`shimmerColor` pair, the light is a shade brighter and nothing more.
|
|
6137
|
+
//
|
|
6138
|
+
// These are the base with a lift in the same hue, so the animation reads as a sheen travelling across
|
|
6139
|
+
// the word rather than as the word changing colour.
|
|
6140
|
+
const GLINT = [255, 205, 95];
|
|
5555
6141
|
|
|
5556
6142
|
const chars = workMsg;
|
|
5557
|
-
const
|
|
5558
|
-
//
|
|
5559
|
-
|
|
5560
|
-
|
|
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:
|
|
6143
|
+
const lightEdge = state.shimmerEdge || (state.cfg && state.cfg.shimmerEdge) || 'cosine';
|
|
6144
|
+
// ONE shimmer for both rows — see `shimmerText`.
|
|
6145
|
+
const out = shimmerText(chars, phaseMs, ORANGE_RGB, GLINT, lightEdge);
|
|
6146
|
+
// A QUIET TURN FADES TOWARD THE WARNING COLOUR.
|
|
5581
6147
|
//
|
|
5582
|
-
//
|
|
5583
|
-
//
|
|
6148
|
+
// `state.lastTokenAt` is stamped whenever output arrives; `state.hasActiveTools` is set while a tool
|
|
6149
|
+
// is running. Both are read here rather than recomputed, because this is the paint path.
|
|
5584
6150
|
//
|
|
5585
|
-
//
|
|
5586
|
-
//
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
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);
|
|
6151
|
+
// The intensity is interpolated into the base colour rather than switching, so a slow model does not
|
|
6152
|
+
// look like a broken one for one frame and then fine the next.
|
|
6153
|
+
const stall = stallIntensity({
|
|
6154
|
+
sinceLastTokenMs: state.lastTokenAt ? Date.now() - state.lastTokenAt : 0,
|
|
6155
|
+
hasActiveTools: !!state.hasActiveTools,
|
|
6156
|
+
});
|
|
6157
|
+
const baseFrame = stall > 0 ? col(frame, lerpColor(
|
|
6158
|
+
ORANGE_RGB[0], ORANGE_RGB[1], ORANGE_RGB[2],
|
|
6159
|
+
255, 90, 90, stall,
|
|
6160
|
+
)) : col(frame, orangeEsc);
|
|
6161
|
+
lines.push(baseFrame + ' ' + out + elapsed);
|
|
5605
6162
|
}
|
|
5606
6163
|
}
|
|
5607
6164
|
|
|
@@ -5705,7 +6262,10 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
|
|
|
5705
6262
|
// the typed TEXT white. The prefix is the first `❯ ` of the row (the
|
|
5706
6263
|
// continuation rows are padded with spaces instead).
|
|
5707
6264
|
const rawRow = composer.rows[ri];
|
|
5708
|
-
|
|
6265
|
+
// Which chip the pointer is over, if any. `state.pasteHover` is the paste NUMBER, set by the
|
|
6266
|
+
// mousehover handler from the same index maths the expand gesture uses, so the highlight and the
|
|
6267
|
+
// click can never disagree about which chip they mean.
|
|
6268
|
+
const styledRow = highlightPasteMarkers(rawRow, state.pasteHover);
|
|
5709
6269
|
// One space of padding after the left wall (and before the right wall), the
|
|
5710
6270
|
// same as the transcript's user/queue boxes, so the geometry is identical.
|
|
5711
6271
|
const pre = composerInput(state).prefix;
|
|
@@ -6323,7 +6883,12 @@ export function makeState({ cfg, session, opts }) {
|
|
|
6323
6883
|
reasoning: effort !== 'off',
|
|
6324
6884
|
seenThinking: false,
|
|
6325
6885
|
expanded: false,
|
|
6326
|
-
|
|
6886
|
+
// A finished list is not restored. A session saved BEFORE this rule existed can still carry one,
|
|
6887
|
+
// and bringing it back would show a panel for work that is already over — the case the drop on the
|
|
6888
|
+
// update path cannot reach.
|
|
6889
|
+
todos: (session && Array.isArray(session.todos) && !todosAllComplete(session.todos))
|
|
6890
|
+
? session.todos
|
|
6891
|
+
: [],
|
|
6327
6892
|
todosExpanded: false,
|
|
6328
6893
|
cwd,
|
|
6329
6894
|
cwdOverride: null,
|
|
@@ -7392,7 +7957,15 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
7392
7957
|
if (!opts.length) { appErr('The current model has no thinking control.'); return; }
|
|
7393
7958
|
if (raw) {
|
|
7394
7959
|
const v = raw.toLowerCase();
|
|
7395
|
-
|
|
7960
|
+
// NO VALIDATION against `opts`. The list is what a model is KNOWN to accept — from the catalog,
|
|
7961
|
+
// or from a user's own `efforts = [...]` — and a value outside it is not necessarily wrong: the
|
|
7962
|
+
// catalog can be behind a model, and a proxy or gateway may take grades the entry never
|
|
7963
|
+
// recorded. Rejecting those made `/effort xhigh` fail on a model that understands it, with no
|
|
7964
|
+
// way to try.
|
|
7965
|
+
//
|
|
7966
|
+
// A wrong value now fails at the provider instead, which is where the truth lives and which
|
|
7967
|
+
// reports what it did not like. `effortWire` already omits the field for a grade with no budget,
|
|
7968
|
+
// so an unknown name is sent as the name itself rather than as a guessed number.
|
|
7396
7969
|
setEffort(state, cfg, v);
|
|
7397
7970
|
persist();
|
|
7398
7971
|
app(`Thinking effort → ${v}`);
|
|
@@ -7833,9 +8406,15 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
7833
8406
|
refreshGitInfo(state, { force: true });
|
|
7834
8407
|
}
|
|
7835
8408
|
|
|
7836
|
-
|
|
7837
8409
|
// Update context gauge based on restored session messages.
|
|
7838
|
-
|
|
8410
|
+
//
|
|
8411
|
+
// THE SAME FORMULA AS A LIVE STEP: messages PLUS the tool schemas. This used to count the
|
|
8412
|
+
// messages alone, so the gauge stepped by a fixed amount whenever a restore and a live step
|
|
8413
|
+
// alternated — a visible jump that was two formulas disagreeing, not noise.
|
|
8414
|
+
//
|
|
8415
|
+
// `dispatch` is module-level and cannot reach startTUI's `setCtxTokens`, so the expression is
|
|
8416
|
+
// repeated here rather than the call. Keep the two identical.
|
|
8417
|
+
const approx = estimateMessagesTokens(session.messages, cfg) + (cfg.toolDefTokens || 0);
|
|
7839
8418
|
state.ctxTokens = approx;
|
|
7840
8419
|
state.ctxMax = cfg.maxContextTokens || state.ctxMax;
|
|
7841
8420
|
state.ctxPercent = usagePercent(approx, state.ctxMax);
|
|
@@ -8024,7 +8603,21 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
8024
8603
|
}
|
|
8025
8604
|
if (ci >= cur.length) { app('Nothing to undo.'); return; }
|
|
8026
8605
|
const removed = cur.slice(ci);
|
|
8606
|
+
// TRUNCATE THE MODEL'S CONTEXT, not just the transcript on screen.
|
|
8607
|
+
//
|
|
8608
|
+
// `state.chat` is what the user sees; `session.messages` is what the model is sent. This used to
|
|
8609
|
+
// shorten only the former, so /undo looked complete while the withdrawn prompt stayed in the
|
|
8610
|
+
// request — and the model kept acting on text the user believed they had removed. Measured: after
|
|
8611
|
+
// undoing a prompt that deleted a block of words, the next request still carried the deletion.
|
|
8612
|
+
//
|
|
8613
|
+
// `session.messages` is the array the agent sends, so it is the one that has to be cut. The
|
|
8614
|
+
// `_ledger` is rebuilt from it on the next turn (see sync), and the cached prefix is dropped so a
|
|
8615
|
+
// stale token count cannot describe a shorter history.
|
|
8616
|
+
session.messages = cur.slice(0, ci);
|
|
8027
8617
|
state.chat = state.chat.slice(0, Math.max(0, state.chat.length - removed.length));
|
|
8618
|
+
if (state.agent && state.agent._ledger) {
|
|
8619
|
+
try { state.agent._ledger.reset(); } catch { /* best-effort */ }
|
|
8620
|
+
}
|
|
8028
8621
|
saveSession(session);
|
|
8029
8622
|
// Rewind the FILES too. This used to only truncate the transcript and then
|
|
8030
8623
|
// say "Undid 1 prompt", while every Edit/Write the agent had made stayed on
|
|
@@ -8075,6 +8668,9 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
8075
8668
|
// same time — `state.running` is what submit() checks to queue instead.
|
|
8076
8669
|
if (state.running) { app('Busy — wait for the current turn to finish.'); return; }
|
|
8077
8670
|
state.running = true;
|
|
8671
|
+
// The Working row takes over the progress display: it shows `Compacting Context...` for as
|
|
8672
|
+
// long as this runs, so the summary round-trip is visible without adding a transcript row.
|
|
8673
|
+
state.compacting = true;
|
|
8078
8674
|
renderFrame();
|
|
8079
8675
|
|
|
8080
8676
|
const maxCtx = cfg.maxContextTokens || state.ctxMax || 512000;
|
|
@@ -8084,16 +8680,14 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
8084
8680
|
const dropped = droppedMsgs.length;
|
|
8085
8681
|
|
|
8086
8682
|
|
|
8087
|
-
// Summarize the dropped portion, so context survives the trim — that is the
|
|
8088
|
-
//
|
|
8089
|
-
//
|
|
8090
|
-
//
|
|
8683
|
+
// Summarize the dropped portion, so context survives the trim — that is the whole point of
|
|
8684
|
+
// compaction. The PROGRESS lives on the Working row (`state.compacting`, set above), and the
|
|
8685
|
+
// transcript gets one row at the END recording what happened: the token counts and, behind
|
|
8686
|
+
// Ctrl+O, the summary itself. That row is information; the blinking was state.
|
|
8091
8687
|
let summary = '';
|
|
8092
8688
|
const liveBlock = {
|
|
8093
8689
|
role: 'compaction', phase: 'running', startedAt: Date.now(), instruction: '',
|
|
8094
8690
|
};
|
|
8095
|
-
addChat(liveBlock);
|
|
8096
|
-
renderFrame();
|
|
8097
8691
|
if (droppedMsgs.length > 0) {
|
|
8098
8692
|
const llm = new LLM(cfg);
|
|
8099
8693
|
// Let Esc abort the summary request (see the escape handler).
|
|
@@ -8113,16 +8707,18 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
8113
8707
|
state._compactLlm = null;
|
|
8114
8708
|
}
|
|
8115
8709
|
// Interrupted (Esc): abort yields an empty summary; do NOT trim — that would
|
|
8116
|
-
// drop history with nothing to replace it.
|
|
8710
|
+
// drop history with nothing to replace it. The Working row goes back to a
|
|
8711
|
+
// rotating phrase and the transcript records that nothing was trimmed.
|
|
8117
8712
|
if (state._compactAborted) {
|
|
8118
8713
|
state._compactAborted = false;
|
|
8714
|
+
state.compacting = false;
|
|
8119
8715
|
liveBlock.phase = 'cancelled';
|
|
8120
8716
|
liveBlock._cache = null;
|
|
8717
|
+
addChat(liveBlock);
|
|
8121
8718
|
state.running = false;
|
|
8122
8719
|
renderFrame();
|
|
8123
8720
|
return;
|
|
8124
8721
|
}
|
|
8125
|
-
|
|
8126
8722
|
const compactedSummary = summary
|
|
8127
8723
|
? `[hncode] Earlier conversation context was compacted. ${dropped} older message(s) were replaced by this summary:\n\n${summary}`
|
|
8128
8724
|
: `[hncode] ${dropped} earlier message(s) were compacted without a summary. Their detail is no longer available.`;
|
|
@@ -8149,7 +8745,8 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
8149
8745
|
];
|
|
8150
8746
|
|
|
8151
8747
|
saveSession(session);
|
|
8152
|
-
|
|
8748
|
+
// Same formula as everywhere else: messages PLUS the tool schemas.
|
|
8749
|
+
state.tokens = estimateMessagesTokens(session.messages, cfg) + (cfg.toolDefTokens || 0);
|
|
8153
8750
|
state.ctxTokens = state.tokens;
|
|
8154
8751
|
state.ctxPercent = usagePercent(state.tokens, state.ctxMax || maxCtx);
|
|
8155
8752
|
// The transcript was just replaced with a much shorter one. Drop the scroll
|
|
@@ -8159,13 +8756,16 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
8159
8756
|
dropScrollPin(state);
|
|
8160
8757
|
state.scroll = 0;
|
|
8161
8758
|
state.running = false;
|
|
8162
|
-
|
|
8163
|
-
//
|
|
8759
|
+
state.compacting = false;
|
|
8760
|
+
// One transcript row, at the END, recording what happened — "Compaction complete
|
|
8761
|
+
// (before → after tokens)" with the summary behind Ctrl+O. The progress was on the
|
|
8762
|
+
// Working row; this is the record.
|
|
8164
8763
|
liveBlock.phase = 'done';
|
|
8165
8764
|
liveBlock.tokensBefore = estimateMessagesTokens(msgs, cfg);
|
|
8166
8765
|
liveBlock.tokensAfter = state.tokens;
|
|
8167
8766
|
liveBlock.text = summary || '';
|
|
8168
8767
|
liveBlock._cache = null;
|
|
8768
|
+
addChat(liveBlock);
|
|
8169
8769
|
renderFrame();
|
|
8170
8770
|
return;
|
|
8171
8771
|
}
|
|
@@ -8675,7 +9275,11 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
8675
9275
|
}
|
|
8676
9276
|
case 'usage': {
|
|
8677
9277
|
const msgs = session.messages || [];
|
|
8678
|
-
|
|
9278
|
+
// THE SAME ESTIMATOR AS THE GAUGE. This was `JSON.stringify(msgs).length / 3.5` — a third formula,
|
|
9279
|
+
// and a wrong one: the serialised JSON carries every key, quote and brace, so it charged for the
|
|
9280
|
+
// structure itself. Three formulas for one quantity is how a session reports three different
|
|
9281
|
+
// numbers for the same request.
|
|
9282
|
+
const approx = estimateMessagesTokens(msgs, cfg) + (cfg.toolDefTokens || 0);
|
|
8679
9283
|
const g = C.gray, w = C.white, b = C.bold;
|
|
8680
9284
|
const u = state.usage || {};
|
|
8681
9285
|
const lines = [
|
|
@@ -8706,6 +9310,33 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
8706
9310
|
lines.push(w + b + 'Context window' + C.reset);
|
|
8707
9311
|
lines.push(' ' + g + 'used ' + C.reset + w + `${state.ctxPercent}% `
|
|
8708
9312
|
+ g + `(${fmtTokens(state.ctxTokens)} / ${fmtTokens(state.ctxMax)})` + C.reset);
|
|
9313
|
+
|
|
9314
|
+
// WHAT THE REQUEST IS MADE OF, itemised.
|
|
9315
|
+
//
|
|
9316
|
+
// A single number cannot answer "why does the provider report more than this?" — the gap could be
|
|
9317
|
+
// the tool block, a cache read, framing, or one enormous tool result. Naming each part makes the
|
|
9318
|
+
// answer visible instead of a theory: the biggest row is where the tokens are.
|
|
9319
|
+
{
|
|
9320
|
+
const sysTokens = msgs.filter((m) => m && m.role === 'system')
|
|
9321
|
+
.reduce((n, m) => n + messageTokens(m, cfg), 0);
|
|
9322
|
+
const convTokens = msgs.filter((m) => m && m.role !== 'system')
|
|
9323
|
+
.reduce((n, m) => n + messageTokens(m, cfg), 0);
|
|
9324
|
+
const toolTokens = cfg.toolDefTokens || 0;
|
|
9325
|
+
const rows = [
|
|
9326
|
+
['system prompt', sysTokens],
|
|
9327
|
+
['conversation', convTokens],
|
|
9328
|
+
['tool schemas', toolTokens],
|
|
9329
|
+
].filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
|
|
9330
|
+
if (rows.length) {
|
|
9331
|
+
lines.push('');
|
|
9332
|
+
lines.push(w + b + 'Where it goes' + C.reset);
|
|
9333
|
+
for (const [label, v] of rows) {
|
|
9334
|
+
const share = state.ctxTokens ? Math.round((v / state.ctxTokens) * 100) : 0;
|
|
9335
|
+
lines.push(' ' + g + label.padEnd(14) + C.reset + w + fmtTokens(v).padStart(8) + C.reset
|
|
9336
|
+
+ g + ` ${share}%` + C.reset);
|
|
9337
|
+
}
|
|
9338
|
+
}
|
|
9339
|
+
}
|
|
8709
9340
|
// Trend of the request size across this session's steps: rises as history
|
|
8710
9341
|
// accumulates and drops when a compaction trims it.
|
|
8711
9342
|
const hist = Array.isArray(state.tokenHistory) ? state.tokenHistory : [];
|
|
@@ -9512,8 +10143,11 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
9512
10143
|
// requests left it showing the pre-trim number until the next turn — the user
|
|
9513
10144
|
// had to send a message to see the effect. `/trim` knows the exact count, so
|
|
9514
10145
|
// push it into the same state fields the events would have set.
|
|
9515
|
-
|
|
9516
|
-
|
|
10146
|
+
//
|
|
10147
|
+
// Plus the tool schemas, like every other writer: `/trim` counts messages, and the request also
|
|
10148
|
+
// carries the tools, so leaving them out dropped the gauge by that much more than was freed.
|
|
10149
|
+
state.ctxTokens = after + (cfg.toolDefTokens || 0);
|
|
10150
|
+
state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || cfg.maxContextTokens || 1);
|
|
9517
10151
|
if (session) {
|
|
9518
10152
|
try {
|
|
9519
10153
|
session.tokenHistory = Array.isArray(session.tokenHistory) ? session.tokenHistory : [];
|
|
@@ -11013,8 +11647,22 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
11013
11647
|
return;
|
|
11014
11648
|
}
|
|
11015
11649
|
}
|
|
11016
|
-
//
|
|
11017
|
-
|
|
11650
|
+
// An unknown command is sent to the model AS TEXT rather than refused.
|
|
11651
|
+
//
|
|
11652
|
+
// The slash was already stripped by the composer's command path, so "unknown command" meant the
|
|
11653
|
+
// user's message was swallowed and never reached the model — typing `/why is this slow` produced
|
|
11654
|
+
// an error instead of a question. A leading slash is also how people write a path, a sed script or
|
|
11655
|
+
// a regex, so a message that merely STARTS with one was the most likely casualty.
|
|
11656
|
+
//
|
|
11657
|
+
// Sending it is the honest default: the model can answer a question phrased oddly, and the
|
|
11658
|
+
// alternative is losing the text entirely. The typo case is covered by the notice, so a
|
|
11659
|
+
// near-miss like `/hlep` still says what was not found while its text goes through.
|
|
11660
|
+
// `cmdRaw` is the whole line the user typed, arguments included, and it may or may not carry a
|
|
11661
|
+
// leading slash depending on the caller. It goes out with ONE slash, so the model sees the message
|
|
11662
|
+
// as the person wrote it rather than as a bare word the composer mangled.
|
|
11663
|
+
const asTyped = `/${String(cmdRaw || '').replace(/^\/+/, '')}`.trim();
|
|
11664
|
+
notice(`No such command: ${asTyped.split(/\s+/)[0]} — sent as a message instead`, 'info');
|
|
11665
|
+
await sendPrompt(asTyped);
|
|
11018
11666
|
return;
|
|
11019
11667
|
}
|
|
11020
11668
|
}
|
|
@@ -11590,18 +12238,22 @@ export function tokenize(str) {
|
|
|
11590
12238
|
const mcol = parseInt(mm[2], 10);
|
|
11591
12239
|
const mrow = parseInt(mm[3], 10);
|
|
11592
12240
|
const press = mm[4] === 'M';
|
|
12241
|
+
// SGR encodes the modifiers in the button parameter: 4 = Shift, 8 = Meta, 16 = Ctrl. Ctrl is
|
|
12242
|
+
// carried on the token because Ctrl+click has its own meaning in the composer — see the
|
|
12243
|
+
// mousedown handler, where it expands a collapsed paste rather than placing the caret.
|
|
12244
|
+
const ctrl = (btn & 16) === 16;
|
|
11593
12245
|
if ((btn & 64) === 64) {
|
|
11594
|
-
out.push({ key: (btn & 1) ? 'wheeldown' : 'wheelup', col: mcol, row: mrow });
|
|
12246
|
+
out.push({ key: (btn & 1) ? 'wheeldown' : 'wheelup', col: mcol, row: mrow, ctrl });
|
|
11595
12247
|
} else if (press && (btn & 3) === 0 && (btn & 32) === 0) {
|
|
11596
|
-
out.push({ key: 'mousedown', button: 'left', col: mcol, row: mrow });
|
|
12248
|
+
out.push({ key: 'mousedown', button: 'left', col: mcol, row: mrow, ctrl });
|
|
11597
12249
|
} else if (press && (btn & 32) === 32 && (btn & 3) === 3) {
|
|
11598
|
-
out.push({ key: 'mousehover', col: mcol, row: mrow });
|
|
12250
|
+
out.push({ key: 'mousehover', col: mcol, row: mrow, ctrl });
|
|
11599
12251
|
} else if (press && (btn & 32) === 32) {
|
|
11600
|
-
out.push({ key: 'mousemove', button: 'left', col: mcol, row: mrow });
|
|
12252
|
+
out.push({ key: 'mousemove', button: 'left', col: mcol, row: mrow, ctrl });
|
|
11601
12253
|
} else if (!press && (btn & 3) === 0) {
|
|
11602
|
-
out.push({ key: 'mouseup', button: 'left', col: mcol, row: mrow });
|
|
12254
|
+
out.push({ key: 'mouseup', button: 'left', col: mcol, row: mrow, ctrl });
|
|
11603
12255
|
} else if (press && (btn & 3) === 2) {
|
|
11604
|
-
out.push({ key: 'rightclick', col: mcol, row: mrow });
|
|
12256
|
+
out.push({ key: 'rightclick', col: mcol, row: mrow, ctrl });
|
|
11605
12257
|
}
|
|
11606
12258
|
i += mm[0].length;
|
|
11607
12259
|
continue;
|
|
@@ -11919,6 +12571,10 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
11919
12571
|
// Delivered as ONE user message rather than a system message, and marked read only after
|
|
11920
12572
|
// the text is built: the model has then genuinely seen it, and a format failure cannot
|
|
11921
12573
|
// silently consume the mail.
|
|
12574
|
+
//
|
|
12575
|
+
// This does NOT become a transcript row. Team traffic belongs in the team PANEL — that is what it
|
|
12576
|
+
// is for, and putting it in the main transcript interleaved another conversation into the one the
|
|
12577
|
+
// user is having with the lead. The panel reads these same files, so nothing is hidden from them.
|
|
11922
12578
|
const teamName = leadTeam();
|
|
11923
12579
|
if (teamName) {
|
|
11924
12580
|
try {
|
|
@@ -11937,7 +12593,6 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
11937
12593
|
// text the status bar's `system prompt` segment collapses to zero columns and vanishes
|
|
11938
12594
|
// mid-turn, then returns at turn end when the untrimmed request is assigned back.
|
|
11939
12595
|
state.turnSystemPrompt = sysText;
|
|
11940
|
-
Object.assign(session, { model: cfg.model, messages: messages.filter((m) => m.role !== 'system') });
|
|
11941
12596
|
// The user's message is on disk before the agent has done anything. A crash
|
|
11942
12597
|
// this early used to lose the entire prompt, because the first save was at
|
|
11943
12598
|
// turn end.
|
|
@@ -12230,57 +12885,51 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
12230
12885
|
return;
|
|
12231
12886
|
}
|
|
12232
12887
|
if (e.type === 'compacting') {
|
|
12233
|
-
//
|
|
12234
|
-
//
|
|
12235
|
-
//
|
|
12236
|
-
//
|
|
12237
|
-
//
|
|
12238
|
-
//
|
|
12888
|
+
// The compaction shows up on the WORKING ROW, not as a transcript block. It is a state of
|
|
12889
|
+
// the turn — the agent is busy doing this one long thing — and the row above the composer is
|
|
12890
|
+
// where "busy" already lives. A transcript block duplicated that and left a blinking row
|
|
12891
|
+
// scrolling away while the turn carried on underneath it.
|
|
12892
|
+
//
|
|
12893
|
+
// A block from a PREVIOUS (interrupted) turn is settled here for the same reason as before:
|
|
12894
|
+
// two compaction rows in the transcript, one stale, is worse than none.
|
|
12239
12895
|
const stale = state.compactionMsg;
|
|
12240
12896
|
if (stale && state.chat.indexOf(stale) >= 0 && stale.phase !== 'running') {
|
|
12241
12897
|
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
12898
|
}
|
|
12247
|
-
state.
|
|
12248
|
-
|
|
12249
|
-
|
|
12250
|
-
};
|
|
12251
|
-
viewCur.addChat(state.compactionMsg);
|
|
12899
|
+
state.compacting = true;
|
|
12900
|
+
// Cleared so nothing tries to settle a block that was never added.
|
|
12901
|
+
state.compactionMsg = null;
|
|
12252
12902
|
viewCur.renderSoon();
|
|
12253
12903
|
return;
|
|
12254
12904
|
}
|
|
12255
12905
|
if (e.type === 'compacted') {
|
|
12256
12906
|
state.ctxTokens = e.after || state.ctxTokens;
|
|
12257
12907
|
state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || 1);
|
|
12258
|
-
//
|
|
12259
|
-
//
|
|
12260
|
-
//
|
|
12261
|
-
|
|
12262
|
-
|
|
12263
|
-
|
|
12264
|
-
|
|
12265
|
-
|
|
12266
|
-
|
|
12267
|
-
|
|
12268
|
-
|
|
12269
|
-
|
|
12270
|
-
|
|
12271
|
-
|
|
12272
|
-
|
|
12273
|
-
|
|
12274
|
-
}
|
|
12908
|
+
// Compaction is over, so the Working row goes back to a rotating phrase.
|
|
12909
|
+
//
|
|
12910
|
+
// RE-ROLLED, not restored: an automatic compaction happens MID-TURN — the agent hit
|
|
12911
|
+
// the window limit between steps and carries on afterwards — so the phrase the turn
|
|
12912
|
+
// started with has been on screen for a while by now. Picking a new one marks the
|
|
12913
|
+
// resume: the row visibly starts again rather than reading as the same stalled
|
|
12914
|
+
// operation it was showing for the last few seconds.
|
|
12915
|
+
state.compacting = false;
|
|
12916
|
+
state.workMsg = WORKING_MESSAGES[Math.floor(Math.random() * WORKING_MESSAGES.length)];
|
|
12917
|
+
// The transcript keeps a RECORD of what happened, with the token counts — that is
|
|
12918
|
+
// information, not state, and it is what Ctrl+O expands to show the summary.
|
|
12919
|
+
viewCur.addChat({
|
|
12920
|
+
role: 'compaction', phase: 'done',
|
|
12921
|
+
tokensBefore: e.before, tokensAfter: e.after, text: e.summary || '',
|
|
12922
|
+
});
|
|
12923
|
+
state.compactionMsg = null;
|
|
12275
12924
|
viewCur.renderSoon();
|
|
12276
12925
|
return;
|
|
12277
12926
|
}
|
|
12278
12927
|
if (e.type === 'compaction_cancelled') {
|
|
12279
|
-
// The user interrupted an auto-compaction
|
|
12280
|
-
//
|
|
12281
|
-
|
|
12282
|
-
|
|
12283
|
-
|
|
12928
|
+
// The user interrupted an auto-compaction: nothing was trimmed, so the transcript says so
|
|
12929
|
+
// and the turn resumes with its phrase intact.
|
|
12930
|
+
state.compacting = false;
|
|
12931
|
+
state.compactionMsg = null;
|
|
12932
|
+
viewCur.addChat({ role: 'compaction', phase: 'cancelled' });
|
|
12284
12933
|
viewCur.renderSoon();
|
|
12285
12934
|
return;
|
|
12286
12935
|
}
|
|
@@ -12290,6 +12939,9 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
12290
12939
|
return;
|
|
12291
12940
|
}
|
|
12292
12941
|
if (e.type === 'data') {
|
|
12942
|
+
// A token arrived, so the turn is not stalled. Stamped here rather than in the paint path
|
|
12943
|
+
// because the paint path cannot tell "slow" from "quiet" — only the events can.
|
|
12944
|
+
state.lastTokenAt = Date.now();
|
|
12293
12945
|
// Feed the answer text into the paced buffer instead of painting it. The
|
|
12294
12946
|
// buffer closes any open reasoning region itself, in arrival order, so the
|
|
12295
12947
|
// block settles exactly when the provider moved on rather than when the next
|
|
@@ -12306,46 +12958,69 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
12306
12958
|
viewCur.renderSoon(); return;
|
|
12307
12959
|
}
|
|
12308
12960
|
if (e.type === 'context') {
|
|
12309
|
-
// Live context gauge: the agent reports the estimated request size once
|
|
12310
|
-
//
|
|
12311
|
-
|
|
12312
|
-
|
|
12961
|
+
// Live context gauge: the agent reports the estimated request size once per step, so the
|
|
12962
|
+
// header and /usage track the growing history.
|
|
12963
|
+
//
|
|
12964
|
+
// ONE FORMULA, inlined. Every writer of `ctxTokens` — this event, session restore, /trim,
|
|
12965
|
+
// compaction — computes the same thing. Before that they disagreed: restore counted messages
|
|
12966
|
+
// alone while a live step counted messages PLUS the tool schemas, so the gauge stepped by a
|
|
12967
|
+
// fixed amount whenever the two paths alternated. A visible jump, caused by two formulas
|
|
12968
|
+
// disagreeing rather than by any noise in either.
|
|
12969
|
+
//
|
|
12970
|
+
// Inlined rather than calling startTUI's `setCtxTokens`: `runTurn` is module-level and cannot
|
|
12971
|
+
// see that closure, and `test/tui-scope.test.mjs` fails the build when it tries. The two must
|
|
12972
|
+
// stay identical.
|
|
12973
|
+
//
|
|
12974
|
+
|
|
12975
|
+
// THE ESTIMATE ONLY. No provider number is mixed in anywhere.
|
|
12976
|
+
//
|
|
12977
|
+
// `e.tokens` is what this app's own tokenizer model prices the request at, and it is the ONE
|
|
12978
|
+
// source for the gauge. `usage.input` was wired in twice and removed twice: the two count
|
|
12979
|
+
// different things (a report covers the request that already went out, the estimate covers the
|
|
12980
|
+
// one about to), so whichever ran last won and the number jumped between formulas.
|
|
12981
|
+
state.ctxTokens = Number(e.tokens) || 0;
|
|
12982
|
+
if (Number.isFinite(e.max) && e.max > 0) state.ctxMax = e.max;
|
|
12313
12983
|
state.ctxPercent = usagePercent(state.ctxTokens, state.ctxMax || 1);
|
|
12314
|
-
// Keep a bounded series of step sizes so /usage can draw the trend.
|
|
12315
12984
|
if (!Array.isArray(state.tokenHistory)) state.tokenHistory = [];
|
|
12316
|
-
|
|
12317
|
-
|
|
12318
|
-
|
|
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);
|
|
12985
|
+
if (state.tokenHistory[state.tokenHistory.length - 1] !== state.ctxTokens) {
|
|
12986
|
+
state.tokenHistory.push(state.ctxTokens);
|
|
12987
|
+
if (state.tokenHistory.length > 240) state.tokenHistory.splice(0, state.tokenHistory.length - 240);
|
|
12325
12988
|
}
|
|
12989
|
+
if (session) session.ctxTokens = state.ctxTokens;
|
|
12326
12990
|
viewCur.renderSoon(); return;
|
|
12327
12991
|
}
|
|
12328
12992
|
if (e.type === 'usage') {
|
|
12329
|
-
// Real token accounting from the provider. Folded into a session total
|
|
12330
12993
|
// that /usage and /cost report, and persisted with the session so a
|
|
12331
12994
|
// --resume does not lose what the session has already spent.
|
|
12332
12995
|
//
|
|
12333
|
-
// The COST is recomputed from the total rather than accumulated per
|
|
12334
12996
|
// event: switching models mid-session would otherwise price every
|
|
12335
12997
|
// earlier request at the new model's rates.
|
|
12336
12998
|
if (!state.usage) state.usage = {};
|
|
12337
12999
|
state.usage = addUsage(state.usage, e.usage);
|
|
12338
13000
|
if (session) session.usage = state.usage;
|
|
12339
13001
|
state.lastUsage = e.usage;
|
|
12340
|
-
// The
|
|
12341
|
-
//
|
|
12342
|
-
//
|
|
12343
|
-
//
|
|
12344
|
-
//
|
|
13002
|
+
// The gauge is NOT updated here. `usage.input` is what the provider charged, and it has caused
|
|
13003
|
+
// two problems worth recording so it is not wired in a third time:
|
|
13004
|
+
//
|
|
13005
|
+
// - it counts the request that ALREADY WENT OUT, while the `context` event counts the one
|
|
13006
|
+
// about to. The two alternate, so the number jumps between two definitions of "context".
|
|
13007
|
+
// - on a cache hit some providers report only the uncached remainder, which made the gauge
|
|
13008
|
+
// COLLAPSE mid-turn and then jump back.
|
|
13009
|
+
//
|
|
13010
|
+
// The estimate has its own correction for that: `calibrate` in agent.js learns the ratio from
|
|
13011
|
+
// these very reports and applies it where the estimate is produced, so the gauge stays ONE
|
|
13012
|
+
// coherent number while still converging on the truth.
|
|
13013
|
+
//
|
|
13014
|
+
// The turn's OUTPUT tokens are still taken from here — that figure has no other source.
|
|
13015
|
+
// The TURN's output tokens, accumulated exactly from the provider rather than estimated from
|
|
13016
|
+
// streamed characters. Kept separate from the context figure above: one counts what was sent,
|
|
13017
|
+
// the other what came back.
|
|
12345
13018
|
if (e.usage && Number.isFinite(e.usage.output)) {
|
|
12346
13019
|
state.turnOutputSettled = (state.turnOutputSettled || 0) + e.usage.output;
|
|
12347
|
-
state.
|
|
12348
|
-
|
|
13020
|
+
state.turnOutputTokens = Math.max(
|
|
13021
|
+
state.turnOutputTokens || 0,
|
|
13022
|
+
(state.turnOutputSettled || 0) + (state.turnOutputEstimate || 0),
|
|
13023
|
+
);
|
|
12349
13024
|
}
|
|
12350
13025
|
refreshCost(state, cfg);
|
|
12351
13026
|
viewCur.renderSoon(); return;
|
|
@@ -12395,6 +13070,11 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
12395
13070
|
return;
|
|
12396
13071
|
}
|
|
12397
13072
|
if (e.type === 'tool_start') {
|
|
13073
|
+
// A RUNNING TOOL IS WORK, NOT A STALL. An `npm install` that takes a minute produces no tokens
|
|
13074
|
+
// and looks exactly like a hung request — the row must not fade to the warning colour while
|
|
13075
|
+
// something is demonstrably happening.
|
|
13076
|
+
state.hasActiveTools = true;
|
|
13077
|
+
state.lastTokenAt = Date.now();
|
|
12398
13078
|
// A tool call means the model finished this step's reasoning and is now
|
|
12399
13079
|
// acting on it, so the turn's text has to be fully on screen first: the tool
|
|
12400
13080
|
// row is drawn UNDER the reply, and text still trickling in afterwards would
|
|
@@ -12512,6 +13192,11 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
12512
13192
|
// as soon as they arrive instead of waiting for the next event.
|
|
12513
13193
|
viewCur.renderSoon();
|
|
12514
13194
|
} else if (e.type === 'tool_result') {
|
|
13195
|
+
// The tool is done, so the stall clock starts again from here. Stamping `lastTokenAt` stops the
|
|
13196
|
+
// row from fading the instant a long tool finishes — the fade should measure quiet AFTER the
|
|
13197
|
+
// tool, not the tool's own duration.
|
|
13198
|
+
state.hasActiveTools = false;
|
|
13199
|
+
state.lastTokenAt = Date.now();
|
|
12515
13200
|
const entry = [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending && m.id === e.id)
|
|
12516
13201
|
|| [...state.chat].reverse().find((m) => m.role === 'tool' && m.pending && m.toolName === e.name);
|
|
12517
13202
|
// A failed run must be visible on the TOOL line too: the status bullet
|
|
@@ -12589,6 +13274,19 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
12589
13274
|
// A finished tool result IS a completed step, so the transcript is
|
|
12590
13275
|
// persisted here too — the other half of the step-boundary save above.
|
|
12591
13276
|
persistTurnProgress(messages);
|
|
13277
|
+
// The step just changed files, so refresh the status bar's diff.
|
|
13278
|
+
//
|
|
13279
|
+
// This is the ONLY mid-turn refresh, and the reason the 15s timer and the guard in
|
|
13280
|
+
// refreshGitInfo keep out of a running turn is that a poll would return a value that is
|
|
13281
|
+
// stale on arrival — the agent is about to write again. A STEP BOUNDARY is different: it is
|
|
13282
|
+
// a moment when the agent has stopped writing and is about to think, so the count is both
|
|
13283
|
+
// current and stable enough to look at. Waiting for the turn to end, which is what it used
|
|
13284
|
+
// to do, meant a long turn showed an unchanging `+0 -0` while dozens of files were edited.
|
|
13285
|
+
//
|
|
13286
|
+
// `force` because the running-turn guard would otherwise drop it, and no `renderFrame()`
|
|
13287
|
+
// here: the reply below already schedules one, and the async git result asks for a repaint
|
|
13288
|
+
// of its own when it lands.
|
|
13289
|
+
refreshGitInfo(state, { force: true });
|
|
12592
13290
|
} else if (e.type === 'error') viewCur.addChat({ role: 'tool_result', text: (e.error && e.error.message) || String(e.error) });
|
|
12593
13291
|
else if (e.type === 'truncation_recovery') {
|
|
12594
13292
|
// The answer hit the output cap mid-way and the agent asked the model to
|
|
@@ -12603,7 +13301,19 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
12603
13301
|
// the user has no way to know the text is still readable.
|
|
12604
13302
|
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
13303
|
}
|
|
12606
|
-
else if (e.type === 'todos') {
|
|
13304
|
+
else if (e.type === 'todos') {
|
|
13305
|
+
// A FINISHED list is dropped outright, not stored and hidden.
|
|
13306
|
+
//
|
|
13307
|
+
// The tool sends the whole list on every call, so "all done" arrives as an ordinary update.
|
|
13308
|
+
// Keeping it would persist a completed plan into the session, hand it back to the model as
|
|
13309
|
+
// `cfg.todoState` on the next turn, and restore it on resume — three descriptions of work
|
|
13310
|
+
// that is already over. Emptying it here means every later reader sees "no todos", which is
|
|
13311
|
+
// the truth.
|
|
13312
|
+
const incoming = Array.isArray(e.todos) ? e.todos : [];
|
|
13313
|
+
state.todos = todosAllComplete(incoming) ? [] : incoming;
|
|
13314
|
+
if (session) session.todos = state.todos;
|
|
13315
|
+
try { viewCur.pubStatusField('todos', [...state.todos]); } catch { /* best-effort */ }
|
|
13316
|
+
}
|
|
12607
13317
|
viewCur.render();
|
|
12608
13318
|
},
|
|
12609
13319
|
});
|
|
@@ -12746,7 +13456,8 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
12746
13456
|
}
|
|
12747
13457
|
}
|
|
12748
13458
|
|
|
12749
|
-
|
|
13459
|
+
// Same formula as everywhere else: messages PLUS the tool schemas.
|
|
13460
|
+
const approx = estimateMessagesTokens(messages, cfg) + (cfg.toolDefTokens || 0);
|
|
12750
13461
|
state.ctxTokens = approx;
|
|
12751
13462
|
state.ctxMax = cfg.maxContextTokens || state.ctxMax;
|
|
12752
13463
|
state.ctxPercent = usagePercent(approx, state.ctxMax);
|
|
@@ -12799,6 +13510,18 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
12799
13510
|
// row and behind whatever the next turn draws.
|
|
12800
13511
|
sessHost.flushStream();
|
|
12801
13512
|
state.running = false;
|
|
13513
|
+
// Release the per-message markdown render caches. Each assistant message's
|
|
13514
|
+
// `_mdCache` holds a full copy of its text, the block split, AND the rendered
|
|
13515
|
+
// ANSI rows twice over — several times the transcript's size — and the chat
|
|
13516
|
+
// array is never trimmed. The rows are re-derivable (the shared bounded row
|
|
13517
|
+
// cache serves history), so all but the most recent messages drop theirs.
|
|
13518
|
+
{
|
|
13519
|
+
const KEEP_RECENT = 8;
|
|
13520
|
+
const chat = state.chat || [];
|
|
13521
|
+
for (let i = 0; i < chat.length - KEEP_RECENT; i++) {
|
|
13522
|
+
if (chat[i] && chat[i]._mdCache) chat[i]._mdCache = null;
|
|
13523
|
+
}
|
|
13524
|
+
}
|
|
12802
13525
|
// The agent may have written or edited files this turn: refresh the git badge
|
|
12803
13526
|
// now so the diff totals reflect it immediately, instead of waiting for the
|
|
12804
13527
|
// 15s timer.
|
|
@@ -12878,6 +13601,38 @@ export async function startTUI(opts) {
|
|
|
12878
13601
|
// hands back a new object, and every later save would otherwise keep writing the old one —
|
|
12879
13602
|
// which is how /move left the session stamped with the directory it was moved away from.
|
|
12880
13603
|
let session = opts.session;
|
|
13604
|
+
|
|
13605
|
+
/**
|
|
13606
|
+
* THE ONLY WRITER of the context gauge.
|
|
13607
|
+
*
|
|
13608
|
+
* Every source goes through here — a live `context` event, session restore, compaction, and the
|
|
13609
|
+
* initial load. Before this existed the gauge had seven assignment sites and two different formulas:
|
|
13610
|
+
* restore counted the messages alone while a live step counted messages PLUS the tool schemas, so the
|
|
13611
|
+
* number stepped by a fixed amount whenever the two paths alternated — a visible jump caused by two
|
|
13612
|
+
* formulas disagreeing, not by noise.
|
|
13613
|
+
*
|
|
13614
|
+
* Keeping one entry point is also what makes the number honest: there is exactly one definition of
|
|
13615
|
+
* "how much context is in use", so a future caller cannot invent a third.
|
|
13616
|
+
*/
|
|
13617
|
+
function setCtxTokens(tokens, max) {
|
|
13618
|
+
const n = Math.max(0, Number(tokens) || 0);
|
|
13619
|
+
state.ctxTokens = n;
|
|
13620
|
+
if (Number.isFinite(max) && max > 0) state.ctxMax = max;
|
|
13621
|
+
state.ctxPercent = usagePercent(n, state.ctxMax || 1);
|
|
13622
|
+
// The trend /usage draws. Bounded, because a long session would otherwise grow it without limit.
|
|
13623
|
+
if (!Array.isArray(state.tokenHistory)) state.tokenHistory = [];
|
|
13624
|
+
const h = state.tokenHistory;
|
|
13625
|
+
if (h[h.length - 1] !== n) h.push(n);
|
|
13626
|
+
if (h.length > 240) h.splice(0, h.length - 240);
|
|
13627
|
+
// Mirrored on the session so saveSession captures it and --resume restores the chart.
|
|
13628
|
+
if (session) {
|
|
13629
|
+
session.ctxTokens = n;
|
|
13630
|
+
if (!Array.isArray(session.tokenHistory)) session.tokenHistory = [];
|
|
13631
|
+
if (session.tokenHistory[session.tokenHistory.length - 1] !== n) session.tokenHistory.push(n);
|
|
13632
|
+
if (session.tokenHistory.length > 240) session.tokenHistory.splice(0, session.tokenHistory.length - 240);
|
|
13633
|
+
}
|
|
13634
|
+
}
|
|
13635
|
+
|
|
12881
13636
|
|
|
12882
13637
|
// FIRST: Ensure model is initialized before restoring session
|
|
12883
13638
|
if (!cfg.model || !cfg.innerModel) {
|
|
@@ -12933,11 +13688,13 @@ export async function startTUI(opts) {
|
|
|
12933
13688
|
return null;
|
|
12934
13689
|
});
|
|
12935
13690
|
|
|
12936
|
-
// Calculate initial context usage from loaded session
|
|
13691
|
+
// Calculate initial context usage from loaded session.
|
|
13692
|
+
//
|
|
13693
|
+
// Through `setCtxTokens` — this IS inside startTUI, where it is defined — and with the same formula as
|
|
13694
|
+
// a live step: messages PLUS the tool schemas. Counting the messages alone here made the gauge read a
|
|
13695
|
+
// different number on restore than on the first step.
|
|
12937
13696
|
if (session && session.messages && session.messages.length) {
|
|
12938
|
-
|
|
12939
|
-
state.ctxTokens = approx;
|
|
12940
|
-
state.ctxPercent = usagePercent(approx, state.ctxMax);
|
|
13697
|
+
setCtxTokens(estimateMessagesTokens(session.messages, cfg) + (cfg.toolDefTokens || 0), state.ctxMax);
|
|
12941
13698
|
}
|
|
12942
13699
|
|
|
12943
13700
|
// Warm the models.dev catalogue in the background. It is what supplies PRICES
|
|
@@ -13382,6 +14139,18 @@ export async function startTUI(opts) {
|
|
|
13382
14139
|
noticeTimer = setTimeout(() => { state.notice = ''; renderFrame(); }, 4000);
|
|
13383
14140
|
}
|
|
13384
14141
|
|
|
14142
|
+
/**
|
|
14143
|
+
* Take the notice row down early.
|
|
14144
|
+
*
|
|
14145
|
+
* `notice` leaves it up for four seconds, which is right for a message about something that HAPPENED.
|
|
14146
|
+
* A hint tied to where the POINTER is has to go when the pointer moves away, or it stays up describing
|
|
14147
|
+
* a chip the pointer has left — and its timer would then blank a notice the user raised meanwhile.
|
|
14148
|
+
*/
|
|
14149
|
+
function clearNotice() {
|
|
14150
|
+
if (noticeTimer) { clearTimeout(noticeTimer); noticeTimer = null; }
|
|
14151
|
+
if (state.notice) state.notice = '';
|
|
14152
|
+
}
|
|
14153
|
+
|
|
13385
14154
|
function dims() {
|
|
13386
14155
|
try {
|
|
13387
14156
|
const [cols, rows] = stdout.getWindowSize();
|
|
@@ -13758,6 +14527,20 @@ export async function startTUI(opts) {
|
|
|
13758
14527
|
* Not awaited by either caller: a teammate works in the background and reports by message,
|
|
13759
14528
|
* which is the whole reason a team is more than a batch of subagents.
|
|
13760
14529
|
*/
|
|
14530
|
+
/**
|
|
14531
|
+
* Record a teammate's live action on the panel, if one is open.
|
|
14532
|
+
*
|
|
14533
|
+
* The panel is opened on demand, so most of the time there is nothing to write to and the stream is
|
|
14534
|
+
* dropped — that is the intended behaviour, not a loss: the work is in the member's own record and
|
|
14535
|
+
* the conversation is drawn on demand when the panel opens.
|
|
14536
|
+
*/
|
|
14537
|
+
function teamPanelNoteActivity(stream) {
|
|
14538
|
+
if (!state.teamPanel) return;
|
|
14539
|
+
try {
|
|
14540
|
+
noteActivity(state.teamPanel, stream);
|
|
14541
|
+
} catch { /* a display hook must never fail a member's turn */ }
|
|
14542
|
+
}
|
|
14543
|
+
|
|
13761
14544
|
async function spawnTeammates(teamName, members) {
|
|
13762
14545
|
const { spawnMany } = await import('./team/spawn.js');
|
|
13763
14546
|
const started = spawnMany(teamName, members, {
|
|
@@ -13768,6 +14551,61 @@ export async function startTUI(opts) {
|
|
|
13768
14551
|
// panel, and so a failure is not silent.
|
|
13769
14552
|
if (ev.type === 'start') notice(`Teammate ${ev.name} started (${ev.taskId}).`);
|
|
13770
14553
|
if (ev.type === 'error') notice(`Teammate ${ev.name} failed: ${ev.error}`, 'error');
|
|
14554
|
+
// LIVE OUTPUT. Without this a teammate was a black box: the roster said idle→working→idle and
|
|
14555
|
+
// the person watching saw none of the work in between. The tool calls are the part that matters
|
|
14556
|
+
// most — a member that has been running the same command for a minute is different from one that
|
|
14557
|
+
// is idle, and the roster alone cannot show that.
|
|
14558
|
+
if (ev.type === 'stream') {
|
|
14559
|
+
teamPanelNoteActivity(ev.stream);
|
|
14560
|
+
renderSoon();
|
|
14561
|
+
}
|
|
14562
|
+
if (ev.type === 'tool') {
|
|
14563
|
+
teamPanelNoteActivity({ kind: 'tool', name: ev.name, tool: ev.tool });
|
|
14564
|
+
renderSoon();
|
|
14565
|
+
}
|
|
14566
|
+
renderSoon();
|
|
14567
|
+
},
|
|
14568
|
+
// A member's message to the lead is the CONVERSATION, not a status line — so it goes straight
|
|
14569
|
+
// into the transcript, in full, as it is sent.
|
|
14570
|
+
//
|
|
14571
|
+
// It used to go only to a file, and `formatLeadInbox` read that file at the START of a turn. The
|
|
14572
|
+
// effect was that an answer arrived only if the person happened to type something else first:
|
|
14573
|
+
// they would send an instruction, watch an empty screen, and conclude the team was dead while the
|
|
14574
|
+
// reply sat on disk. Nothing about that is fixable by hiding it better — the message IS what the
|
|
14575
|
+
// user asked to see.
|
|
14576
|
+
onLeadMail: (mail) => {
|
|
14577
|
+
// A PERMISSION REQUEST IS NOT CHAT. It becomes a prompt the person must answer; showing it as
|
|
14578
|
+
// transcript text would leave it looking like a teammate's remark, with a member blocked behind
|
|
14579
|
+
// it until its ten-minute timeout.
|
|
14580
|
+
const ask = asPermissionRequest(mail.text);
|
|
14581
|
+
if (ask) {
|
|
14582
|
+
if (state.teamPanel) {
|
|
14583
|
+
// The panel owns it while open: `y`/`n` there answer it.
|
|
14584
|
+
state.teamPanel.pendingPermission = ask;
|
|
14585
|
+
} else {
|
|
14586
|
+
// With no panel there is nowhere to press `y`, so it is said out loud. Silently accepting
|
|
14587
|
+
// would run the tool the person was never shown.
|
|
14588
|
+
notice(`${ask.agentId} needs permission for ${ask.toolName}: ${ask.description}`, 'error');
|
|
14589
|
+
try {
|
|
14590
|
+
answerPermission({
|
|
14591
|
+
teamName, memberName: ask.agentId, requestId: ask.requestId,
|
|
14592
|
+
approved: false, error: 'no team panel open to approve it',
|
|
14593
|
+
});
|
|
14594
|
+
} catch { /* best-effort */ }
|
|
14595
|
+
}
|
|
14596
|
+
renderSoon();
|
|
14597
|
+
return;
|
|
14598
|
+
}
|
|
14599
|
+
// Structured messages carry JSON, which is not worth showing a person: the tool that sent it has
|
|
14600
|
+
// already reported its own outcome.
|
|
14601
|
+
if (String(mail.text || '').trim().startsWith('{')) return;
|
|
14602
|
+
addChat({
|
|
14603
|
+
role: 'system',
|
|
14604
|
+
text: `[${mail.from} → team-lead] ${mail.text}`,
|
|
14605
|
+
});
|
|
14606
|
+
// Also announced, because the transcript may be scrolled away and a teammate answering is the
|
|
14607
|
+
// one event worth interrupting for.
|
|
14608
|
+
notice(`${mail.from}: ${String(mail.text).split('\n')[0].slice(0, 100)}`);
|
|
13771
14609
|
renderSoon();
|
|
13772
14610
|
},
|
|
13773
14611
|
});
|
|
@@ -14005,6 +14843,105 @@ export async function startTUI(opts) {
|
|
|
14005
14843
|
renderFrame();
|
|
14006
14844
|
return;
|
|
14007
14845
|
}
|
|
14846
|
+
if (res.action === 'permission') {
|
|
14847
|
+
// The answer goes to the member that asked, and the request is cleared only after the write
|
|
14848
|
+
// succeeds: a failure here would otherwise drop the prompt and leave the member blocking until its
|
|
14849
|
+
// ten-minute timeout, with nothing on screen to explain why.
|
|
14850
|
+
const req = p.pendingPermission;
|
|
14851
|
+
if (req) {
|
|
14852
|
+
try {
|
|
14853
|
+
answerPermission({
|
|
14854
|
+
teamName: p.teamName,
|
|
14855
|
+
memberName: req.agentId,
|
|
14856
|
+
requestId: req.requestId,
|
|
14857
|
+
approved: res.approved,
|
|
14858
|
+
error: res.approved ? undefined : 'denied by the user',
|
|
14859
|
+
});
|
|
14860
|
+
p.pendingPermission = null;
|
|
14861
|
+
p.flash = res.approved ? `allowed for ${req.agentId}` : `denied for ${req.agentId}`;
|
|
14862
|
+
setTimeout(() => { if (state.teamPanel === p) { p.flash = ''; renderFrame(); } }, 2500);
|
|
14863
|
+
} catch (e) {
|
|
14864
|
+
p.flash = `could not answer: ${(e && e.message) || e}`;
|
|
14865
|
+
}
|
|
14866
|
+
}
|
|
14867
|
+
renderFrame();
|
|
14868
|
+
return;
|
|
14869
|
+
}
|
|
14870
|
+
|
|
14871
|
+
// `k` — kill the selected member. The abort controller fires and the run unwinds.
|
|
14872
|
+
//
|
|
14873
|
+
// Distinct from closing the panel: that left the member working in the background, which is the one
|
|
14874
|
+
// thing a person watching a runaway agent most needs to stop.
|
|
14875
|
+
if (res.action === 'kill') {
|
|
14876
|
+
try {
|
|
14877
|
+
const stopped = stopTeammate(p.teamName, res.name);
|
|
14878
|
+
p.flash = stopped ? `killed ${res.name}` : `${res.name} was not running`;
|
|
14879
|
+
} catch (e) {
|
|
14880
|
+
p.flash = `could not kill: ${(e && e.message) || e}`;
|
|
14881
|
+
}
|
|
14882
|
+
setTimeout(() => { if (state.teamPanel === p) { p.flash = ''; renderFrame(); } }, 2500);
|
|
14883
|
+
renderFrame();
|
|
14884
|
+
return;
|
|
14885
|
+
}
|
|
14886
|
+
|
|
14887
|
+
// `s` — ask it to stop GRACEFULLY. It may refuse if it is mid-task, which is the reason this exists
|
|
14888
|
+
// beside `k`: killing a member halfway through a write leaves the file half-written.
|
|
14889
|
+
if (res.action === 'shutdown') {
|
|
14890
|
+
try {
|
|
14891
|
+
mailMod.writeToMailbox(p.teamName, res.name, {
|
|
14892
|
+
from: TEAM_LEAD_NAME,
|
|
14893
|
+
text: JSON.stringify(protocolMod.shutdownRequest({
|
|
14894
|
+
requestId: protocolMod.newRequestId('sd'),
|
|
14895
|
+
from: TEAM_LEAD_NAME,
|
|
14896
|
+
reason: 'shutdown requested from the team panel',
|
|
14897
|
+
})),
|
|
14898
|
+
});
|
|
14899
|
+
p.flash = `asked ${res.name} to stop`;
|
|
14900
|
+
} catch (e) {
|
|
14901
|
+
p.flash = `could not ask: ${(e && e.message) || e}`;
|
|
14902
|
+
}
|
|
14903
|
+
setTimeout(() => { if (state.teamPanel === p) { p.flash = ''; renderFrame(); } }, 2500);
|
|
14904
|
+
renderFrame();
|
|
14905
|
+
return;
|
|
14906
|
+
}
|
|
14907
|
+
|
|
14908
|
+
// `p` — prune every idle member. With five idle teammates the roster is mostly noise.
|
|
14909
|
+
if (res.action === 'prune') {
|
|
14910
|
+
const names = Array.isArray(res.names) ? res.names : [];
|
|
14911
|
+
if (!names.length) {
|
|
14912
|
+
p.flash = 'no idle members to prune';
|
|
14913
|
+
} else {
|
|
14914
|
+
try {
|
|
14915
|
+
let killed = 0;
|
|
14916
|
+
for (const n of names) if (stopTeammate(p.teamName, n)) killed++;
|
|
14917
|
+
p.flash = `pruned ${killed} idle member(s)`;
|
|
14918
|
+
} catch (e) {
|
|
14919
|
+
p.flash = `could not prune: ${(e && e.message) || e}`;
|
|
14920
|
+
}
|
|
14921
|
+
}
|
|
14922
|
+
setTimeout(() => { if (state.teamPanel === p) { p.flash = ''; renderFrame(); } }, 2500);
|
|
14923
|
+
renderFrame();
|
|
14924
|
+
return;
|
|
14925
|
+
}
|
|
14926
|
+
|
|
14927
|
+
// `m` — cycle the selected member's permission mode: ask → auto → yolo → ask.
|
|
14928
|
+
//
|
|
14929
|
+
// The mode is recorded on the roster so a respawn honours it; a running member keeps its current
|
|
14930
|
+
// setting until it restarts, since the hook is captured when the agent is built.
|
|
14931
|
+
if (res.action === 'cycleMode') {
|
|
14932
|
+
try {
|
|
14933
|
+
const order = ['ask', 'auto', 'yolo'];
|
|
14934
|
+
const cur = (teamMod.findMember(p.teamName, res.name) || {}).permissionMode || 'ask';
|
|
14935
|
+
const next = order[(order.indexOf(cur) + 1) % order.length];
|
|
14936
|
+
teamMod.setMemberMode(p.teamName, res.name, next);
|
|
14937
|
+
p.flash = `${res.name}: ${next}${next === 'ask' ? '' : ' (takes effect on restart)'}`;
|
|
14938
|
+
} catch (e) {
|
|
14939
|
+
p.flash = `could not set mode: ${(e && e.message) || e}`;
|
|
14940
|
+
}
|
|
14941
|
+
setTimeout(() => { if (state.teamPanel === p) { p.flash = ''; renderFrame(); } }, 3000);
|
|
14942
|
+
renderFrame();
|
|
14943
|
+
return;
|
|
14944
|
+
}
|
|
14008
14945
|
renderFrame();
|
|
14009
14946
|
}
|
|
14010
14947
|
|
|
@@ -14258,14 +15195,14 @@ export async function startTUI(opts) {
|
|
|
14258
15195
|
return true;
|
|
14259
15196
|
}
|
|
14260
15197
|
|
|
14261
|
-
// Kill / abort one task by id. An agent task aborts through its
|
|
14262
|
-
// process is killed as a tree (Windows) or by signal.
|
|
15198
|
+
// Kill / abort one task by id. An agent OR teammate task aborts through its
|
|
15199
|
+
// controller (`_abort`); a process is killed as a tree (Windows) or by signal.
|
|
14263
15200
|
function stopTask(ctxLike, id) {
|
|
14264
15201
|
if (!id) return;
|
|
14265
15202
|
const tk = getTask(ctxLike, id) || (state.tasks || {})[id];
|
|
14266
15203
|
if (!tk || tk.status !== 'running') return;
|
|
14267
15204
|
try {
|
|
14268
|
-
if (
|
|
15205
|
+
if (typeof tk._abort === 'function') {
|
|
14269
15206
|
tk._abort();
|
|
14270
15207
|
} else if (tk.pid) {
|
|
14271
15208
|
if (process.platform === 'win32') {
|
|
@@ -14331,6 +15268,10 @@ export async function startTUI(opts) {
|
|
|
14331
15268
|
// is the flash this ordering exists to prevent.
|
|
14332
15269
|
stdout.write(alternateScreen(true));
|
|
14333
15270
|
stdout.write(clearScreen());
|
|
15271
|
+
// From here the TUI owns stdout: every frame is painted as a whole screen, so a stray console.log from
|
|
15272
|
+
// anywhere — a plugin's load-time output, a stray debug line — writes over the frame. Plugins report
|
|
15273
|
+
// through the notice queue instead, which the TUI drains into its own transcript.
|
|
15274
|
+
try { setPluginLoggerTui(true); } catch { /* the logger is best-effort */ }
|
|
14334
15275
|
// Adopt the session's title immediately. A RESUMED session already has one, and without
|
|
14335
15276
|
// this the terminal kept whatever the shell had put there — the title only appeared once
|
|
14336
15277
|
// something CHANGED it (`/title`, or a fresh auto-naming), so reopening a named session
|
|
@@ -14343,13 +15284,36 @@ export async function startTUI(opts) {
|
|
|
14343
15284
|
// `caps.kitty === null` (nothing answered) keeps the old default of ON, so a
|
|
14344
15285
|
// terminal that simply says nothing is treated exactly as before.
|
|
14345
15286
|
if (process.env.HNCODE_NO_KITTY_KEYS !== '1' && kittyKeysUsable()) stdout.write('\x1b[>1u');
|
|
14346
|
-
// Mouse modes,
|
|
14347
|
-
//
|
|
14348
|
-
//
|
|
14349
|
-
//
|
|
14350
|
-
//
|
|
14351
|
-
|
|
14352
|
-
|
|
15287
|
+
// Mouse modes, enabled per mode and only when the terminal said it KNOWS that mode.
|
|
15288
|
+
//
|
|
15289
|
+
// THE PROBE IS THE POINT. These sequences were written unconditionally, so a terminal that does not
|
|
15290
|
+
// implement one — or accepts it and never sends an event — left the application waiting for reports
|
|
15291
|
+
// that never come. The symptom is a UI whose keyboard works and whose mouse does nothing: clicks,
|
|
15292
|
+
// drags and the wheel all inert, with no error anywhere to explain it.
|
|
15293
|
+
//
|
|
15294
|
+
// `caps.mouseClick` and friends come from the DECRQM replies (`ESC[?1000$p` and so on). `undefined`
|
|
15295
|
+
// means the terminal did not answer, which is treated as YES: silence is not a refusal, and a
|
|
15296
|
+
// terminal that ignores DECRQM altogether has to keep working exactly as before. Only an explicit
|
|
15297
|
+
// "not recognised" (status 0) turns a mode off.
|
|
15298
|
+
//
|
|
15299
|
+
// HNCODE_NO_MOUSE_HOVER=1 drop any-motion regardless of the probe
|
|
15300
|
+
// HNCODE_NO_MOUSE=1 drop mouse reporting entirely, so the TERMINAL keeps the mouse: text
|
|
15301
|
+
// selection, the right-click menu and file drag-and-drop work again and
|
|
15302
|
+
// hncode's own click targets do not
|
|
15303
|
+
//
|
|
15304
|
+
// Bracket paste is NOT mouse reporting and stays on in every case — it is how a multi-line paste
|
|
15305
|
+
// arrives as one token.
|
|
15306
|
+
const mouseOff = process.env.HNCODE_NO_MOUSE === '1';
|
|
15307
|
+
const wants = (name, cap) => !mouseOff && cap !== false
|
|
15308
|
+
&& !(name === 'hover' && process.env.HNCODE_NO_MOUSE_HOVER === '1');
|
|
15309
|
+
stdout.write('\x1b[?2004h');
|
|
15310
|
+
if (wants('click', caps.mouseClick) || wants('drag', caps.mouseDrag)) {
|
|
15311
|
+
// SGR (`?1006`) is the coordinate encoding for everything above; it goes with them.
|
|
15312
|
+
stdout.write('\x1b[?1006h');
|
|
15313
|
+
if (wants('click', caps.mouseClick)) stdout.write('\x1b[?1000h');
|
|
15314
|
+
if (wants('drag', caps.mouseDrag)) stdout.write('\x1b[?1002h');
|
|
15315
|
+
}
|
|
15316
|
+
if (wants('hover', caps.mouseHover)) stdout.write('\x1b[?1003h');
|
|
14353
15317
|
// Start the PowerShell clipboard helper now: its cold start is seconds long,
|
|
14354
15318
|
// and doing it lazily on the first Ctrl+Shift+V made the shortcut look dead.
|
|
14355
15319
|
// Deferred by one turn of the event loop, though: `spawn` costs ~950ms here (the
|
|
@@ -14371,22 +15335,47 @@ export async function startTUI(opts) {
|
|
|
14371
15335
|
// to the model on its next turn.
|
|
14372
15336
|
if (typeof opts.mcpConnect === 'function') {
|
|
14373
15337
|
Promise.resolve()
|
|
14374
|
-
.then(() => opts.mcpConnect(
|
|
15338
|
+
.then(() => opts.mcpConnect({
|
|
15339
|
+
// Project-scope servers (the workspace's OWN .hncode/mcp.json) spawn
|
|
15340
|
+
// whatever command that file names, so they ask here before starting —
|
|
15341
|
+
// same promise-handshake as the tool-approval prompt. Esc refuses.
|
|
15342
|
+
approveProjectServer: (name, def) => new Promise((resolve) => {
|
|
15343
|
+
const target = def && def.url
|
|
15344
|
+
? def.url
|
|
15345
|
+
: `${(def && def.command) || ''} ${((def && def.args) || []).join(' ')}`.trim();
|
|
15346
|
+
state.approvalPending = {
|
|
15347
|
+
toolName: 'MCP server',
|
|
15348
|
+
args: {},
|
|
15349
|
+
desc: `Start project MCP server "${name}" (${target})?`,
|
|
15350
|
+
detail: [
|
|
15351
|
+
'This server is defined by THIS WORKSPACE (.hncode/mcp.json), not by you.',
|
|
15352
|
+
'Approving runs its command on this machine. Esc refuses for this launch.',
|
|
15353
|
+
],
|
|
15354
|
+
resolve,
|
|
15355
|
+
};
|
|
15356
|
+
viewCur.render();
|
|
15357
|
+
}),
|
|
15358
|
+
}))
|
|
14375
15359
|
.then((res) => {
|
|
14376
15360
|
const servers = (res && res.servers) || [];
|
|
14377
15361
|
if (!servers.length) return;
|
|
14378
15362
|
// The notice line is a SINGLE status-line row, so report all servers in
|
|
14379
15363
|
// one message — looping notice() per server only left the last one
|
|
14380
15364
|
// visible. Failures are listed too, and the whole notice turns red when
|
|
14381
|
-
// any server failed.
|
|
15365
|
+
// any server failed. Project servers the user refused are REPORTED, not
|
|
15366
|
+
// failures — they are a decision, not an error.
|
|
14382
15367
|
const okServers = servers.filter((s) => s.ok);
|
|
14383
|
-
const
|
|
15368
|
+
const skipped = servers.filter((s) => s.skipped);
|
|
15369
|
+
const bad = servers.filter((s) => !s.ok && !s.skipped);
|
|
14384
15370
|
const parts = [];
|
|
14385
15371
|
if (okServers.length) {
|
|
14386
15372
|
const tools = okServers.reduce((n, s) => n + (s.toolCount || 0), 0);
|
|
14387
15373
|
const names = okServers.map((s) => s.name).join(', ');
|
|
14388
15374
|
parts.push(`MCP connected: ${names} (${tools} tool${tools === 1 ? '' : 's'})`);
|
|
14389
15375
|
}
|
|
15376
|
+
if (skipped.length) {
|
|
15377
|
+
parts.push(`MCP not started (needs approval): ${skipped.map((s) => s.name).join(', ')}`);
|
|
15378
|
+
}
|
|
14390
15379
|
if (bad.length) {
|
|
14391
15380
|
parts.push(`MCP failed: ${bad.map((s) => s.name).join(', ')}`);
|
|
14392
15381
|
}
|
|
@@ -14445,7 +15434,10 @@ export async function startTUI(opts) {
|
|
|
14445
15434
|
// red phase. The SPINNER frame is derived with `% SPINNER.length` at the
|
|
14446
15435
|
// call site instead.
|
|
14447
15436
|
state.spin = (state.spin || 0) + 1;
|
|
14448
|
-
|
|
15437
|
+
// Coalesced like every other repaint trigger: a direct paintNow here ran a
|
|
15438
|
+
// full compose+diff on top of the 16ms stream ticks and the 33ms anim timer,
|
|
15439
|
+
// up to ~90 recomposes a second during a turn.
|
|
15440
|
+
renderSoon();
|
|
14449
15441
|
}, 80);
|
|
14450
15442
|
|
|
14451
15443
|
// Paced reveal tick. The 80ms spinner tick above is too coarse for this: at 12.5
|
|
@@ -14464,11 +15456,27 @@ export async function startTUI(opts) {
|
|
|
14464
15456
|
}, STREAM_TICK_MS);
|
|
14465
15457
|
if (streamTimer.unref) streamTimer.unref();
|
|
14466
15458
|
|
|
15459
|
+
// Animation tick: every animated row redraws at FRAME_MS.
|
|
15460
|
+
//
|
|
15461
|
+
// The 80ms spinner tick above is far too coarse for a light that must APPEAR to travel — at that rate
|
|
15462
|
+
// it lands a character apart and reads as cells being switched on one at a time, which is what the
|
|
15463
|
+
// crossing looked like before this existed. The rate lives in one named constant because the crossing
|
|
15464
|
+
// time is stated in frames: `CROSS_MS` at 30fps is six of them.
|
|
15465
|
+
//
|
|
15466
|
+
// It runs only while a turn is running, which is the only time the Working row or a pending tool row
|
|
15467
|
+
// can be on screen, so an idle session and a finished turn pay nothing. The light takes its phase from
|
|
15468
|
+
// the wall clock, so this timer does not advance any counter — it only asks for redraws.
|
|
15469
|
+
const animTimer = setInterval(() => {
|
|
15470
|
+
if (!state.running) return;
|
|
15471
|
+
renderSoon();
|
|
15472
|
+
}, FRAME_MS);
|
|
15473
|
+
if (animTimer.unref) animTimer.unref();
|
|
15474
|
+
|
|
14467
15475
|
const TOK_WINDOW_MS = 1000;
|
|
14468
15476
|
const tokTimer = setInterval(() => {
|
|
14469
15477
|
const times = state._tokTimes;
|
|
14470
15478
|
if (!times || !times.length) {
|
|
14471
|
-
if (state.tokRate !== 0) { state.tokRate = 0;
|
|
15479
|
+
if (state.tokRate !== 0) { state.tokRate = 0; renderSoon(); }
|
|
14472
15480
|
return;
|
|
14473
15481
|
}
|
|
14474
15482
|
const cutoff = Date.now() - TOK_WINDOW_MS;
|
|
@@ -14476,7 +15484,7 @@ export async function startTUI(opts) {
|
|
|
14476
15484
|
while (drop < times.length && times[drop] < cutoff) drop++;
|
|
14477
15485
|
if (drop) times.splice(0, drop);
|
|
14478
15486
|
const rate = times.length;
|
|
14479
|
-
if (rate !== state.tokRate) { state.tokRate = rate;
|
|
15487
|
+
if (rate !== state.tokRate) { state.tokRate = rate; renderSoon(); }
|
|
14480
15488
|
}, 50);
|
|
14481
15489
|
|
|
14482
15490
|
// ---- auto-update (background + SILENT, never blocks the TUI) --------------
|
|
@@ -14767,11 +15775,24 @@ export async function startTUI(opts) {
|
|
|
14767
15775
|
const t = state._lastMouse;
|
|
14768
15776
|
const clickCol = t ? (t.col || 1) - 1 : 1;
|
|
14769
15777
|
// The column is measured from the row's PREFIX, because `composerTextIndexAt`
|
|
14770
|
-
// subtracts `preWidth` itself. The row is `
|
|
15778
|
+
// subtracts `preWidth` itself. The row is ` |❯ text`: 0-based 0 is CPAD, 1 the wall,
|
|
14771
15779
|
// 2-3 the `❯ ` prefix, and the text starts at 5. For text[i] the terminal column is
|
|
14772
15780
|
// 6 + i (1-based), so `clickCol` is 5 + i and this must pass 2 + i — an offset of 3.
|
|
14773
15781
|
const colInContent = Math.max(0, clickCol - 3);
|
|
14774
15782
|
const idx = composerTextIndexAt(layout, hb.rowIdx, colInContent);
|
|
15783
|
+
|
|
15784
|
+
// CTRL+CLICK on a collapsed paste expands it in place.
|
|
15785
|
+
//
|
|
15786
|
+
// A marker is a chip: the whole `[paste #1 +12 lines]` stands in for content the user cannot see,
|
|
15787
|
+
// and until now the only way to read it back was to delete the chip. Ctrl+click is the gesture
|
|
15788
|
+
// that says "show me what this is" without changing what the message will contain — `expandPastes`
|
|
15789
|
+
// runs at submit either way, so the text that is SENT is identical.
|
|
15790
|
+
if (t && t.ctrl) {
|
|
15791
|
+
// `idx` is already the input index, so the helper does not convert again — see its comment.
|
|
15792
|
+
const expanded = expandPasteMarkerAt(state, idx);
|
|
15793
|
+
if (expanded) { renderFrame(); return true; }
|
|
15794
|
+
}
|
|
15795
|
+
|
|
14775
15796
|
state.caret = idx;
|
|
14776
15797
|
// Store mousedown position for potential drag selection in the composer.
|
|
14777
15798
|
state._composerMouseDown = { row: hb.rowIdx, col: idx, caret: idx };
|
|
@@ -14858,8 +15879,8 @@ export async function startTUI(opts) {
|
|
|
14858
15879
|
f.type = hb.option;
|
|
14859
15880
|
// A click on the Type is the LAST decision in this form, so it submits — the
|
|
14860
15881
|
// 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
|
|
14862
|
-
//
|
|
15882
|
+
// the option and the user still had to reach for the keyboard — clicking a choice
|
|
15883
|
+
// should complete it, the way pressing Enter on it would.
|
|
14863
15884
|
if (f.typeClickSubmits) {
|
|
14864
15885
|
const values = {};
|
|
14865
15886
|
for (const fl of f.fields || []) values[fl.key] = String(fl.value || '').trim();
|
|
@@ -15039,8 +16060,33 @@ export async function startTUI(opts) {
|
|
|
15039
16060
|
// The queue resize handle also gets a hover flag.
|
|
15040
16061
|
const onQueueRule = !!(hb && hb.kind === 'queueResize');
|
|
15041
16062
|
if (onQueueRule !== state.queueResizeHover) { state.queueResizeHover = onQueueRule; renderFrame(); }
|
|
16063
|
+
|
|
16064
|
+
// A COLLAPSED PASTE UNDER THE POINTER is highlighted and explained. This is the only hover cue in
|
|
16065
|
+
// the composer: the chip is otherwise drawn exactly like the text around it, so nothing says it can
|
|
16066
|
+
// be clicked at all, let alone with Ctrl.
|
|
16067
|
+
//
|
|
16068
|
+
// The index comes from the same conversion the Ctrl+click handler uses, so the chip that lights up
|
|
16069
|
+
// is always the chip that would expand.
|
|
16070
|
+
let pasteHover = null;
|
|
16071
|
+
if (hb && hb.kind === 'composerRow') {
|
|
16072
|
+
const { cols } = dims();
|
|
16073
|
+
const insideW = Math.max(0, cols - 2);
|
|
16074
|
+
const layout = composerLayout(state, insideW - 3);
|
|
16075
|
+
const clickCol = Math.max(0, (t.col || 1) - 1 - 3);
|
|
16076
|
+
pasteHover = pasteMarkerIdAt(state.input || '', composerTextIndexAt(layout, hb.rowIdx, clickCol));
|
|
16077
|
+
}
|
|
16078
|
+
if (pasteHover !== state.pasteHover) {
|
|
16079
|
+
state.pasteHover = pasteHover;
|
|
16080
|
+
// The notice line is the second half of the cue: the highlight says WHICH, the hint says WHAT a
|
|
16081
|
+
// click would do. It is cleared when the pointer leaves, so it never outlives the pointer being
|
|
16082
|
+
// on the chip.
|
|
16083
|
+
if (pasteHover != null) notice('Ctrl+Click to expand it', 'info');
|
|
16084
|
+
else clearNotice();
|
|
16085
|
+
renderFrame();
|
|
16086
|
+
}
|
|
16087
|
+
|
|
15042
16088
|
// The composer rows stay click targets (mousedown places the caret), but
|
|
15043
|
-
// they are NOT highlighted on hover — tinting the prompt you are typing
|
|
16089
|
+
// they are NOT otherwise highlighted on hover — tinting the prompt you are typing
|
|
15044
16090
|
// is noise, not feedback.
|
|
15045
16091
|
const next = (hb && hb.kind !== 'composerRow') ? { row: hb.row, col0: hb.col0, col1: hb.col1 } : null;
|
|
15046
16092
|
const prev = state.hoverHit;
|
|
@@ -15189,16 +16235,34 @@ export async function startTUI(opts) {
|
|
|
15189
16235
|
else if (r === end.row) { c0 = 0; c1 = end.col + 1; }
|
|
15190
16236
|
else { c0 = 0; c1 = Infinity; }
|
|
15191
16237
|
|
|
15192
|
-
|
|
16238
|
+
// COLUMN 0 IS NEVER PART OF THE SELECTION, exactly as in the highlight — see `highlightAnsiRange`.
|
|
16239
|
+
//
|
|
16240
|
+
// A row is drawn with one column of left margin, so a drag that starts at the left edge paints it
|
|
16241
|
+
// too, and copying a reply then puts a leading space on the clipboard on EVERY line. The highlight
|
|
16242
|
+
// has skipped it all along; the copy was the half that did not, which made the two disagree about
|
|
16243
|
+
// what "selected" meant — you could see it and then paste something wider.
|
|
16244
|
+
const sliced = slicePlainByCol(plain, Math.max(c0, 1), c1);
|
|
15193
16245
|
const clean = sliced
|
|
15194
16246
|
.replace(/<\/?thinking\b[^>]*>/gi, '')
|
|
15195
16247
|
.replace(/<\/?think\b[^>]*>/gi, '');
|
|
15196
|
-
|
|
16248
|
+
// NO BLANKET TRAILING-WHITESPACE STRIP.
|
|
16249
|
+
//
|
|
16250
|
+
// It used to end with `.replace(/\s+$/, '')`, which made the copy disagree with the highlight: the
|
|
16251
|
+
// selection painted the space the pointer ended on, and the clipboard came back one character
|
|
16252
|
+
// shorter. Copying `foo ` gave `foo`, and a selection of nothing but spaces gave an empty line.
|
|
16253
|
+
//
|
|
16254
|
+
// What is highlighted is what gets copied — but only up to the selection's own end. A row is
|
|
16255
|
+
// right-padded to the terminal width, so slicing to the end of a row would otherwise carry that
|
|
16256
|
+
// padding onto the clipboard. `c1` is the bound, so the padding is already outside it.
|
|
16257
|
+
out.push(clean);
|
|
15197
16258
|
}
|
|
15198
16259
|
return out.join('\n');
|
|
15199
16260
|
}
|
|
15200
16261
|
function slicePlainByCol(text, c0, c1) {
|
|
15201
|
-
|
|
16262
|
+
// A full-row slice keeps its content but loses the RIGHT PADDING. The row is padded to the terminal
|
|
16263
|
+
// width, and `c1 === Infinity` means "to the end of the row" — copying that padding would put
|
|
16264
|
+
// invisible spaces on the clipboard that were never selected.
|
|
16265
|
+
if (c0 === 0 && c1 === Infinity) return text.replace(/\s+$/, '');
|
|
15202
16266
|
let out = '';
|
|
15203
16267
|
let col = 0;
|
|
15204
16268
|
const s = String(text || '');
|
|
@@ -15269,10 +16333,10 @@ export async function startTUI(opts) {
|
|
|
15269
16333
|
// Async so the notice can be painted BEFORE the clipboard read: a cold
|
|
15270
16334
|
// PowerShell took ~3s, which read as "the shortcut does nothing".
|
|
15271
16335
|
async function pasteFromClipboard(fastPathOnly = false) {
|
|
15272
|
-
|
|
15273
|
-
//
|
|
15274
|
-
//
|
|
15275
|
-
//
|
|
16336
|
+
// NO "Pasting…" FLASH. It was there to reassure during a cold PowerShell read (~3s), but the read is
|
|
16337
|
+
// now 7-12ms warm, so the notice did nothing except paint a frame, get replaced a moment later, and
|
|
16338
|
+
// make the paste feel like it was waiting for something. The text appears, or an error is reported —
|
|
16339
|
+
// both are visible without an intermediate state.
|
|
15276
16340
|
const { text, via } = await readClipboardContentAsync();
|
|
15277
16341
|
if (text) {
|
|
15278
16342
|
// An image collapses to a `[paste #N image]` chip, the same way a multi-line
|
|
@@ -15535,6 +16599,29 @@ export async function startTUI(opts) {
|
|
|
15535
16599
|
}
|
|
15536
16600
|
return; // swallow other keys while reviewing
|
|
15537
16601
|
}
|
|
16602
|
+
|
|
16603
|
+
// ESC INTERRUPTS A RUNNING TURN.
|
|
16604
|
+
//
|
|
16605
|
+
// It did nothing during tool execution — only Ctrl+C interrupted, and Ctrl+C also arms the exit
|
|
16606
|
+
// prompt, so the one key a person reaches for when a command is taking too long was the one that
|
|
16607
|
+
// could quit the app instead. `interrupt()` aborts the tool (via `toolAbort`) and the model request,
|
|
16608
|
+
// which is exactly what the user means by pressing it.
|
|
16609
|
+
//
|
|
16610
|
+
// Placed AFTER the overlay branches above so Esc still closes a picker, a form, or an approval
|
|
16611
|
+
// prompt first — those own the keyboard while open, and interrupting the turn behind them would be
|
|
16612
|
+
// the wrong answer to "close this dialog".
|
|
16613
|
+
// Overlays own the keyboard while open, so Esc keeps its other job of closing them. The check is
|
|
16614
|
+
// inline rather than a named local because `test/esc-interrupt.test.mjs` slices this branch to verify
|
|
16615
|
+
// the exclusion list, and a local defined above the slice was invisible to it.
|
|
16616
|
+
if (t.key === 'escape' && state.running && state.agent
|
|
16617
|
+
&& !state.menuOpen && !state.picker && !state.form && !state.panel
|
|
16618
|
+
&& !state.question && !state.editor && !state.approvalPending) {
|
|
16619
|
+
try { state.agent.interrupt(); } catch { /* an already-finished turn */ }
|
|
16620
|
+
notice('Interrupted', 'info');
|
|
16621
|
+
renderFrame();
|
|
16622
|
+
return;
|
|
16623
|
+
}
|
|
16624
|
+
|
|
15538
16625
|
if (t.key === 'c-c') {
|
|
15539
16626
|
// An open overlay consumes Ctrl+C: close it instead of interrupting the
|
|
15540
16627
|
// turn that is still running behind it. The modal editor belongs here too:
|
|
@@ -15803,6 +16890,33 @@ export async function startTUI(opts) {
|
|
|
15803
16890
|
return;
|
|
15804
16891
|
}
|
|
15805
16892
|
}
|
|
16893
|
+
// Ctrl+A — SELECT ALL of the composer. An ordinary editor key, and its absence was conspicuous:
|
|
16894
|
+
|
|
16895
|
+
// Ctrl+A — SELECT ALL of the composer. An ordinary editor key, and its absence was conspicuous:
|
|
16896
|
+
// Ctrl+X, Ctrl+C and Ctrl+V all worked on a selection there was no keyboard way to make.
|
|
16897
|
+
//
|
|
16898
|
+
// Handled BEFORE the approval dialog's own `c-a` above, which is why that one tests
|
|
16899
|
+
// `state.approvalPending` first — while a dialog is open it owns the keyboard, and there `c-a` means
|
|
16900
|
+
// "approve for the rest of the session" rather than "select all".
|
|
16901
|
+
//
|
|
16902
|
+
// The selection uses the same `{anchor, head}` the mouse drag produces, so copy, cut and the
|
|
16903
|
+
// right-click menu treat it identically.
|
|
16904
|
+
if (t.key === 'c-a' && !state.approvalPending && !state.editor) {
|
|
16905
|
+
const cur = state.input || '';
|
|
16906
|
+
if (!cur) {
|
|
16907
|
+
// Nothing to select: say so rather than silently doing nothing, which reads as a dead key.
|
|
16908
|
+
notice('Nothing to select', 'error');
|
|
16909
|
+
renderFrame();
|
|
16910
|
+
return;
|
|
16911
|
+
}
|
|
16912
|
+
state.composerSel = { anchor: 0, head: cur.length };
|
|
16913
|
+
// The caret goes to the END, which is where an editor leaves it: the next typed character then
|
|
16914
|
+
// REPLACES the selection rather than being inserted at the start of it.
|
|
16915
|
+
state.caret = cur.length;
|
|
16916
|
+
refreshMenu(state);
|
|
16917
|
+
renderFrame();
|
|
16918
|
+
return;
|
|
16919
|
+
}
|
|
15806
16920
|
|
|
15807
16921
|
// Ctrl+X / Ctrl+Shift+X — CUT. Removes the composer's selected text (or the
|
|
15808
16922
|
// whole line when nothing is selected) and puts it on the clipboard, like a
|
|
@@ -16854,6 +17968,48 @@ export async function startTUI(opts) {
|
|
|
16854
17968
|
state.mentionOffset = 0;
|
|
16855
17969
|
state.historyIdx = -1;
|
|
16856
17970
|
if (!text) { renderFrame(); return; }
|
|
17971
|
+
// `@name message` sends STRAIGHT to a teammate, without the model.
|
|
17972
|
+
//
|
|
17973
|
+
// This is Claude Code's `parseDirectMemberMessage`, and the reason it exists: routing a one-line
|
|
17974
|
+
// instruction through the conversation costs a full model turn for the lead, and the lead may reword
|
|
17975
|
+
// it. When you already know who should do it and what to say, the mailbox is the shorter path — and
|
|
17976
|
+
// it is the only way to steer a member while the lead is mid-turn.
|
|
17977
|
+
if (text.startsWith('@')) {
|
|
17978
|
+
const m = /^@([\w-]+)\s+([\s\S]+)$/.exec(text);
|
|
17979
|
+
if (!m) {
|
|
17980
|
+
notice('Use `@name message` to send straight to a member.', 'error');
|
|
17981
|
+
renderFrame();
|
|
17982
|
+
return;
|
|
17983
|
+
}
|
|
17984
|
+
const [, who, body] = m;
|
|
17985
|
+
const teamName = leadTeam();
|
|
17986
|
+
if (!teamName) {
|
|
17987
|
+
notice('No team. Create one: /team create <name>', 'error');
|
|
17988
|
+
renderFrame();
|
|
17989
|
+
return;
|
|
17990
|
+
}
|
|
17991
|
+
const roster = teamMod.readRoster(teamName);
|
|
17992
|
+
const names = String(roster || '').split('\n').map((l) => l.trim().split(/\s+/)[0]).filter(Boolean);
|
|
17993
|
+
// The user is not on the roster, so `@team-lead` would look unknown — it is accepted because a
|
|
17994
|
+
// member's own sends use that alias and it is the box the person reads.
|
|
17995
|
+
if (!names.includes(who) && who !== TEAM_LEAD_NAME) {
|
|
17996
|
+
notice(`No member "${who}". On this team: ${names.join(', ') || '(nobody)'}`, 'error');
|
|
17997
|
+
renderFrame();
|
|
17998
|
+
return;
|
|
17999
|
+
}
|
|
18000
|
+
try {
|
|
18001
|
+
mailMod.writeToMailbox(teamName, who, { from: TEAM_LEAD_NAME, text: body.trim() });
|
|
18002
|
+
// Shown in the transcript so the instruction is part of the record: a message that only exists in
|
|
18003
|
+
// someone's inbox is one the person cannot see themselves having sent.
|
|
18004
|
+
addChat({ role: 'system', text: `[you → ${who}] ${body.trim()}` });
|
|
18005
|
+
notice(`sent to ${who}`);
|
|
18006
|
+
} catch (e) {
|
|
18007
|
+
notice(`could not send: ${(e && e.message) || e}`, 'error');
|
|
18008
|
+
}
|
|
18009
|
+
renderFrame();
|
|
18010
|
+
return;
|
|
18011
|
+
}
|
|
18012
|
+
|
|
16857
18013
|
if (!state.running) {
|
|
16858
18014
|
state.turnStart = Date.now();
|
|
16859
18015
|
// The turn's own output tokens, summed across its requests. Reset HERE so the
|
|
@@ -16868,7 +18024,7 @@ export async function startTUI(opts) {
|
|
|
16868
18024
|
// spinner happens to be in when the start animation ends.
|
|
16869
18025
|
state.pulseStart = null;
|
|
16870
18026
|
}
|
|
16871
|
-
|
|
18027
|
+
|
|
16872
18028
|
if (state.history[state.history.length - 1] !== text) {
|
|
16873
18029
|
state.history.push(text);
|
|
16874
18030
|
saveHistory(state.history); // persist so ↑ survives a restart
|
|
@@ -17808,8 +18964,10 @@ export async function startTUI(opts) {
|
|
|
17808
18964
|
}
|
|
17809
18965
|
|
|
17810
18966
|
// The one-shot namer. Sentence-case, 3-7 words, with worked examples — a bare
|
|
17811
|
-
// "at most 50 characters" let the model echo
|
|
17812
|
-
//
|
|
18967
|
+
// "at most 50 characters" let the model echo a bare greeting back as a title,
|
|
18968
|
+
// which is not a name anyone can find in a list. The Chinese example is
|
|
18969
|
+
// deliberate: in a Chinese conversation the failing case IS that greeting, and
|
|
18970
|
+
// an English-only example did not stop it.
|
|
17813
18971
|
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
18972
|
+ '\n'
|
|
17815
18973
|
+ 'Capture the main topic or goal so the user recognises the session in a list.\n'
|
|
@@ -17867,6 +19025,16 @@ export async function startTUI(opts) {
|
|
|
17867
19025
|
if (spinTimer) clearInterval(spinTimer);
|
|
17868
19026
|
if (streamTimer) clearInterval(streamTimer);
|
|
17869
19027
|
if (tokTimer) clearInterval(tokTimer);
|
|
19028
|
+
// The rest of the intervals created by startTUI. Leaving any of them armed
|
|
19029
|
+
// kept the process alive — and worse, `animTimer` kept PAINTING after the
|
|
19030
|
+
// alternate screen was released (diff frames onto the primary screen), and
|
|
19031
|
+
// `gitTimer`/`autoCheckTimer` could spawn a child during shutdown.
|
|
19032
|
+
if (gitTimer) clearInterval(gitTimer);
|
|
19033
|
+
if (scheduleTimer) clearInterval(scheduleTimer);
|
|
19034
|
+
if (animTimer) clearInterval(animTimer);
|
|
19035
|
+
if (autoCheckTimer) clearInterval(autoCheckTimer);
|
|
19036
|
+
if (noticeTimer) clearTimeout(noticeTimer);
|
|
19037
|
+
if (escTimer) clearTimeout(escTimer);
|
|
17870
19038
|
if (confirmTimer) clearTimeout(confirmTimer);
|
|
17871
19039
|
// The /tasks panel owns a flash timer; leaving it armed keeps the process
|
|
17872
19040
|
// alive until it fires after exit.
|
|
@@ -17882,6 +19050,9 @@ export async function startTUI(opts) {
|
|
|
17882
19050
|
stdout.write('\x1b[<u\x1b[?2004l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l');
|
|
17883
19051
|
stdout.write(alternateScreen(false));
|
|
17884
19052
|
stdout.write(showCursor());
|
|
19053
|
+
// The TUI no longer owns stdout, so plugin logging goes back to the console. Without this a later
|
|
19054
|
+
// load — a second /reload, or a command run after the screen is released — would be swallowed.
|
|
19055
|
+
try { setPluginLoggerTui(false); } catch { /* the logger is best-effort */ }
|
|
17885
19056
|
const id = session && session.id;
|
|
17886
19057
|
const work = session && session.workspace;
|
|
17887
19058
|
if (id) {
|
|
@@ -17913,6 +19084,10 @@ function quit() {
|
|
|
17913
19084
|
bye();
|
|
17914
19085
|
}
|
|
17915
19086
|
process.on('SIGWINCH', () => renderFrame());
|
|
19087
|
+
// Windows never delivers SIGWINCH — Node reports tty resizes there (and on
|
|
19088
|
+
// every platform) as the stream's own 'resize' event. Without this a terminal
|
|
19089
|
+
// resize left the screen stale until an unrelated repaint happened to fire.
|
|
19090
|
+
if (process.stdout.isTTY) process.stdout.on('resize', () => renderFrame());
|
|
17916
19091
|
process.on('SIGINT', () => quit());
|
|
17917
19092
|
|
|
17918
19093
|
/**
|
|
@@ -17983,6 +19158,18 @@ function quit() {
|
|
|
17983
19158
|
// delete them here so they cannot pile up in the temp dir. Synchronous, and it
|
|
17984
19159
|
// runs on the graceful AND the crash path because 'exit' always fires.
|
|
17985
19160
|
try { cleanupPastedImages(); } catch { /* never block the exit */ }
|
|
19161
|
+
// The clipboard helper is a PowerShell child. Without this it would outlive the TUI, and worse, a
|
|
19162
|
+
// helper that exits during shutdown would be respawned by the keep-alive in dropHelper.
|
|
19163
|
+
try { stopClipboardHelper(); } catch { /* never block the exit */ }
|
|
19164
|
+
// Language servers and MCP stdio children outlive the process unless they are
|
|
19165
|
+
// killed here — the exit handler cannot await the polite shutdown handshake,
|
|
19166
|
+
// so they are killed outright. Each can hold hundreds of MB.
|
|
19167
|
+
try { killLspClientsSync(); } catch { /* never block the exit */ }
|
|
19168
|
+
try {
|
|
19169
|
+
for (const c of getLiveConnections()) {
|
|
19170
|
+
if (c && c.transport && c.transport.child) { try { c.transport.child.kill(); } catch { /* gone */ } }
|
|
19171
|
+
}
|
|
19172
|
+
} catch { /* never block the exit */ }
|
|
17986
19173
|
noteExit(`exit code=${code}`);
|
|
17987
19174
|
if (stopped) return;
|
|
17988
19175
|
try { stdout.write('\x1b[<u\x1b[?2004l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l' + alternateScreen(false) + showCursor()); } catch {}
|