@visual-z/xeno 0.1.0
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 +498 -0
- package/docs/install.md +130 -0
- package/package.json +43 -0
- package/src/apps/approve.ts +49 -0
- package/src/apps/cli.ts +37 -0
- package/src/apps/commands.ts +226 -0
- package/src/apps/completions.ts +28 -0
- package/src/apps/input.ts +297 -0
- package/src/apps/render.ts +207 -0
- package/src/apps/shell.ts +2353 -0
- package/src/apps/startup.ts +1302 -0
- package/src/core/benchmark.ts +195 -0
- package/src/core/boundary.ts +174 -0
- package/src/core/churn.ts +59 -0
- package/src/core/completion-review.ts +66 -0
- package/src/core/context-policy.ts +60 -0
- package/src/core/context-window.ts +54 -0
- package/src/core/continuation.ts +114 -0
- package/src/core/cycle.ts +17 -0
- package/src/core/data-query.ts +201 -0
- package/src/core/deadline.ts +39 -0
- package/src/core/derive.ts +489 -0
- package/src/core/edit-plan.ts +200 -0
- package/src/core/environment.ts +29 -0
- package/src/core/event-validation.ts +99 -0
- package/src/core/events.ts +486 -0
- package/src/core/evidence-links.ts +71 -0
- package/src/core/evidence.ts +89 -0
- package/src/core/execution.ts +12 -0
- package/src/core/exhaustive.ts +18 -0
- package/src/core/experiment.ts +40 -0
- package/src/core/export.ts +169 -0
- package/src/core/file-references.ts +35 -0
- package/src/core/frontmatter.ts +213 -0
- package/src/core/handoff.ts +397 -0
- package/src/core/harness-comparison.ts +90 -0
- package/src/core/history-query.ts +103 -0
- package/src/core/history.ts +100 -0
- package/src/core/json-event.ts +135 -0
- package/src/core/json-pointer.ts +46 -0
- package/src/core/keymap.ts +241 -0
- package/src/core/observation-view.ts +87 -0
- package/src/core/outcome.ts +45 -0
- package/src/core/overflow.ts +59 -0
- package/src/core/permissions.ts +182 -0
- package/src/core/policy-evaluation.ts +55 -0
- package/src/core/progress.ts +101 -0
- package/src/core/project.ts +372 -0
- package/src/core/prompts.ts +83 -0
- package/src/core/queue.ts +60 -0
- package/src/core/read-bounds.ts +15 -0
- package/src/core/recall-analysis.ts +90 -0
- package/src/core/recall-trial.ts +153 -0
- package/src/core/response-recovery.ts +46 -0
- package/src/core/scheduling.ts +8 -0
- package/src/core/settings.ts +174 -0
- package/src/core/skills.ts +121 -0
- package/src/core/stall.ts +48 -0
- package/src/core/text-normalize.ts +81 -0
- package/src/core/theme.ts +179 -0
- package/src/core/tokens.ts +149 -0
- package/src/core/tool-contract.ts +102 -0
- package/src/core/tool-input.ts +38 -0
- package/src/core/transcript.ts +60 -0
- package/src/core/window-trial.ts +65 -0
- package/src/core/work-state.ts +103 -0
- package/src/core/yaml.ts +282 -0
- package/src/index.ts +10 -0
- package/src/runtime/auth.ts +255 -0
- package/src/runtime/benchmark.ts +187 -0
- package/src/runtime/catalog.ts +365 -0
- package/src/runtime/evidence-notes.ts +33 -0
- package/src/runtime/exec-sessions.ts +214 -0
- package/src/runtime/execution-control.ts +39 -0
- package/src/runtime/harness-comparison.ts +356 -0
- package/src/runtime/history-tools.ts +108 -0
- package/src/runtime/loop.ts +1983 -0
- package/src/runtime/model.ts +614 -0
- package/src/runtime/models-fetch.ts +155 -0
- package/src/runtime/oauth-device.ts +86 -0
- package/src/runtime/oauth.ts +759 -0
- package/src/runtime/openai-responses.ts +286 -0
- package/src/runtime/openai.ts +675 -0
- package/src/runtime/provider.ts +498 -0
- package/src/runtime/recall-trial.ts +226 -0
- package/src/runtime/resume.ts +66 -0
- package/src/runtime/retry.ts +297 -0
- package/src/runtime/tools.ts +1193 -0
- package/src/runtime/usage.ts +225 -0
- package/src/runtime/window-trial.ts +140 -0
- package/src/runtime/working-notes.ts +30 -0
- package/src/store/atomic.ts +31 -0
- package/src/store/auth-store.ts +50 -0
- package/src/store/blobs.ts +142 -0
- package/src/store/command.ts +75 -0
- package/src/store/config.ts +69 -0
- package/src/store/context-files.ts +356 -0
- package/src/store/file-index.ts +81 -0
- package/src/store/git-branch.ts +35 -0
- package/src/store/history-search.ts +19 -0
- package/src/store/log.ts +642 -0
- package/src/store/models.ts +41 -0
- package/src/store/observations.ts +181 -0
- package/src/store/query-source.ts +44 -0
- package/src/store/recovery.ts +39 -0
- package/src/store/replay.ts +63 -0
- package/src/store/resources.ts +361 -0
- package/src/store/restore.ts +302 -0
- package/src/store/search.ts +644 -0
- package/src/store/serial.ts +13 -0
- package/src/store/sessions.ts +463 -0
- package/src/store/trust.ts +69 -0
- package/src/store/workspace.ts +256 -0
- package/src/store/worktree.ts +233 -0
- package/src/tui/ansi.ts +67 -0
- package/src/tui/autocomplete.ts +29 -0
- package/src/tui/complete.ts +87 -0
- package/src/tui/diff.ts +160 -0
- package/src/tui/editor.ts +893 -0
- package/src/tui/exhaustive.ts +14 -0
- package/src/tui/fuzzy.ts +75 -0
- package/src/tui/host.ts +223 -0
- package/src/tui/input.ts +69 -0
- package/src/tui/keys.ts +378 -0
- package/src/tui/markdown.ts +17 -0
- package/src/tui/render.ts +153 -0
- package/src/tui/select.ts +240 -0
- package/src/tui/settings.ts +40 -0
- package/src/tui/statusline.ts +162 -0
- package/src/tui/style.ts +205 -0
- package/src/tui/terminal.ts +30 -0
- package/src/tui/text.ts +52 -0
- package/src/tui/toolview.ts +386 -0
- package/src/tui/transcript.ts +71 -0
- package/src/tui/tree.ts +405 -0
- package/src/tui/width.ts +567 -0
- package/src/vendor/pi-tui/LICENSE.upstream +21 -0
- package/src/vendor/pi-tui/VENDOR.md +23 -0
- package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
- package/src/vendor/pi-tui/autocomplete.ts +835 -0
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
- package/src/vendor/pi-tui/components/box.ts +167 -0
- package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
- package/src/vendor/pi-tui/components/editor.ts +2470 -0
- package/src/vendor/pi-tui/components/h-stack.ts +44 -0
- package/src/vendor/pi-tui/components/image.ts +127 -0
- package/src/vendor/pi-tui/components/input.ts +494 -0
- package/src/vendor/pi-tui/components/loader.ts +101 -0
- package/src/vendor/pi-tui/components/markdown.ts +1015 -0
- package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
- package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
- package/src/vendor/pi-tui/components/select-list.ts +273 -0
- package/src/vendor/pi-tui/components/settings-list.ts +328 -0
- package/src/vendor/pi-tui/components/spacer.ts +28 -0
- package/src/vendor/pi-tui/components/stack.ts +154 -0
- package/src/vendor/pi-tui/components/text.ts +107 -0
- package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
- package/src/vendor/pi-tui/components/v-stack.ts +33 -0
- package/src/vendor/pi-tui/editor-component.ts +74 -0
- package/src/vendor/pi-tui/fuzzy.ts +138 -0
- package/src/vendor/pi-tui/index.ts +156 -0
- package/src/vendor/pi-tui/keybindings.ts +320 -0
- package/src/vendor/pi-tui/keys.ts +1401 -0
- package/src/vendor/pi-tui/kill-ring.ts +46 -0
- package/src/vendor/pi-tui/latex.ts +1506 -0
- package/src/vendor/pi-tui/layout-node.ts +51 -0
- package/src/vendor/pi-tui/layout.ts +449 -0
- package/src/vendor/pi-tui/native-modifiers.ts +13 -0
- package/src/vendor/pi-tui/native-module-path.ts +31 -0
- package/src/vendor/pi-tui/native-platform.ts +63 -0
- package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
- package/src/vendor/pi-tui/terminal-colors.ts +73 -0
- package/src/vendor/pi-tui/terminal-image.ts +696 -0
- package/src/vendor/pi-tui/terminal.ts +547 -0
- package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
- package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
- package/src/vendor/pi-tui/tui.ts +1456 -0
- package/src/vendor/pi-tui/undo-stack.ts +28 -0
- package/src/vendor/pi-tui/utils.ts +1345 -0
- package/src/vendor/pi-tui/word-navigation.ts +117 -0
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legacy pure editor contracts plus the live Pi-owned Editor adapter below.
|
|
3
|
+
* The pure reducer is retained for compatibility tests, not live dispatch.
|
|
4
|
+
* Its state is a pure applyKey(state, key) -- no terminal,
|
|
5
|
+
* no clock -- so "left twice then backspace across a CJK boundary" is a
|
|
6
|
+
* unit test rather than a hope.
|
|
7
|
+
*
|
|
8
|
+
* Cursor is a UTF-16 offset into `text` so slice() can address it, but
|
|
9
|
+
* every motion step is one display cluster (a width>0 base plus trailing
|
|
10
|
+
* combining marks, or a newline). Counting `.length` as columns would
|
|
11
|
+
* stop the cursor on the right half of 中, and the terminal would draw it
|
|
12
|
+
* there.
|
|
13
|
+
*
|
|
14
|
+
* applyKey does not take a width. Soft wrap is a render of the same
|
|
15
|
+
* buffer; up/down walk hard lines (Shift+Enter). Visual-row motion needs
|
|
16
|
+
* the terminal width, which the next card can store on the caller.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Editor as PiEditor, type TUI } from "../vendor/pi-tui/index.ts";
|
|
20
|
+
import { getKeybindings, setKeybindings, type KeybindingsManager } from "../vendor/pi-tui/keybindings.ts";
|
|
21
|
+
import type { AutocompleteProvider } from "../vendor/pi-tui/autocomplete.ts";
|
|
22
|
+
import { encodeKey } from "./input.ts";
|
|
23
|
+
import { IDENTITY, PLAIN_ATTRS, paint, type Style } from "./style.ts";
|
|
24
|
+
import { completePath, pathTokenAtCursor, type PathToken } from "./complete.ts";
|
|
25
|
+
import { assertNever } from "./exhaustive.ts";
|
|
26
|
+
import { identityEditorTheme, stubTui } from "./host.ts";
|
|
27
|
+
import type { Key, KeyMods } from "./keys.ts";
|
|
28
|
+
import {
|
|
29
|
+
applySelectKey,
|
|
30
|
+
createSelect,
|
|
31
|
+
renderSelect,
|
|
32
|
+
type SelectState,
|
|
33
|
+
} from "./select.ts";
|
|
34
|
+
import type { Component } from "./text.ts";
|
|
35
|
+
import { displayWidth } from "./width.ts";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cap on prompt history. Assumed, not measured: matches pi-tui's cap.
|
|
39
|
+
* Too small and a long session cannot recall an earlier prompt; the
|
|
40
|
+
* caller can pass a different limit.
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_HISTORY_LIMIT = 100;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Cap on undo snapshots. Assumed, not measured: matches the history cap.
|
|
46
|
+
* Too small and a long edit cannot be walked back as groups.
|
|
47
|
+
*/
|
|
48
|
+
const UNDO_LIMIT = 100;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Cap on kill-ring entries. Assumed, not measured: Emacs default is 30;
|
|
52
|
+
* 16 keeps a JSON-roundtrip of EditorState cheap. Too small and an
|
|
53
|
+
* earlier kill cannot be yanked.
|
|
54
|
+
*/
|
|
55
|
+
const KILL_RING_LIMIT = 16;
|
|
56
|
+
|
|
57
|
+
export type EditGroup = "insert" | "delete" | "move" | "yank" | null;
|
|
58
|
+
|
|
59
|
+
export interface EditorSnapshot {
|
|
60
|
+
readonly text: string;
|
|
61
|
+
readonly cursor: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CreateEditorOptions {
|
|
65
|
+
readonly text?: string;
|
|
66
|
+
/** Oldest-to-newest submitted prompts. Stored newest-first internally. */
|
|
67
|
+
readonly history?: readonly string[];
|
|
68
|
+
readonly historyLimit?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Display-only. applyKey still stores plaintext so submit can return the
|
|
71
|
+
* real value; renderEditor paints a mask so a pasted secret never reaches
|
|
72
|
+
* the live region (and therefore never scrollback).
|
|
73
|
+
*/
|
|
74
|
+
readonly secret?: boolean;
|
|
75
|
+
/** Injected directory listing. applyKey never reads the disk. */
|
|
76
|
+
readonly pathEntries?: readonly string[];
|
|
77
|
+
/**
|
|
78
|
+
* Vendor Editor reads tui.terminal.rows for the 30%-of-height cap.
|
|
79
|
+
* Unit tests omit this and get a silent stub so render() does not write.
|
|
80
|
+
*/
|
|
81
|
+
readonly tui?: TUI;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface EditorState {
|
|
85
|
+
readonly text: string;
|
|
86
|
+
/**
|
|
87
|
+
* UTF-16 offset into `text`, always on a cluster boundary. Not a column
|
|
88
|
+
* count -- 中 is one unit here and two columns in displayWidth.
|
|
89
|
+
*/
|
|
90
|
+
readonly cursor: number;
|
|
91
|
+
/** Sticky display column for vertical motion, like a terminal's curswant. */
|
|
92
|
+
readonly preferredColumn: number;
|
|
93
|
+
readonly history: readonly string[];
|
|
94
|
+
readonly historyLimit: number;
|
|
95
|
+
/** -1 = live buffer; 0 = newest history entry. */
|
|
96
|
+
readonly historyIndex: number;
|
|
97
|
+
readonly draft: string | null;
|
|
98
|
+
readonly submitted: string | null;
|
|
99
|
+
readonly secret: boolean;
|
|
100
|
+
readonly pathEntries: readonly string[];
|
|
101
|
+
readonly completion: SelectState | null;
|
|
102
|
+
readonly undoStack: readonly EditorSnapshot[];
|
|
103
|
+
readonly redoStack: readonly EditorSnapshot[];
|
|
104
|
+
readonly killRing: readonly string[];
|
|
105
|
+
readonly lastGroup: EditGroup;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface CursorView {
|
|
109
|
+
readonly row: number;
|
|
110
|
+
readonly col: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createEditor(opts: CreateEditorOptions = {}): EditorState {
|
|
114
|
+
const text = opts.text ?? "";
|
|
115
|
+
const cursor = text.length;
|
|
116
|
+
return {
|
|
117
|
+
text,
|
|
118
|
+
cursor,
|
|
119
|
+
preferredColumn: columnOf(text, cursor),
|
|
120
|
+
history: [...(opts.history ?? [])].reverse(),
|
|
121
|
+
historyLimit: opts.historyLimit ?? DEFAULT_HISTORY_LIMIT,
|
|
122
|
+
historyIndex: -1,
|
|
123
|
+
draft: null,
|
|
124
|
+
submitted: null,
|
|
125
|
+
secret: opts.secret ?? false,
|
|
126
|
+
pathEntries: opts.pathEntries ?? [],
|
|
127
|
+
completion: null,
|
|
128
|
+
undoStack: [],
|
|
129
|
+
redoStack: [],
|
|
130
|
+
killRing: [],
|
|
131
|
+
lastGroup: null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function cursorDisplayColumn(state: EditorState): number {
|
|
136
|
+
const { lines, line, offset } = locate(state.text, state.cursor);
|
|
137
|
+
return columnAt(lines[line]!, offset);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Map the UTF-16 cursor onto wrapped rows. Width is a render concern --
|
|
142
|
+
* applyKey never sees it -- so this is the thing that has to stay in
|
|
143
|
+
* lockstep with renderEditor or a wrapped CJK line puts the hardware
|
|
144
|
+
* cursor on the wrong glyph.
|
|
145
|
+
*/
|
|
146
|
+
export function cursorView(state: EditorState, width: number): CursorView {
|
|
147
|
+
const { lines, line, offset } = locate(state.text, state.cursor);
|
|
148
|
+
let row = 0;
|
|
149
|
+
for (let i = 0; i < line; i++) {
|
|
150
|
+
row += wrapLogicalLine(lines[i]!, width).length;
|
|
151
|
+
}
|
|
152
|
+
const vis = wrapLogicalLine(lines[line]!, width);
|
|
153
|
+
for (let i = 0; i < vis.length; i++) {
|
|
154
|
+
const chunk = vis[i]!;
|
|
155
|
+
const last = i === vis.length - 1;
|
|
156
|
+
if (offset < chunk.end || last) {
|
|
157
|
+
return { row: row + i, col: columnAt(chunk.text, offset - chunk.start) };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const last = vis[vis.length - 1]!;
|
|
161
|
+
return { row: row + vis.length - 1, col: columnAt(last.text, last.text.length) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Dispatch a namespaced editor action. Key lookup lives above tui (core
|
|
166
|
+
* keymap); this only applies the motion so a rebound chord reaches the
|
|
167
|
+
* same function the default binding did.
|
|
168
|
+
*/
|
|
169
|
+
export function applyEditorAction(state: EditorState, action: string): EditorState {
|
|
170
|
+
switch (action) {
|
|
171
|
+
case "editor.cursorLeft":
|
|
172
|
+
return moveHorizontal(state, -1);
|
|
173
|
+
case "editor.cursorRight":
|
|
174
|
+
return moveHorizontal(state, 1);
|
|
175
|
+
case "editor.cursorWordLeft":
|
|
176
|
+
return moveWord(state, -1);
|
|
177
|
+
case "editor.cursorWordRight":
|
|
178
|
+
return moveWord(state, 1);
|
|
179
|
+
case "editor.cursorLineStart":
|
|
180
|
+
return moveLineStart(state);
|
|
181
|
+
case "editor.cursorLineEnd":
|
|
182
|
+
return moveLineEnd(state);
|
|
183
|
+
case "editor.cursorUp":
|
|
184
|
+
return moveVertical(state, -1);
|
|
185
|
+
case "editor.cursorDown":
|
|
186
|
+
return moveVertical(state, 1);
|
|
187
|
+
case "editor.deleteCharBackward":
|
|
188
|
+
return deleteBackward(state);
|
|
189
|
+
case "editor.deleteCharForward":
|
|
190
|
+
return deleteForward(state);
|
|
191
|
+
case "editor.deleteWordBackward":
|
|
192
|
+
return deleteWordBackward(state);
|
|
193
|
+
case "editor.deleteWordForward":
|
|
194
|
+
return deleteWordForward(state);
|
|
195
|
+
case "editor.deleteToLineStart":
|
|
196
|
+
return deleteToLineStart(state);
|
|
197
|
+
case "editor.deleteToLineEnd":
|
|
198
|
+
return deleteToLineEnd(state);
|
|
199
|
+
case "editor.submit":
|
|
200
|
+
return submit(state);
|
|
201
|
+
case "editor.newLine":
|
|
202
|
+
return insert(state, "\n");
|
|
203
|
+
case "editor.undo":
|
|
204
|
+
return undo(state);
|
|
205
|
+
case "editor.redo":
|
|
206
|
+
return redo(state);
|
|
207
|
+
case "editor.yank":
|
|
208
|
+
return yank(state);
|
|
209
|
+
case "editor.complete":
|
|
210
|
+
return applyTab(state);
|
|
211
|
+
default:
|
|
212
|
+
return state;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function applyKey(state: EditorState, key: Key): EditorState {
|
|
217
|
+
if (state.completion !== null) {
|
|
218
|
+
const handled = applyCompletionKey(state, key);
|
|
219
|
+
if (handled !== null) return handled;
|
|
220
|
+
state = { ...state, completion: null };
|
|
221
|
+
}
|
|
222
|
+
switch (key.type) {
|
|
223
|
+
case "char":
|
|
224
|
+
return insert(state, key.grapheme);
|
|
225
|
+
case "enter":
|
|
226
|
+
return key.shift ? insert(state, "\n") : submit(state);
|
|
227
|
+
case "backspace":
|
|
228
|
+
return deleteBackward(state);
|
|
229
|
+
case "delete":
|
|
230
|
+
return deleteForward(state);
|
|
231
|
+
case "left":
|
|
232
|
+
return isWordMotion(key.mods) ? moveWord(state, -1) : moveHorizontal(state, -1);
|
|
233
|
+
case "right":
|
|
234
|
+
return isWordMotion(key.mods) ? moveWord(state, 1) : moveHorizontal(state, 1);
|
|
235
|
+
case "up":
|
|
236
|
+
return moveVertical(state, -1);
|
|
237
|
+
case "down":
|
|
238
|
+
return moveVertical(state, 1);
|
|
239
|
+
case "home":
|
|
240
|
+
return moveLineStart(state);
|
|
241
|
+
case "end":
|
|
242
|
+
return moveLineEnd(state);
|
|
243
|
+
case "ctrl":
|
|
244
|
+
return applyCtrl(state, key.key, key.shift === true);
|
|
245
|
+
case "alt":
|
|
246
|
+
return applyAlt(state, key.key);
|
|
247
|
+
case "tab":
|
|
248
|
+
return key.shift === true ? clearSubmitted(state) : applyTab(state);
|
|
249
|
+
case "escape":
|
|
250
|
+
case "pageup":
|
|
251
|
+
case "pagedown":
|
|
252
|
+
return clearSubmitted(state);
|
|
253
|
+
default:
|
|
254
|
+
return assertNever(key, "applyKey");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function applyCtrl(state: EditorState, key: string, shift: boolean): EditorState {
|
|
259
|
+
switch (key) {
|
|
260
|
+
case "a":
|
|
261
|
+
return moveLineStart(state);
|
|
262
|
+
case "e":
|
|
263
|
+
return moveLineEnd(state);
|
|
264
|
+
case "u":
|
|
265
|
+
return deleteToLineStart(state);
|
|
266
|
+
case "k":
|
|
267
|
+
return deleteToLineEnd(state);
|
|
268
|
+
case "w":
|
|
269
|
+
return deleteWordBackward(state);
|
|
270
|
+
case "z":
|
|
271
|
+
return shift ? redo(state) : undo(state);
|
|
272
|
+
case "_":
|
|
273
|
+
return undo(state);
|
|
274
|
+
case "y":
|
|
275
|
+
return yank(state);
|
|
276
|
+
default:
|
|
277
|
+
return clearSubmitted(state);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Word left/right for the Emacs letters. Real terminals send
|
|
283
|
+
* CSI 1;3D / CSI 1;5C (handled on `left`/`right` via mods); Alt+b /
|
|
284
|
+
* Alt+f still arrive as `{ type: "alt", key: "b"|"f" }`. The
|
|
285
|
+
* synthetic `{ type: "alt", key: "left"|"right" }` names stay bound
|
|
286
|
+
* because existing tests construct them.
|
|
287
|
+
*/
|
|
288
|
+
function applyAlt(state: EditorState, key: string): EditorState {
|
|
289
|
+
const k = key.length === 1 ? key.toLowerCase() : key;
|
|
290
|
+
if (k === "left" || k === "b") return moveWord(state, -1);
|
|
291
|
+
if (k === "right" || k === "f") return moveWord(state, 1);
|
|
292
|
+
if (k === "d") return deleteWordForward(state);
|
|
293
|
+
if (k === "backspace" || k === "\x7f") return deleteWordBackward(state);
|
|
294
|
+
return clearSubmitted(state);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isWordMotion(mods: KeyMods | undefined): boolean {
|
|
298
|
+
return mods?.alt === true || mods?.ctrl === true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function clearSubmitted(state: EditorState): EditorState {
|
|
302
|
+
return state.submitted === null ? state : { ...state, submitted: null };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function snapshotOf(state: EditorState): EditorSnapshot {
|
|
306
|
+
return { text: state.text, cursor: state.cursor };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function beginGroup(state: EditorState, group: Exclude<EditGroup, "move" | null>): EditorState {
|
|
310
|
+
const coalesce = (group === "insert" || group === "delete") && state.lastGroup === group;
|
|
311
|
+
if (coalesce) return state;
|
|
312
|
+
return {
|
|
313
|
+
...state,
|
|
314
|
+
undoStack: [...state.undoStack, snapshotOf(state)].slice(-UNDO_LIMIT),
|
|
315
|
+
redoStack: [],
|
|
316
|
+
lastGroup: group,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function commit(
|
|
321
|
+
state: EditorState,
|
|
322
|
+
text: string,
|
|
323
|
+
cursor: number,
|
|
324
|
+
group: Exclude<EditGroup, "move" | null>,
|
|
325
|
+
): EditorState {
|
|
326
|
+
const base = beginGroup(state, group);
|
|
327
|
+
const clamped = Math.max(0, Math.min(cursor, text.length));
|
|
328
|
+
return {
|
|
329
|
+
...base,
|
|
330
|
+
text,
|
|
331
|
+
cursor: clamped,
|
|
332
|
+
preferredColumn: columnOf(text, clamped),
|
|
333
|
+
submitted: null,
|
|
334
|
+
completion: null,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function kill(state: EditorState, text: string, cursor: number, killed: string): EditorState {
|
|
339
|
+
if (killed.length === 0) return clearSubmitted(state);
|
|
340
|
+
const next = commit(state, text, cursor, "delete");
|
|
341
|
+
return { ...next, killRing: [...next.killRing, killed].slice(-KILL_RING_LIMIT) };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function moveTo(state: EditorState, cursor: number): EditorState {
|
|
345
|
+
const clamped = Math.max(0, Math.min(cursor, state.text.length));
|
|
346
|
+
if (clamped === state.cursor && state.submitted === null && state.lastGroup === "move") {
|
|
347
|
+
return state;
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
...clearSubmitted(state),
|
|
351
|
+
cursor: clamped,
|
|
352
|
+
preferredColumn: columnOf(state.text, clamped),
|
|
353
|
+
lastGroup: "move",
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function undo(state: EditorState): EditorState {
|
|
358
|
+
const prev = state.undoStack[state.undoStack.length - 1];
|
|
359
|
+
if (prev === undefined) return clearSubmitted(state);
|
|
360
|
+
return {
|
|
361
|
+
...clearSubmitted(state),
|
|
362
|
+
text: prev.text,
|
|
363
|
+
cursor: prev.cursor,
|
|
364
|
+
preferredColumn: columnOf(prev.text, prev.cursor),
|
|
365
|
+
undoStack: state.undoStack.slice(0, -1),
|
|
366
|
+
redoStack: [...state.redoStack, snapshotOf(state)],
|
|
367
|
+
lastGroup: null,
|
|
368
|
+
completion: null,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function redo(state: EditorState): EditorState {
|
|
373
|
+
const next = state.redoStack[state.redoStack.length - 1];
|
|
374
|
+
if (next === undefined) return clearSubmitted(state);
|
|
375
|
+
return {
|
|
376
|
+
...clearSubmitted(state),
|
|
377
|
+
text: next.text,
|
|
378
|
+
cursor: next.cursor,
|
|
379
|
+
preferredColumn: columnOf(next.text, next.cursor),
|
|
380
|
+
undoStack: [...state.undoStack, snapshotOf(state)].slice(-UNDO_LIMIT),
|
|
381
|
+
redoStack: state.redoStack.slice(0, -1),
|
|
382
|
+
lastGroup: null,
|
|
383
|
+
completion: null,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function yank(state: EditorState): EditorState {
|
|
388
|
+
const piece = state.killRing[state.killRing.length - 1];
|
|
389
|
+
if (piece === undefined) return clearSubmitted(state);
|
|
390
|
+
const text = state.text.slice(0, state.cursor) + piece + state.text.slice(state.cursor);
|
|
391
|
+
return commit(state, text, state.cursor + piece.length, "yank");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function applyExternalText(state: EditorState, text: string): EditorState {
|
|
395
|
+
return commit(state, text, text.length, "yank");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function applyTab(state: EditorState): EditorState {
|
|
399
|
+
const token = pathTokenAtCursor(state.text, state.cursor);
|
|
400
|
+
if (token === null) return clearSubmitted(state);
|
|
401
|
+
const result = completePath(token.prefix, state.pathEntries);
|
|
402
|
+
if (result.candidates.length === 0) return clearSubmitted(state);
|
|
403
|
+
if (result.candidates.length === 1) {
|
|
404
|
+
return replaceToken(state, token, result.completed);
|
|
405
|
+
}
|
|
406
|
+
let next = state;
|
|
407
|
+
if (result.completed.length > token.prefix.length) {
|
|
408
|
+
next = replaceToken(state, token, result.completed);
|
|
409
|
+
}
|
|
410
|
+
const items = result.candidates.map((value) => ({ value, label: value }));
|
|
411
|
+
return { ...next, completion: createSelect(items) };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function replaceToken(state: EditorState, token: PathToken, replacement: string): EditorState {
|
|
415
|
+
const text = state.text.slice(0, token.start) + replacement + state.text.slice(token.end);
|
|
416
|
+
return commit(state, text, token.start + replacement.length, "yank");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function applyCompletionKey(state: EditorState, key: Key): EditorState | null {
|
|
420
|
+
const list = state.completion;
|
|
421
|
+
if (list === null) return null;
|
|
422
|
+
if (key.type === "up" || key.type === "down") {
|
|
423
|
+
return { ...state, completion: applySelectKey(list, key) };
|
|
424
|
+
}
|
|
425
|
+
if (key.type === "enter" || (key.type === "tab" && key.shift !== true)) {
|
|
426
|
+
return confirmCompletion(state);
|
|
427
|
+
}
|
|
428
|
+
if (key.type === "escape") {
|
|
429
|
+
return { ...state, completion: null };
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function confirmCompletion(state: EditorState): EditorState {
|
|
435
|
+
const list = state.completion;
|
|
436
|
+
if (list === null) return state;
|
|
437
|
+
const item = list.items[list.selected];
|
|
438
|
+
const closed = { ...state, completion: null };
|
|
439
|
+
if (item === undefined) return closed;
|
|
440
|
+
const token = pathTokenAtCursor(closed.text, closed.cursor);
|
|
441
|
+
if (token === null) return closed;
|
|
442
|
+
return replaceToken(closed, token, item.value);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function insert(state: EditorState, grapheme: string): EditorState {
|
|
446
|
+
const text = state.text.slice(0, state.cursor) + grapheme + state.text.slice(state.cursor);
|
|
447
|
+
// grapheme.length is UTF-16 units of the inserted cluster, not columns.
|
|
448
|
+
return commit(state, text, state.cursor + grapheme.length, "insert");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function submit(state: EditorState): EditorState {
|
|
452
|
+
if (state.text.trim() === "") return { ...state, submitted: null };
|
|
453
|
+
const entry = state.text;
|
|
454
|
+
// A secret must not land in prompt history: recalling it later would
|
|
455
|
+
// paint the plaintext once secret mode is off.
|
|
456
|
+
const history = state.secret
|
|
457
|
+
? state.history
|
|
458
|
+
: [entry, ...state.history.filter((h) => h !== entry)].slice(0, state.historyLimit);
|
|
459
|
+
return {
|
|
460
|
+
text: "",
|
|
461
|
+
cursor: 0,
|
|
462
|
+
preferredColumn: 0,
|
|
463
|
+
history,
|
|
464
|
+
historyLimit: state.historyLimit,
|
|
465
|
+
historyIndex: -1,
|
|
466
|
+
draft: null,
|
|
467
|
+
submitted: entry,
|
|
468
|
+
secret: state.secret,
|
|
469
|
+
pathEntries: state.pathEntries,
|
|
470
|
+
completion: null,
|
|
471
|
+
undoStack: [],
|
|
472
|
+
redoStack: [],
|
|
473
|
+
killRing: state.killRing,
|
|
474
|
+
lastGroup: null,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function deleteBackward(state: EditorState): EditorState {
|
|
479
|
+
if (state.cursor <= 0) return clearSubmitted(state);
|
|
480
|
+
const from = prevClusterStart(state.text, state.cursor);
|
|
481
|
+
const text = state.text.slice(0, from) + state.text.slice(state.cursor);
|
|
482
|
+
return commit(state, text, from, "delete");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function deleteForward(state: EditorState): EditorState {
|
|
486
|
+
if (state.cursor >= state.text.length) return clearSubmitted(state);
|
|
487
|
+
const to = nextClusterEnd(state.text, state.cursor);
|
|
488
|
+
const text = state.text.slice(0, state.cursor) + state.text.slice(to);
|
|
489
|
+
return commit(state, text, state.cursor, "delete");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function moveHorizontal(state: EditorState, dir: -1 | 1): EditorState {
|
|
493
|
+
const cursor = dir < 0
|
|
494
|
+
? prevClusterStart(state.text, state.cursor)
|
|
495
|
+
: nextClusterEnd(state.text, state.cursor);
|
|
496
|
+
return moveTo(state, cursor);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function moveVertical(state: EditorState, dir: -1 | 1): EditorState {
|
|
500
|
+
const { lines, line } = locate(state.text, state.cursor);
|
|
501
|
+
const preferred = state.preferredColumn;
|
|
502
|
+
if (dir < 0 && line === 0) return historyPrev(state);
|
|
503
|
+
if (dir > 0 && line === lines.length - 1) return historyNext(state);
|
|
504
|
+
|
|
505
|
+
const targetLine = line + dir;
|
|
506
|
+
const target = lines[targetLine]!;
|
|
507
|
+
const newOffset = cursorAtColumn(target, preferred);
|
|
508
|
+
const cursor = indexOfLine(lines, targetLine, newOffset);
|
|
509
|
+
return { ...clearSubmitted(state), cursor, preferredColumn: preferred, lastGroup: "move" };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function moveLineStart(state: EditorState): EditorState {
|
|
513
|
+
const { lines, line } = locate(state.text, state.cursor);
|
|
514
|
+
return moveTo(state, indexOfLine(lines, line, 0));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function moveLineEnd(state: EditorState): EditorState {
|
|
518
|
+
const { lines, line } = locate(state.text, state.cursor);
|
|
519
|
+
const row = lines[line]!;
|
|
520
|
+
return moveTo(state, indexOfLine(lines, line, row.length));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function deleteToLineStart(state: EditorState): EditorState {
|
|
524
|
+
const { lines, line, offset } = locate(state.text, state.cursor);
|
|
525
|
+
const row = lines[line]!;
|
|
526
|
+
const killed = row.slice(0, offset);
|
|
527
|
+
const next = [...lines];
|
|
528
|
+
next[line] = row.slice(offset);
|
|
529
|
+
const text = next.join("\n");
|
|
530
|
+
return kill(state, text, indexOfLine(next, line, 0), killed);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function deleteToLineEnd(state: EditorState): EditorState {
|
|
534
|
+
const { lines, line, offset } = locate(state.text, state.cursor);
|
|
535
|
+
const row = lines[line]!;
|
|
536
|
+
const killed = row.slice(offset);
|
|
537
|
+
const next = [...lines];
|
|
538
|
+
next[line] = row.slice(0, offset);
|
|
539
|
+
return kill(state, next.join("\n"), state.cursor, killed);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function deleteWordBackward(state: EditorState): EditorState {
|
|
543
|
+
const to = wordOffset(state, -1);
|
|
544
|
+
if (to >= state.cursor) return clearSubmitted(state);
|
|
545
|
+
const killed = state.text.slice(to, state.cursor);
|
|
546
|
+
const text = state.text.slice(0, to) + state.text.slice(state.cursor);
|
|
547
|
+
return kill(state, text, to, killed);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function deleteWordForward(state: EditorState): EditorState {
|
|
551
|
+
const to = wordOffset(state, 1);
|
|
552
|
+
if (to <= state.cursor) return clearSubmitted(state);
|
|
553
|
+
const killed = state.text.slice(state.cursor, to);
|
|
554
|
+
const text = state.text.slice(0, state.cursor) + state.text.slice(to);
|
|
555
|
+
return kill(state, text, state.cursor, killed);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function moveWord(state: EditorState, dir: -1 | 1): EditorState {
|
|
559
|
+
return moveTo(state, wordOffset(state, dir));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function historyPrev(state: EditorState): EditorState {
|
|
563
|
+
if (state.secret) return clearSubmitted(state);
|
|
564
|
+
if (state.history.length === 0) return clearSubmitted(state);
|
|
565
|
+
const next = state.historyIndex === -1 ? 0 : state.historyIndex + 1;
|
|
566
|
+
if (next >= state.history.length) return clearSubmitted(state);
|
|
567
|
+
const draft = state.historyIndex === -1 ? state.text : state.draft;
|
|
568
|
+
const text = state.history[next]!;
|
|
569
|
+
// Cursor at start so another up stays on the first hard line and
|
|
570
|
+
// continues walking history instead of moving inside a multiline entry.
|
|
571
|
+
const base = commit(clearSubmitted(state), text, 0, "yank");
|
|
572
|
+
return { ...base, preferredColumn: 0, historyIndex: next, draft };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function historyNext(state: EditorState): EditorState {
|
|
576
|
+
if (state.secret) return clearSubmitted(state);
|
|
577
|
+
if (state.historyIndex === -1) return clearSubmitted(state);
|
|
578
|
+
const next = state.historyIndex - 1;
|
|
579
|
+
if (next < 0) {
|
|
580
|
+
const text = state.draft ?? "";
|
|
581
|
+
const base = commit(
|
|
582
|
+
{ ...state, historyIndex: -1, draft: null, submitted: null },
|
|
583
|
+
text,
|
|
584
|
+
text.length,
|
|
585
|
+
"yank",
|
|
586
|
+
);
|
|
587
|
+
return base;
|
|
588
|
+
}
|
|
589
|
+
const text = state.history[next]!;
|
|
590
|
+
// Cursor at end so another down stays on the last hard line.
|
|
591
|
+
return {
|
|
592
|
+
...commit({ ...state, submitted: null }, text, text.length, "yank"),
|
|
593
|
+
historyIndex: next,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function columnOf(text: string, cursor: number): number {
|
|
598
|
+
const { lines, line, offset } = locate(text, cursor);
|
|
599
|
+
return columnAt(lines[line]!, offset);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
interface Location {
|
|
603
|
+
readonly lines: string[];
|
|
604
|
+
readonly line: number;
|
|
605
|
+
readonly offset: number;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function locate(text: string, cursor: number): Location {
|
|
609
|
+
const lines = text.split("\n");
|
|
610
|
+
let rest = Math.max(0, Math.min(cursor, text.length));
|
|
611
|
+
for (let line = 0; line < lines.length; line++) {
|
|
612
|
+
const len = lines[line]!.length;
|
|
613
|
+
if (rest <= len) return { lines, line, offset: rest };
|
|
614
|
+
rest -= len + 1;
|
|
615
|
+
}
|
|
616
|
+
const line = lines.length - 1;
|
|
617
|
+
return { lines, line, offset: lines[line]!.length };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function indexOfLine(lines: readonly string[], line: number, offset: number): number {
|
|
621
|
+
let i = 0;
|
|
622
|
+
for (let n = 0; n < line; n++) i += lines[n]!.length + 1;
|
|
623
|
+
return i + offset;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function columnAt(line: string, offset: number): number {
|
|
627
|
+
return displayWidth(line.slice(0, offset));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function cursorAtColumn(line: string, target: number): number {
|
|
631
|
+
let col = 0;
|
|
632
|
+
let i = 0;
|
|
633
|
+
for (const ch of line) {
|
|
634
|
+
const w = displayWidth(ch);
|
|
635
|
+
if (w > 0 && col + w > target) break;
|
|
636
|
+
col += w;
|
|
637
|
+
i += ch.length;
|
|
638
|
+
}
|
|
639
|
+
return i;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* A newline is width 0 in displayWidth (it is a Cc) but it is a hard
|
|
644
|
+
* break, not a combining mark. Treating it as combining would let left
|
|
645
|
+
* skip past a whole line-break as if it were an accent.
|
|
646
|
+
*/
|
|
647
|
+
function isClusterBase(ch: string): boolean {
|
|
648
|
+
return ch === "\n" || displayWidth(ch) > 0;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function prevClusterStart(text: string, cursor: number): number {
|
|
652
|
+
if (cursor <= 0) return 0;
|
|
653
|
+
const chars = [...text.slice(0, cursor)];
|
|
654
|
+
while (chars.length > 0 && !isClusterBase(chars[chars.length - 1]!)) chars.pop();
|
|
655
|
+
if (chars.length > 0) chars.pop();
|
|
656
|
+
let n = 0;
|
|
657
|
+
for (const ch of chars) n += ch.length;
|
|
658
|
+
return n;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function nextClusterEnd(text: string, cursor: number): number {
|
|
662
|
+
if (cursor >= text.length) return text.length;
|
|
663
|
+
const chars = [...text.slice(cursor)];
|
|
664
|
+
if (chars.length === 0) return text.length;
|
|
665
|
+
let consumed = chars[0]!.length;
|
|
666
|
+
if (isClusterBase(chars[0]!)) {
|
|
667
|
+
for (let i = 1; i < chars.length; i++) {
|
|
668
|
+
const ch = chars[i]!;
|
|
669
|
+
if (isClusterBase(ch)) break;
|
|
670
|
+
consumed += ch.length;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return cursor + consumed;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function isAsciiWordChar(ch: string): boolean {
|
|
677
|
+
return /[A-Za-z0-9_]/.test(ch);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Word motion stays on the current hard line. Crossing a newline is a
|
|
682
|
+
* character motion; mixing the two made Ctrl+W at column 0 eat the
|
|
683
|
+
* previous line's last word, which is not what the bindings say.
|
|
684
|
+
*/
|
|
685
|
+
function wordOffset(state: EditorState, dir: -1 | 1): number {
|
|
686
|
+
const { lines, line, offset } = locate(state.text, state.cursor);
|
|
687
|
+
const row = lines[line]!;
|
|
688
|
+
const chars = [...row];
|
|
689
|
+
let i = 0;
|
|
690
|
+
let units = 0;
|
|
691
|
+
while (units < offset && i < chars.length) {
|
|
692
|
+
units += chars[i]!.length;
|
|
693
|
+
i++;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (dir < 0) {
|
|
697
|
+
while (i > 0 && !isAsciiWordChar(chars[i - 1]!)) i--;
|
|
698
|
+
while (i > 0 && isAsciiWordChar(chars[i - 1]!)) i--;
|
|
699
|
+
} else {
|
|
700
|
+
while (i < chars.length && !isAsciiWordChar(chars[i]!)) i++;
|
|
701
|
+
while (i < chars.length && isAsciiWordChar(chars[i]!)) i++;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
let newOffset = 0;
|
|
705
|
+
for (let j = 0; j < i; j++) newOffset += chars[j]!.length;
|
|
706
|
+
return indexOfLine(lines, line, newOffset);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
interface VisualLine {
|
|
710
|
+
readonly text: string;
|
|
711
|
+
readonly start: number;
|
|
712
|
+
readonly end: number;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Same wrap rule as Text in text.ts (kept local: wrapLine is not part of
|
|
717
|
+
* the kernel's public surface and this card cannot change text.ts). A
|
|
718
|
+
* CJK character that would overflow starts a new line instead of being
|
|
719
|
+
* split, because a terminal cell cannot hold half of it.
|
|
720
|
+
*/
|
|
721
|
+
function wrapLogicalLine(s: string, width: number): VisualLine[] {
|
|
722
|
+
if (width <= 0) return [{ text: s, start: 0, end: s.length }];
|
|
723
|
+
if (s.length === 0) return [{ text: "", start: 0, end: 0 }];
|
|
724
|
+
const lines: VisualLine[] = [];
|
|
725
|
+
let start = 0;
|
|
726
|
+
let current = "";
|
|
727
|
+
let currentW = 0;
|
|
728
|
+
let i = 0;
|
|
729
|
+
for (const ch of s) {
|
|
730
|
+
const w = displayWidth(ch);
|
|
731
|
+
if (w > 0 && currentW + w > width && current.length > 0) {
|
|
732
|
+
lines.push({ text: current, start, end: i });
|
|
733
|
+
start = i;
|
|
734
|
+
current = ch;
|
|
735
|
+
currentW = w;
|
|
736
|
+
} else {
|
|
737
|
+
current += ch;
|
|
738
|
+
currentW += w;
|
|
739
|
+
}
|
|
740
|
+
i += ch.length;
|
|
741
|
+
}
|
|
742
|
+
lines.push({ text: current, start, end: i });
|
|
743
|
+
return lines;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export function renderEditor(state: EditorState, width: number): string[] {
|
|
747
|
+
const source = state.secret ? maskSecret(state.text) : state.text;
|
|
748
|
+
const out: string[] = [];
|
|
749
|
+
for (const hard of source.split("\n")) {
|
|
750
|
+
for (const vis of wrapLogicalLine(hard, width)) out.push(vis.text);
|
|
751
|
+
}
|
|
752
|
+
if (out.length === 0) out.push("");
|
|
753
|
+
if (state.completion !== null) {
|
|
754
|
+
out.push(...renderSelect(state.completion, width));
|
|
755
|
+
}
|
|
756
|
+
return out;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** One mask cell per code point; newlines stay so wrap still matches. */
|
|
760
|
+
function maskSecret(text: string): string {
|
|
761
|
+
let out = "";
|
|
762
|
+
for (const ch of text) out += ch === "\n" ? "\n" : "*";
|
|
763
|
+
return out;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export class Editor implements Component {
|
|
767
|
+
private secret: boolean;
|
|
768
|
+
private submitted: string | null = null;
|
|
769
|
+
private history: string[];
|
|
770
|
+
private readonly historyLimit: number;
|
|
771
|
+
private provider: AutocompleteProvider | undefined;
|
|
772
|
+
bindings: KeybindingsManager | undefined;
|
|
773
|
+
onInput: ((data: string) => void) | undefined;
|
|
774
|
+
private style: Style = IDENTITY;
|
|
775
|
+
/**
|
|
776
|
+
* Pi paints a horizontal rule above and below the buffer. Colour tracks
|
|
777
|
+
* the thinking level so a glance at the frame says which budget the next
|
|
778
|
+
* turn will spend (pi's behaviour). Identity until the Tui sets it.
|
|
779
|
+
*/
|
|
780
|
+
borderColor: (s: string) => string = (s) => s;
|
|
781
|
+
readonly host: TUI;
|
|
782
|
+
private pi: PiEditor;
|
|
783
|
+
|
|
784
|
+
constructor(opts: CreateEditorOptions = {}) {
|
|
785
|
+
this.secret = opts.secret ?? false;
|
|
786
|
+
this.history = [...(opts.history ?? [])].reverse();
|
|
787
|
+
this.historyLimit = opts.historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
|
788
|
+
this.host = opts.tui ?? stubTui();
|
|
789
|
+
this.pi = this.createPi();
|
|
790
|
+
this.pi.setText(opts.text ?? "");
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
private createPi(): PiEditor {
|
|
794
|
+
const theme = identityEditorTheme();
|
|
795
|
+
const pi = new PiEditor(this.host, {
|
|
796
|
+
...theme,
|
|
797
|
+
borderColor: (s) => this.borderColor(s),
|
|
798
|
+
selectList: {
|
|
799
|
+
selectedPrefix: (s) => paint(s, { ...PLAIN_ATTRS, selected: true }, this.style),
|
|
800
|
+
selectedText: (s) => paint(s, { ...PLAIN_ATTRS, selected: true }, this.style),
|
|
801
|
+
description: (s) => paint(s, { ...PLAIN_ATTRS, dim: true }, this.style),
|
|
802
|
+
scrollInfo: (s) => paint(s, { ...PLAIN_ATTRS, dim: true }, this.style),
|
|
803
|
+
noMatch: (s) => paint(s, { ...PLAIN_ATTRS, dim: true }, this.style),
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
if (!this.secret) {
|
|
807
|
+
for (const text of [...this.history].reverse()) pi.addToHistory(text);
|
|
808
|
+
if (this.provider) pi.setAutocompleteProvider(this.provider);
|
|
809
|
+
}
|
|
810
|
+
pi.onSubmit = (text) => {
|
|
811
|
+
this.submitted = text;
|
|
812
|
+
if (!this.secret && text.trim().length > 0) {
|
|
813
|
+
pi.addToHistory(text);
|
|
814
|
+
if (this.history[0] !== text) this.history.unshift(text);
|
|
815
|
+
this.history = this.history.slice(0, this.historyLimit);
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
return pi;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
get focused(): boolean { return this.pi.focused; }
|
|
822
|
+
set focused(value: boolean) { this.pi.focused = value; }
|
|
823
|
+
setStyle(style: Style): void { this.style = style; }
|
|
824
|
+
|
|
825
|
+
handleInput(data: string): void {
|
|
826
|
+
if (this.onInput) this.onInput(data);
|
|
827
|
+
else this.acceptInput(data);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
acceptInput(data: string): EditorState {
|
|
831
|
+
this.submitted = null;
|
|
832
|
+
const previous = getKeybindings();
|
|
833
|
+
if (this.bindings) setKeybindings(this.bindings);
|
|
834
|
+
try { this.pi.handleInput(data); }
|
|
835
|
+
finally { setKeybindings(previous); }
|
|
836
|
+
return this.state;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
get autocompleteVisible(): boolean { return this.pi.isShowingAutocomplete(); }
|
|
840
|
+
|
|
841
|
+
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
842
|
+
this.provider = provider;
|
|
843
|
+
if (!this.secret) this.pi.setAutocompleteProvider(provider);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
invalidate(): void {
|
|
847
|
+
this.pi.invalidate();
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
get state(): EditorState {
|
|
851
|
+
const text = this.pi.getExpandedText();
|
|
852
|
+
const pos = this.pi.getCursor();
|
|
853
|
+
const cursor = this.pi.getLines().slice(0, pos.line).reduce((n, line) => n + line.length + 1, 0) + pos.col;
|
|
854
|
+
return { ...createEditor(), text, cursor, submitted: this.submitted, secret: this.secret,
|
|
855
|
+
history: this.history, historyLimit: this.historyLimit };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
applyKey(key: Key): EditorState {
|
|
859
|
+
return this.acceptInput(encodeKey(key));
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
applyExternal(text: string): EditorState {
|
|
863
|
+
this.pi.setText(text);
|
|
864
|
+
this.submitted = null;
|
|
865
|
+
return this.state;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
setSecret(secret: boolean): EditorState {
|
|
869
|
+
if (this.secret === secret) return this.state;
|
|
870
|
+
const focused = this.focused;
|
|
871
|
+
const text = this.secret ? "" : this.pi.getExpandedText();
|
|
872
|
+
this.secret = secret;
|
|
873
|
+
// Discard secret undo/history at the authentication boundary.
|
|
874
|
+
this.pi = this.createPi();
|
|
875
|
+
this.pi.focused = focused;
|
|
876
|
+
this.pi.setText(text);
|
|
877
|
+
return this.state;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
render(width: number): string[] {
|
|
881
|
+
this.pi.borderColor = this.borderColor;
|
|
882
|
+
if (this.secret) {
|
|
883
|
+
// A display-only projection never passes plaintext to the renderer.
|
|
884
|
+
const masked = new PiEditor(this.host, identityEditorTheme());
|
|
885
|
+
masked.borderColor = this.borderColor;
|
|
886
|
+
masked.focused = this.focused;
|
|
887
|
+
masked.setText(maskSecret(this.pi.getText()));
|
|
888
|
+
for (const _ of this.pi.getText().slice(this.state.cursor)) masked.handleInput("\x1b[D");
|
|
889
|
+
return masked.render(width);
|
|
890
|
+
}
|
|
891
|
+
return this.pi.render(width);
|
|
892
|
+
}
|
|
893
|
+
}
|