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