@aragon-agent/cli 0.6.12 → 0.6.13
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/README.md +81 -15
- package/dist/agent/controller.d.ts +29 -1
- package/dist/agent/controller.js +123 -22
- package/dist/agent/model-profile-settings.d.ts +31 -0
- package/dist/agent/model-profile-settings.js +199 -0
- package/dist/agent/queued-messages.d.ts +12 -0
- package/dist/agent/queued-messages.js +18 -0
- package/dist/agent/reducer.d.ts +13 -1
- package/dist/agent/reducer.js +51 -12
- package/dist/cli.js +15 -1
- package/dist/commands/builtins.js +66 -39
- package/dist/commands/queue-command.d.ts +3 -0
- package/dist/commands/queue-command.js +5 -0
- package/dist/commands/registry.d.ts +3 -0
- package/dist/compaction/child.d.ts +4 -2
- package/dist/compaction/child.js +4 -1
- package/dist/compaction/compactor.d.ts +7 -2
- package/dist/compaction/compactor.js +13 -11
- package/dist/compaction/context-command.js +5 -0
- package/dist/compaction/wiring.d.ts +4 -3
- package/dist/compaction/wiring.js +6 -1
- package/dist/config/cli-commands.d.ts +2 -0
- package/dist/config/cli-commands.js +16 -4
- package/dist/config/load.d.ts +2 -1
- package/dist/config/load.js +15 -8
- package/dist/config/model-profile-resolution.d.ts +20 -0
- package/dist/config/model-profile-resolution.js +81 -0
- package/dist/config/model-profile-store.d.ts +67 -0
- package/dist/config/model-profile-store.js +153 -0
- package/dist/config/model-profiles.d.ts +44 -0
- package/dist/config/model-profiles.js +121 -0
- package/dist/config/schema.d.ts +9 -0
- package/dist/config/store.d.ts +11 -1
- package/dist/config/store.js +43 -27
- package/dist/fast/resolve.d.ts +2 -1
- package/dist/fast/resolve.js +6 -4
- package/dist/fast/wiring.d.ts +5 -3
- package/dist/fast/wiring.js +8 -4
- package/dist/input/stdin-filter.js +3 -2
- package/dist/logging/install.js +4 -2
- package/dist/logging/secret-registry.d.ts +2 -0
- package/dist/logging/secret-registry.js +18 -0
- package/dist/team/runtime.d.ts +2 -1
- package/dist/team/subagent.d.ts +3 -1
- package/dist/team/subagent.js +3 -1
- package/dist/ui/ActivityLine.d.ts +6 -70
- package/dist/ui/ActivityLine.js +12 -34
- package/dist/ui/App.d.ts +1 -0
- package/dist/ui/App.js +113 -205
- package/dist/ui/BottomStatusRow.d.ts +6 -86
- package/dist/ui/BottomStatusRow.js +11 -26
- package/dist/ui/Composer.d.ts +16 -83
- package/dist/ui/Composer.js +9 -100
- package/dist/ui/PromptInput.d.ts +4 -1
- package/dist/ui/PromptInput.js +41 -34
- package/dist/ui/QueuePanel.d.ts +8 -0
- package/dist/ui/QueuePanel.js +9 -0
- package/dist/ui/QueueStatusRow.d.ts +2 -0
- package/dist/ui/QueueStatusRow.js +1 -1
- package/dist/ui/StatusBar.d.ts +13 -149
- package/dist/ui/StatusBar.js +27 -142
- package/dist/ui/clipboard-task.d.ts +29 -0
- package/dist/ui/clipboard-task.js +66 -0
- package/dist/ui/clipboard.d.ts +29 -50
- package/dist/ui/clipboard.js +141 -75
- package/dist/ui/composer-limits.d.ts +3 -4
- package/dist/ui/composer-limits.js +4 -9
- package/dist/ui/composer-rows.d.ts +1 -1
- package/dist/ui/composer-rows.js +24 -137
- package/dist/ui/editor-navigation.d.ts +45 -0
- package/dist/ui/editor-navigation.js +85 -0
- package/dist/ui/editor-reducer.d.ts +4 -0
- package/dist/ui/editor-reducer.js +29 -15
- package/dist/ui/interaction-copy.d.ts +77 -0
- package/dist/ui/interaction-copy.js +40 -0
- package/dist/ui/interaction-hints.d.ts +18 -0
- package/dist/ui/interaction-hints.js +92 -0
- package/dist/ui/layout/budget.js +1 -1
- package/dist/ui/layout/queue-layout.d.ts +19 -0
- package/dist/ui/layout/queue-layout.js +26 -0
- package/dist/ui/layout/status-layout.d.ts +55 -0
- package/dist/ui/layout/status-layout.js +109 -0
- package/dist/ui/layout/todo-layout.d.ts +1 -0
- package/dist/ui/layout/todo-layout.js +1 -1
- package/dist/ui/overlays/HelpOverlay.js +8 -8
- package/dist/ui/overlays/ModelProfileEditor.d.ts +12 -0
- package/dist/ui/overlays/ModelProfileEditor.js +103 -0
- package/dist/ui/overlays/ModelProfilePicker.d.ts +35 -0
- package/dist/ui/overlays/ModelProfilePicker.js +167 -0
- package/dist/ui/overlays/QueueOverlay.d.ts +22 -0
- package/dist/ui/overlays/QueueOverlay.js +79 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +9 -2
- package/dist/ui/overlays/SettingsScreen.js +347 -3
- package/dist/ui/overlays/model-profile-state.d.ts +74 -0
- package/dist/ui/overlays/model-profile-state.js +119 -0
- package/dist/ui/run-status-row.d.ts +7 -19
- package/dist/ui/run-status-row.js +2 -18
- package/dist/ui/selection/selection-controller.d.ts +2 -2
- package/dist/ui/selection/selection-controller.js +1 -0
- package/dist/ui/use-wheel-routing.js +2 -1
- package/package.json +2 -2
package/dist/ui/clipboard.js
CHANGED
|
@@ -1,91 +1,157 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Clipboard (tui-selection-and-scroll-follow §4.4.5) — the SINGLE clipboard path
|
|
3
|
-
* for the package. `/copy` and the selection release both come through here.
|
|
4
|
-
*
|
|
5
|
-
* Two mechanisms, both attempted, and the return value names the better one so
|
|
6
|
-
* the toast can say what was SENT rather than claiming a success neither one can
|
|
7
|
-
* confirm:
|
|
8
|
-
*
|
|
9
|
-
* 1. **OSC 52.** It reaches the clipboard of the machine the USER is sitting
|
|
10
|
-
* at, which is the only correct target over SSH, and it is the only mechanism
|
|
11
|
-
* that works with no helper binary installed. `/copy` gained SSH support the
|
|
12
|
-
* moment it moved onto this module.
|
|
13
|
-
* 2. **The platform binary** (`clip` / `pbcopy` / `xclip`), moved here unchanged
|
|
14
|
-
* from `commands/builtins.ts`, for the local case and for terminals that
|
|
15
|
-
* ignore OSC 52 (tmux without `set -g set-clipboard on`, R-7).
|
|
16
|
-
*
|
|
17
|
-
* IT TAKES A WRITE *DOOR*, NOT A STREAM, and that is P1-6 rather than taste. In
|
|
18
|
-
* full-screen mode stdout is a `Proxy` in front of the frame differ; an
|
|
19
|
-
* unrecognised chunk there takes `passThrough(true)`, which increments
|
|
20
|
-
* `fallbacks` and — on the first one — raises `onFirstFallback`, wired to
|
|
21
|
-
* `console.warn(FRAME_FALLBACK_NOTICE)`. Sending OSC 52 through the proxy would
|
|
22
|
-
* therefore print a diagnostic at a user who did nothing wrong the first time
|
|
23
|
-
* they copied anything, and would permanently corrupt a counter documented to
|
|
24
|
-
* mean "something wrote to stdout behind Ink's back". The caller supplies
|
|
25
|
-
* `writeForeign` instead, and this module cannot pick the wrong door.
|
|
26
|
-
*/
|
|
1
|
+
/** The only clipboard writer. Interaction callers share clipboard-task's coordinator. */
|
|
27
2
|
import { spawn } from 'node:child_process';
|
|
28
3
|
import process from 'node:process';
|
|
29
|
-
/**
|
|
30
|
-
* Ceiling on an OSC 52 payload, in bytes of the ORIGINAL text.
|
|
31
|
-
*
|
|
32
|
-
* xterm's default limit is around 100 000 BASE64 characters and tmux / screen
|
|
33
|
-
* are stricter, so 56 000 source bytes (~74 700 base64) stays inside all three
|
|
34
|
-
* with room to spare. Past it the sequence is skipped rather than truncated: a
|
|
35
|
-
* terminal that drops an over-long OSC 52 leaves the clipboard holding whatever
|
|
36
|
-
* was there before, and half a selection is worse than none.
|
|
37
|
-
*/
|
|
38
4
|
export const MAX_OSC52_BYTES = 56_000;
|
|
39
|
-
|
|
5
|
+
const CLEANUP_GRACE_MS = 500;
|
|
6
|
+
// clip.exe preserves a supplied BOM and guesses BOM-less UTF-16 incorrectly for
|
|
7
|
+
// short CJK text. Decode stdin explicitly; never interpolate clipboard content.
|
|
8
|
+
const WINDOWS_CLIPBOARD_COMMAND = "$ErrorActionPreference='Stop'; "
|
|
9
|
+
+ '$reader=[IO.StreamReader]::new([Console]::OpenStandardInput(),'
|
|
10
|
+
+ '[Text.UTF8Encoding]::new($false),$false); Set-Clipboard -Value $reader.ReadToEnd()';
|
|
11
|
+
/** Encode the full clipboard selection as UTF-8, without truncating its payload. */
|
|
40
12
|
export function osc52(text) {
|
|
41
|
-
return `\x1b]52;c;${Buffer.from(text, '
|
|
13
|
+
return `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`;
|
|
42
14
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
:
|
|
48
|
-
|
|
49
|
-
|
|
15
|
+
function sendOsc52(text, options, fallbackReason) {
|
|
16
|
+
if (options.signal?.aborted)
|
|
17
|
+
return { status: 'failed', reason: 'cancelled' };
|
|
18
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_OSC52_BYTES) {
|
|
19
|
+
return { status: 'failed', reason: 'too-large' };
|
|
20
|
+
}
|
|
21
|
+
if (!options.write)
|
|
22
|
+
return { status: 'failed', reason: fallbackReason ?? 'unavailable' };
|
|
50
23
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
/* swallow — clipboard is best-effort, and a missing xclip is ordinary */
|
|
54
|
-
});
|
|
55
|
-
child.stdin?.end(text);
|
|
56
|
-
return true;
|
|
24
|
+
options.write(osc52(text));
|
|
25
|
+
return { status: 'sent', via: 'osc52', ...(fallbackReason ? { fallbackReason } : {}) };
|
|
57
26
|
}
|
|
58
27
|
catch {
|
|
59
|
-
return
|
|
28
|
+
return { status: 'failed', reason: 'write' };
|
|
60
29
|
}
|
|
61
30
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
31
|
+
function settledTask(result) {
|
|
32
|
+
return { result: Promise.resolve(result), released: Promise.resolve() };
|
|
33
|
+
}
|
|
34
|
+
function spawnNative() {
|
|
35
|
+
const windows = process.platform === 'win32';
|
|
36
|
+
const file = windows ? 'powershell.exe' : process.platform === 'darwin' ? 'pbcopy' : 'xclip';
|
|
37
|
+
const args = windows
|
|
38
|
+
? ['-NoProfile', '-NonInteractive', '-STA', '-Command', WINDOWS_CLIPBOARD_COMMAND]
|
|
39
|
+
: file === 'xclip' ? ['-selection', 'clipboard'] : [];
|
|
40
|
+
return spawn(file, args, {
|
|
41
|
+
stdio: ['pipe', 'ignore', 'ignore'], shell: false, windowsHide: true,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** Owns a writer through close, even when its user-facing result timed out earlier. */
|
|
45
|
+
class NativeClipboardTask {
|
|
46
|
+
child;
|
|
47
|
+
text;
|
|
48
|
+
options;
|
|
49
|
+
result;
|
|
50
|
+
released;
|
|
51
|
+
resolveResult;
|
|
52
|
+
resolveReleased;
|
|
53
|
+
settled = false;
|
|
54
|
+
closed = false;
|
|
55
|
+
stopping = false;
|
|
56
|
+
failure;
|
|
57
|
+
timer;
|
|
58
|
+
cleanupTimer;
|
|
59
|
+
constructor(child, text, options) {
|
|
60
|
+
this.child = child;
|
|
61
|
+
this.text = text;
|
|
62
|
+
this.options = options;
|
|
63
|
+
this.result = new Promise((resolve) => { this.resolveResult = resolve; });
|
|
64
|
+
this.released = new Promise((resolve) => { this.resolveReleased = resolve; });
|
|
65
|
+
child.on('error', this.onError);
|
|
66
|
+
child.once('close', this.onClose);
|
|
67
|
+
child.stdin?.on('error', this.onStdinError);
|
|
68
|
+
options.signal?.addEventListener('abort', this.onAbort, { once: true });
|
|
69
|
+
this.timer = setTimeout(() => this.stop('timeout'), options.timeoutMs ?? 1500);
|
|
70
|
+
if (options.signal?.aborted) {
|
|
71
|
+
this.onAbort();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!child.stdin) {
|
|
75
|
+
this.stop('write');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
79
78
|
try {
|
|
80
|
-
|
|
81
|
-
sentOsc52 = true;
|
|
79
|
+
child.stdin.end(Buffer.from(text, 'utf8'));
|
|
82
80
|
}
|
|
83
81
|
catch {
|
|
84
|
-
|
|
82
|
+
this.stop('write');
|
|
85
83
|
}
|
|
86
84
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
settle(result) {
|
|
86
|
+
if (this.settled)
|
|
87
|
+
return;
|
|
88
|
+
this.settled = true;
|
|
89
|
+
clearTimeout(this.timer);
|
|
90
|
+
clearTimeout(this.cleanupTimer);
|
|
91
|
+
this.resolveResult(result);
|
|
92
|
+
}
|
|
93
|
+
stop(reason) {
|
|
94
|
+
if (this.closed || this.stopping)
|
|
95
|
+
return;
|
|
96
|
+
this.stopping = true;
|
|
97
|
+
this.failure = reason;
|
|
98
|
+
clearTimeout(this.timer);
|
|
99
|
+
// No fallback before close: even a successful kill request is not proof of exit.
|
|
100
|
+
if (!this.settled) {
|
|
101
|
+
this.cleanupTimer = setTimeout(() => {
|
|
102
|
+
this.settle({ status: 'failed', reason });
|
|
103
|
+
}, CLEANUP_GRACE_MS);
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
this.child.stdin?.destroy();
|
|
107
|
+
}
|
|
108
|
+
catch { /* Keep error listeners until close. */ }
|
|
109
|
+
try {
|
|
110
|
+
this.child.kill();
|
|
111
|
+
}
|
|
112
|
+
catch { /* A failed kill must not release the writer lock. */ }
|
|
113
|
+
}
|
|
114
|
+
onAbort = () => {
|
|
115
|
+
this.settle({ status: 'failed', reason: 'cancelled' });
|
|
116
|
+
this.stop('cancelled');
|
|
117
|
+
};
|
|
118
|
+
onError = () => { this.stop('unavailable'); };
|
|
119
|
+
onStdinError = () => { this.stop('write'); };
|
|
120
|
+
onClose = (code) => {
|
|
121
|
+
this.closed = true;
|
|
122
|
+
clearTimeout(this.timer);
|
|
123
|
+
clearTimeout(this.cleanupTimer);
|
|
124
|
+
this.options.signal?.removeEventListener('abort', this.onAbort);
|
|
125
|
+
if (!this.settled) {
|
|
126
|
+
const result = code === 0 && !this.failure
|
|
127
|
+
? { status: 'confirmed', via: 'native' }
|
|
128
|
+
: sendOsc52(this.text, this.options, this.failure ?? 'write');
|
|
129
|
+
this.settle(result);
|
|
130
|
+
}
|
|
131
|
+
this.child.removeListener('error', this.onError);
|
|
132
|
+
this.child.stdin?.removeListener('error', this.onStdinError);
|
|
133
|
+
this.resolveReleased();
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Start a copy without rejecting; result and writer release are separate signals. */
|
|
137
|
+
export function startClipboardTask(text, options = {}) {
|
|
138
|
+
if (options.signal?.aborted)
|
|
139
|
+
return settledTask({ status: 'failed', reason: 'cancelled' });
|
|
140
|
+
if (text.length === 0)
|
|
141
|
+
return settledTask({ status: 'failed', reason: 'empty' });
|
|
142
|
+
const remote = options.remote ?? Boolean(process.env.SSH_CONNECTION || process.env.SSH_TTY);
|
|
143
|
+
if (remote)
|
|
144
|
+
return settledTask(sendOsc52(text, options));
|
|
145
|
+
let child;
|
|
146
|
+
try {
|
|
147
|
+
child = spawnNative();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return settledTask(sendOsc52(text, options, 'unavailable'));
|
|
151
|
+
}
|
|
152
|
+
return new NativeClipboardTask(child, text, options);
|
|
153
|
+
}
|
|
154
|
+
/** Promise convenience wrapper; interactive callers must use the shared coordinator. */
|
|
155
|
+
export function copyText(text, options = {}) {
|
|
156
|
+
return startClipboardTask(text, options).result;
|
|
91
157
|
}
|
|
@@ -22,10 +22,9 @@ export declare const USER_ENTRY_MAX_ROWS = 40;
|
|
|
22
22
|
/**
|
|
23
23
|
* Ceiling on the composer's DRAFT rows at a given terminal height.
|
|
24
24
|
*
|
|
25
|
-
* Non-decreasing in `terminalRows
|
|
26
|
-
*
|
|
27
|
-
* that stage revertible on its own.
|
|
25
|
+
* Non-decreasing in `terminalRows`, with one quarter of the terminal available
|
|
26
|
+
* to the draft and an absolute six-row ceiling shared by rendering and budgets.
|
|
28
27
|
*/
|
|
29
28
|
export declare function draftMaxRows(terminalRows: number): number;
|
|
30
|
-
/**
|
|
29
|
+
/** Compatibility export for the shared hint visibility threshold. */
|
|
31
30
|
export declare const DRAFT_MAX_ROWS_HINT_TIER = 20;
|
|
@@ -33,18 +33,13 @@ const DRAFT_TIER_MIN_ROWS = 20;
|
|
|
33
33
|
/**
|
|
34
34
|
* Ceiling on the composer's DRAFT rows at a given terminal height.
|
|
35
35
|
*
|
|
36
|
-
* Non-decreasing in `terminalRows
|
|
37
|
-
*
|
|
38
|
-
* that stage revertible on its own.
|
|
36
|
+
* Non-decreasing in `terminalRows`, with one quarter of the terminal available
|
|
37
|
+
* to the draft and an absolute six-row ceiling shared by rendering and budgets.
|
|
39
38
|
*/
|
|
40
39
|
export function draftMaxRows(terminalRows) {
|
|
41
40
|
if (!Number.isFinite(terminalRows))
|
|
42
41
|
return 3;
|
|
43
|
-
|
|
44
|
-
return 10;
|
|
45
|
-
if (terminalRows >= DRAFT_TIER_MIN_ROWS)
|
|
46
|
-
return 6;
|
|
47
|
-
return 3;
|
|
42
|
+
return Math.max(1, Math.min(6, Math.floor(terminalRows / 4)));
|
|
48
43
|
}
|
|
49
|
-
/**
|
|
44
|
+
/** Compatibility export for the shared hint visibility threshold. */
|
|
50
45
|
export const DRAFT_MAX_ROWS_HINT_TIER = DRAFT_TIER_MIN_ROWS;
|
|
@@ -44,7 +44,7 @@ export interface ComposerLayoutInput {
|
|
|
44
44
|
readonly maxRows: number;
|
|
45
45
|
readonly active: boolean;
|
|
46
46
|
}
|
|
47
|
-
/**
|
|
47
|
+
/** Width of a displayed run, measured by complete graphemes. */
|
|
48
48
|
export declare function displayWidth(text: string): number;
|
|
49
49
|
/**
|
|
50
50
|
* Wrap `buffer` into rows, locate the caret, and select a window of at most
|
package/dist/ui/composer-rows.js
CHANGED
|
@@ -18,65 +18,11 @@
|
|
|
18
18
|
* spaces, so it destroys indentation and makes the character-index-to-screen-cell
|
|
19
19
|
* mapping the caret depends on impossible to state, let alone compute.
|
|
20
20
|
*/
|
|
21
|
-
import
|
|
21
|
+
import { buildEditorVisualMap, graphemes, graphemeWidth } from './editor-navigation.js';
|
|
22
22
|
import { tokenSpans } from './paste-tokens.js';
|
|
23
|
-
/**
|
|
24
|
-
* Columns a TAB advances to.
|
|
25
|
-
*
|
|
26
|
-
* TABS ARE EXPANDED TO SPACES FOR DISPLAY ONLY -- the buffer keeps the tab, so
|
|
27
|
-
* the model receives exactly what was pasted. The alternative is to emit the raw
|
|
28
|
-
* tab and let the terminal expand it, which puts the terminal's tab stop (8) and
|
|
29
|
-
* this module's column arithmetic in disagreement: the row would overflow its box
|
|
30
|
-
* and the terminal would wrap it a second time, which is the corruption the whole
|
|
31
|
-
* feature exists to remove.
|
|
32
|
-
*/
|
|
33
|
-
const TAB_WIDTH = 4;
|
|
34
|
-
/**
|
|
35
|
-
* Display width of one code point.
|
|
36
|
-
*
|
|
37
|
-
* The ASCII fast path matters: `layoutComposer` runs on every keystroke and a
|
|
38
|
-
* recalled prompt-history entry can be thousands of characters, so one
|
|
39
|
-
* `string-width` call per character would be the cost this package spent
|
|
40
|
-
* `tui-render-performance` removing.
|
|
41
|
-
*/
|
|
42
|
-
function charWidth(ch) {
|
|
43
|
-
const code = ch.codePointAt(0) ?? 0;
|
|
44
|
-
if (code >= 0x20 && code < 0x7f)
|
|
45
|
-
return 1;
|
|
46
|
-
return stringWidth(ch);
|
|
47
|
-
}
|
|
48
|
-
/** Total display width of a run, using the same table the layout used. */
|
|
23
|
+
/** Width of a displayed run, measured by complete graphemes. */
|
|
49
24
|
export function displayWidth(text) {
|
|
50
|
-
|
|
51
|
-
for (const ch of text)
|
|
52
|
-
w += charWidth(ch);
|
|
53
|
-
return w;
|
|
54
|
-
}
|
|
55
|
-
/** Normalize only the visual projection; the editor keeps its UTF-16 index. */
|
|
56
|
-
function visualCursor(buffer, cursor) {
|
|
57
|
-
let index = Math.max(0, Math.min(buffer.length, cursor));
|
|
58
|
-
const code = buffer.charCodeAt(index);
|
|
59
|
-
const previous = buffer.charCodeAt(index - 1);
|
|
60
|
-
if (code >= 0xdc00 && code <= 0xdfff && previous >= 0xd800 && previous <= 0xdbff)
|
|
61
|
-
index -= 1;
|
|
62
|
-
if (index === buffer.length || buffer[index] === '\n')
|
|
63
|
-
return index;
|
|
64
|
-
const ch = String.fromCodePoint(buffer.codePointAt(index));
|
|
65
|
-
if (ch === '\t' || charWidth(ch) > 0)
|
|
66
|
-
return index;
|
|
67
|
-
const start = buffer.lastIndexOf('\n', index - 1) + 1;
|
|
68
|
-
let nearest = -1;
|
|
69
|
-
let i = start;
|
|
70
|
-
while (i < buffer.length && buffer[i] !== '\n') {
|
|
71
|
-
const point = String.fromCodePoint(buffer.codePointAt(i));
|
|
72
|
-
if (point === '\t' || charWidth(point) > 0) {
|
|
73
|
-
if (i > index)
|
|
74
|
-
return nearest >= 0 ? nearest : i;
|
|
75
|
-
nearest = i;
|
|
76
|
-
}
|
|
77
|
-
i += point.length;
|
|
78
|
-
}
|
|
79
|
-
return nearest >= 0 ? nearest : i;
|
|
25
|
+
return graphemes(text).reduce((width, part) => width + graphemeWidth(part.text), 0);
|
|
80
26
|
}
|
|
81
27
|
/**
|
|
82
28
|
* Wrap `buffer` into rows, locate the caret, and select a window of at most
|
|
@@ -89,88 +35,29 @@ function visualCursor(buffer, cursor) {
|
|
|
89
35
|
*/
|
|
90
36
|
export function layoutComposer(input) {
|
|
91
37
|
const { buffer, active } = input;
|
|
92
|
-
const cursor = visualCursor(buffer, input.cursor);
|
|
93
|
-
const cols = Math.max(1, Math.floor(input.cols));
|
|
94
38
|
const maxRows = input.maxRows >= 1 ? Math.floor(input.maxRows) : 1;
|
|
39
|
+
const map = buildEditorVisualMap(input);
|
|
40
|
+
const cursorRowAbs = active ? map.cursor.row : -1;
|
|
41
|
+
const cursorCol = map.cursor.column;
|
|
95
42
|
const spans = tokenSpans(buffer);
|
|
96
43
|
let spanIndex = 0;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
spanIndex
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
segments = [];
|
|
112
|
-
col = 0;
|
|
113
|
-
};
|
|
114
|
-
const append = (text, width, kind) => {
|
|
115
|
-
const last = segments[segments.length - 1];
|
|
116
|
-
if (last && last.kind === kind)
|
|
117
|
-
segments[segments.length - 1] = { text: last.text + text, kind };
|
|
118
|
-
else
|
|
119
|
-
segments.push({ text, kind });
|
|
120
|
-
col += width;
|
|
121
|
-
};
|
|
122
|
-
const ensureRoom = (width) => {
|
|
123
|
-
if (col > 0 && col + width > cols)
|
|
124
|
-
breakRow();
|
|
125
|
-
};
|
|
126
|
-
const length = buffer.length;
|
|
127
|
-
let i = 0;
|
|
128
|
-
while (i <= length) {
|
|
129
|
-
const atCursor = active && i === cursor && cursorRowAbs === -1;
|
|
130
|
-
if (i === length) {
|
|
131
|
-
if (atCursor) {
|
|
132
|
-
ensureRoom(1);
|
|
133
|
-
cursorRowAbs = rows.length;
|
|
134
|
-
cursorCol = col;
|
|
135
|
-
}
|
|
136
|
-
break;
|
|
137
|
-
}
|
|
138
|
-
const ch = String.fromCodePoint(buffer.codePointAt(i));
|
|
139
|
-
if (ch === '\n') {
|
|
140
|
-
if (atCursor) {
|
|
141
|
-
ensureRoom(1);
|
|
142
|
-
cursorRowAbs = rows.length;
|
|
143
|
-
cursorCol = col;
|
|
144
|
-
}
|
|
145
|
-
breakRow();
|
|
146
|
-
i += 1;
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
const kind = kindAt(i);
|
|
150
|
-
if (ch === '\t') {
|
|
151
|
-
let advance = TAB_WIDTH - (col % TAB_WIDTH);
|
|
152
|
-
if (col > 0 && col + advance > cols) {
|
|
153
|
-
breakRow();
|
|
154
|
-
advance = TAB_WIDTH;
|
|
155
|
-
}
|
|
156
|
-
if (atCursor) {
|
|
157
|
-
cursorRowAbs = rows.length;
|
|
158
|
-
cursorCol = col;
|
|
159
|
-
}
|
|
160
|
-
append(' '.repeat(advance), advance, kind);
|
|
161
|
-
i += 1;
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
const width = charWidth(ch);
|
|
165
|
-
ensureRoom(width);
|
|
166
|
-
if (atCursor) {
|
|
167
|
-
cursorRowAbs = rows.length;
|
|
168
|
-
cursorCol = col;
|
|
44
|
+
const rows = map.rows.map((cells) => {
|
|
45
|
+
const segments = [];
|
|
46
|
+
for (const cell of cells) {
|
|
47
|
+
while (spanIndex < spans.length && spans[spanIndex].end <= cell.index)
|
|
48
|
+
spanIndex += 1;
|
|
49
|
+
const span = spans[spanIndex];
|
|
50
|
+
const kind = span && cell.index >= span.start && cell.index < span.end ? 'token' : 'text';
|
|
51
|
+
const last = segments[segments.length - 1];
|
|
52
|
+
if (last && last.kind === kind)
|
|
53
|
+
segments[segments.length - 1] = {
|
|
54
|
+
text: last.text + cell.text, kind,
|
|
55
|
+
};
|
|
56
|
+
else
|
|
57
|
+
segments.push({ text: cell.text, kind });
|
|
169
58
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
rows.push({ segments });
|
|
59
|
+
return { segments };
|
|
60
|
+
});
|
|
174
61
|
const totalRows = rows.length;
|
|
175
62
|
const cap = Math.max(1, Math.min(maxRows, totalRows));
|
|
176
63
|
let start = Math.max(0, totalRows - cap);
|
|
@@ -213,8 +100,8 @@ export function splitRowAtColumn(row, column) {
|
|
|
213
100
|
const cells = [];
|
|
214
101
|
let leading = '';
|
|
215
102
|
for (const segment of row.segments) {
|
|
216
|
-
for (const ch of segment.text) {
|
|
217
|
-
if (
|
|
103
|
+
for (const { text: ch } of graphemes(segment.text)) {
|
|
104
|
+
if (graphemeWidth(ch) === 0) {
|
|
218
105
|
const last = cells[cells.length - 1];
|
|
219
106
|
if (last)
|
|
220
107
|
cells[cells.length - 1] = { ...last, text: last.text + ch };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface EditorGrapheme {
|
|
2
|
+
readonly text: string;
|
|
3
|
+
readonly index: number;
|
|
4
|
+
readonly end: number;
|
|
5
|
+
}
|
|
6
|
+
/** Segment text without changing its UTF-16 offsets or stored whitespace. */
|
|
7
|
+
export declare function graphemes(text: string): EditorGrapheme[];
|
|
8
|
+
/** Snap an arbitrary UTF-16 offset to a complete grapheme boundary. */
|
|
9
|
+
export declare function snapGrapheme(text: string, cursor: number, right?: boolean): number;
|
|
10
|
+
/** Move one complete grapheme, leaving the two buffer edges unchanged. */
|
|
11
|
+
export declare function stepGrapheme(text: string, cursor: number, direction: 'left' | 'right'): number;
|
|
12
|
+
/** Width of one grapheme (ASCII avoids the Unicode width-table lookup). */
|
|
13
|
+
export declare function graphemeWidth(text: string): number;
|
|
14
|
+
export interface VisualCell extends EditorGrapheme {
|
|
15
|
+
readonly column: number;
|
|
16
|
+
readonly width: number;
|
|
17
|
+
}
|
|
18
|
+
export interface VisualPosition {
|
|
19
|
+
readonly index: number;
|
|
20
|
+
readonly row: number;
|
|
21
|
+
readonly column: number;
|
|
22
|
+
}
|
|
23
|
+
export interface EditorVisualMap {
|
|
24
|
+
readonly rows: VisualCell[][];
|
|
25
|
+
readonly positions: VisualPosition[];
|
|
26
|
+
readonly cursor: VisualPosition;
|
|
27
|
+
}
|
|
28
|
+
/** Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation. */
|
|
29
|
+
export declare function buildEditorVisualMap(input: {
|
|
30
|
+
buffer: string;
|
|
31
|
+
cols: number;
|
|
32
|
+
cursor: number;
|
|
33
|
+
active?: boolean;
|
|
34
|
+
}): EditorVisualMap;
|
|
35
|
+
/** Move to the closest boundary at or left of the preferred display column. */
|
|
36
|
+
export declare function moveVisualCursor(input: {
|
|
37
|
+
buffer: string;
|
|
38
|
+
cursor: number;
|
|
39
|
+
cols: number;
|
|
40
|
+
direction: 'up' | 'down';
|
|
41
|
+
preferredVisualColumn?: number;
|
|
42
|
+
}): {
|
|
43
|
+
cursor: number;
|
|
44
|
+
preferredVisualColumn: number;
|
|
45
|
+
} | null;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Shared UTF-16 grapheme boundaries and terminal-cell layout for the editor. */
|
|
2
|
+
import stringWidth from 'string-width';
|
|
3
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
4
|
+
/** Segment text without changing its UTF-16 offsets or stored whitespace. */
|
|
5
|
+
export function graphemes(text) {
|
|
6
|
+
return Array.from(segmenter.segment(text), ({ segment, index }) => ({
|
|
7
|
+
text: segment, index, end: index + segment.length,
|
|
8
|
+
}));
|
|
9
|
+
}
|
|
10
|
+
/** Snap an arbitrary UTF-16 offset to a complete grapheme boundary. */
|
|
11
|
+
export function snapGrapheme(text, cursor, right = false) {
|
|
12
|
+
const clamped = Math.max(0, Math.min(text.length, cursor));
|
|
13
|
+
for (const part of segmenter.segment(text)) {
|
|
14
|
+
const end = part.index + part.segment.length;
|
|
15
|
+
if (clamped > part.index && clamped < end)
|
|
16
|
+
return right ? end : part.index;
|
|
17
|
+
if (part.index >= clamped)
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
return clamped;
|
|
21
|
+
}
|
|
22
|
+
/** Move one complete grapheme, leaving the two buffer edges unchanged. */
|
|
23
|
+
export function stepGrapheme(text, cursor, direction) {
|
|
24
|
+
if (direction === 'left')
|
|
25
|
+
return snapGrapheme(text, Math.max(0, cursor - 1));
|
|
26
|
+
return snapGrapheme(text, Math.min(text.length, cursor + 1), true);
|
|
27
|
+
}
|
|
28
|
+
/** Width of one grapheme (ASCII avoids the Unicode width-table lookup). */
|
|
29
|
+
export function graphemeWidth(text) {
|
|
30
|
+
if (text.length === 1 && text >= ' ' && text <= '~')
|
|
31
|
+
return 1;
|
|
32
|
+
return stringWidth(text);
|
|
33
|
+
}
|
|
34
|
+
/** Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation. */
|
|
35
|
+
export function buildEditorVisualMap(input) {
|
|
36
|
+
const cols = Math.max(1, Math.floor(input.cols));
|
|
37
|
+
const cursor = snapGrapheme(input.buffer, input.cursor);
|
|
38
|
+
const rows = [[]];
|
|
39
|
+
const positions = [];
|
|
40
|
+
let column = 0;
|
|
41
|
+
const breakRow = () => { rows.push([]); column = 0; };
|
|
42
|
+
const locate = (index) => {
|
|
43
|
+
positions.push({ index, row: rows.length - 1, column });
|
|
44
|
+
};
|
|
45
|
+
for (const part of graphemes(input.buffer)) {
|
|
46
|
+
if (part.text === '\n') {
|
|
47
|
+
if (input.active !== false && part.index === cursor && column >= cols)
|
|
48
|
+
breakRow();
|
|
49
|
+
locate(part.index);
|
|
50
|
+
breakRow();
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let width = part.text === '\t' ? Math.min(cols, 4 - column % 4)
|
|
54
|
+
: graphemeWidth(part.text);
|
|
55
|
+
if (column > 0 && column + width > cols) {
|
|
56
|
+
breakRow();
|
|
57
|
+
if (part.text === '\t')
|
|
58
|
+
width = Math.min(cols, 4);
|
|
59
|
+
}
|
|
60
|
+
locate(part.index);
|
|
61
|
+
rows[rows.length - 1].push({ ...part,
|
|
62
|
+
text: part.text === '\t' ? ' '.repeat(width) : part.text, column, width });
|
|
63
|
+
column += width;
|
|
64
|
+
}
|
|
65
|
+
if (input.active !== false && cursor === input.buffer.length && column >= cols)
|
|
66
|
+
breakRow();
|
|
67
|
+
locate(input.buffer.length);
|
|
68
|
+
const caret = positions.find((position) => position.index === cursor);
|
|
69
|
+
return { rows, positions, cursor: caret };
|
|
70
|
+
}
|
|
71
|
+
/** Move to the closest boundary at or left of the preferred display column. */
|
|
72
|
+
export function moveVisualCursor(input) {
|
|
73
|
+
const map = buildEditorVisualMap(input);
|
|
74
|
+
const row = map.cursor.row + (input.direction === 'up' ? -1 : 1);
|
|
75
|
+
if (row < 0 || row >= map.rows.length)
|
|
76
|
+
return null;
|
|
77
|
+
const preferredVisualColumn = input.preferredVisualColumn ?? map.cursor.column;
|
|
78
|
+
const candidates = map.positions.filter((position) => position.row === row);
|
|
79
|
+
let target = candidates[0];
|
|
80
|
+
for (const position of candidates) {
|
|
81
|
+
if (position.column <= preferredVisualColumn)
|
|
82
|
+
target = position;
|
|
83
|
+
}
|
|
84
|
+
return target ? { cursor: target.index, preferredVisualColumn } : null;
|
|
85
|
+
}
|
|
@@ -26,6 +26,7 @@ import { type PasteRecord } from './paste-tokens.js';
|
|
|
26
26
|
export interface EditorState {
|
|
27
27
|
buffer: string;
|
|
28
28
|
cursor: number;
|
|
29
|
+
preferredVisualColumn?: number;
|
|
29
30
|
historyIndex: number | null;
|
|
30
31
|
dismissed: boolean;
|
|
31
32
|
sel: number;
|
|
@@ -70,9 +71,12 @@ export type EditorAction = {
|
|
|
70
71
|
cursor: number;
|
|
71
72
|
} | {
|
|
72
73
|
type: 'backspace';
|
|
74
|
+
} | {
|
|
75
|
+
type: 'delete';
|
|
73
76
|
} | {
|
|
74
77
|
type: 'moveCursor';
|
|
75
78
|
cursor: number;
|
|
79
|
+
preferredVisualColumn?: number;
|
|
76
80
|
} | {
|
|
77
81
|
type: 'recall';
|
|
78
82
|
buffer: string;
|