@oh-my-pi/pi-tui 18.1.16 → 18.1.18
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/CHANGELOG.md +15 -0
- package/dist/types/components/editor.d.ts +30 -0
- package/dist/types/mouse.d.ts +5 -5
- package/dist/types/terminal.d.ts +14 -0
- package/dist/types/tui.d.ts +22 -0
- package/dist/types/vim.d.ts +94 -0
- package/package.json +3 -3
- package/src/components/editor.ts +492 -22
- package/src/mouse.ts +5 -5
- package/src/terminal.ts +42 -0
- package/src/tui.ts +106 -17
- package/src/vim.ts +760 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.1.17] - 2026-09-10
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Editor history can retain local draft snapshots with their paste expansions and host-owned attachment restoration, without writing them to persistent history ([#11524](https://github.com/can1357/oh-my-pi/pull/11524) by [@camjac251](https://github.com/camjac251)).
|
|
10
|
+
- Added an optional Vim-style modal editing layer to `Editor`, off unless `setVimMode(true)` is called: Insert, Normal, and Visual/Visual-Line with motions, count prefixes, and operators. `j`/`k` keep Vim's desired column, so passing over a shorter line does not collapse it, and `$` sticks to end-of-line. `p`/`P` paste from an internal register; yanks are surfaced to the host through `onYank` so it can route them to the system clipboard.
|
|
11
|
+
- Vim mode now exposes its chrome to hosts: `Editor.vimEnabled`, `Editor.vimPending` (the half-typed command, e.g. `2d`), and `Editor.vimSelectedLines` (Visual selection height). `onVimModeChange` additionally fires when the pending command or selection size changes, not only on mode switches.
|
|
12
|
+
- Vim mode now understands text objects: `iw`/`aw` (and `W`), quotes `i"`/`a'`/`` a` ``, bracket pairs `i(`/`a{`/`i[`/`a<` (nesting-aware and multi-line), and paragraphs `ip`/`ap`. They work under an operator (`diw`, `ca(`) and in Visual mode (`viw`), with counts (`d2aw`). Previously `i` after an operator fell through to Insert mode, so `diw` typed text instead of deleting a word.
|
|
13
|
+
- Vim mode's `c` operator now actually enters Insert mode (`cw`, `cip`, `c` in Visual); it also follows Vim's `cw`-acts-like-`ce` quirk and parks the cursor correctly when the changed range is empty (`ci"` between bare quotes).
|
|
14
|
+
- Added `Terminal.setCursorShape()` (DECSCUSR), restored to the terminal's configured shape on teardown and crash cleanup.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- The software cursor now reflects the Vim mode: a reverse-video block in Normal/Visual and an underline in Insert. Both occupy one cell, so layout is unchanged, and non-modal editors keep the reverse-video block they always had.
|
|
19
|
+
|
|
5
20
|
## [18.1.15] - 2026-09-08
|
|
6
21
|
|
|
7
22
|
### Fixed
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type AutocompleteProvider } from "../autocomplete.js";
|
|
2
2
|
import type { SymbolTheme } from "../symbols.js";
|
|
3
3
|
import { type Component, type Focusable } from "../tui.js";
|
|
4
|
+
import { type VimMode } from "../vim.js";
|
|
4
5
|
import { type EditorBorderStyle, type EditorTopBorder } from "./composer/index.js";
|
|
5
6
|
export type { EditorBorderStyle, EditorTopBorder };
|
|
6
7
|
import { type SelectListTheme } from "./select-list.js";
|
|
@@ -74,6 +75,11 @@ export declare class Editor implements Component, Focusable {
|
|
|
74
75
|
* Width-changing output is allowed on lines without the cursor; it is truncated
|
|
75
76
|
* to the content width rather than reflowed. Cursor glyphs and inline hints are excluded. */
|
|
76
77
|
decorateText: ((text: string, context: EditorTextDecorationContext) => string) | undefined;
|
|
78
|
+
/** Called with the selected text when Visual mode yanks, so hosts can reach the system
|
|
79
|
+
* clipboard — `packages/tui` deliberately has no clipboard dependency of its own. */
|
|
80
|
+
onYank?: (text: string) => void;
|
|
81
|
+
/** Fired when the modal state changes, so hosts can restyle their chrome (border, status). */
|
|
82
|
+
onVimModeChange?: (mode: VimMode) => void;
|
|
77
83
|
borderColor: (str: string) => string;
|
|
78
84
|
onAutocompleteUpdate?: () => void;
|
|
79
85
|
/** Called after an async text-assist result mutates the document outside an input event, so hosts can schedule a repaint. */
|
|
@@ -148,6 +154,24 @@ export declare class Editor implements Component, Focusable {
|
|
|
148
154
|
setUseTerminalCursor(useTerminalCursor: boolean): void;
|
|
149
155
|
/** Render a dedicated bottom border so terminal-local IME preedit cannot shift editor chrome. */
|
|
150
156
|
setImeSafeCursorLayout(enabled: boolean): void;
|
|
157
|
+
/** Enable Vim-style modal editing. Toggling always drops back to Insert mode so the editor is
|
|
158
|
+
* never left in a state where ordinary typing does nothing. */
|
|
159
|
+
setVimMode(enabled: boolean): void;
|
|
160
|
+
/** Current modal state; always `"insert"` when Vim mode is off. */
|
|
161
|
+
get vimMode(): VimMode;
|
|
162
|
+
/** True when modal editing is active, regardless of which mode is current. */
|
|
163
|
+
get vimEnabled(): boolean;
|
|
164
|
+
/** Half-typed Vim command (`"2d"`, `"g"`), or `""` when nothing is pending. */
|
|
165
|
+
get vimPending(): string;
|
|
166
|
+
/** Lines spanned by the active Visual selection; 0 outside Visual modes. */
|
|
167
|
+
get vimSelectedLines(): number;
|
|
168
|
+
/**
|
|
169
|
+
* Whether Escape belongs to the editor right now rather than to the app.
|
|
170
|
+
*
|
|
171
|
+
* Hosts bind Escape to interrupt/clear; in Vim mode it first has to mean "leave Insert mode" and
|
|
172
|
+
* "cancel a half-typed operator". Only a quiet Normal mode gives Escape back to the app.
|
|
173
|
+
*/
|
|
174
|
+
vimConsumesEscape(): boolean;
|
|
151
175
|
getUseTerminalCursor(): boolean;
|
|
152
176
|
setMaxHeight(maxHeight: number | undefined): void;
|
|
153
177
|
/** Enable/disable the right-border scrollbar. Only shown when content overflows. */
|
|
@@ -162,6 +186,12 @@ export declare class Editor implements Component, Focusable {
|
|
|
162
186
|
* Called after successful submission.
|
|
163
187
|
*/
|
|
164
188
|
addToHistory(text: string): void;
|
|
189
|
+
/** Retain the current draft for local recall only, never persistent history. */
|
|
190
|
+
rememberDraft(restore?: () => void): void;
|
|
191
|
+
/** Release the current draft's expansion payloads without touching history. */
|
|
192
|
+
clearPasteState(): void;
|
|
193
|
+
/** Restore host-owned draft state before history text triggers onChange. */
|
|
194
|
+
restoreHistoryState(restore?: () => void): void;
|
|
165
195
|
invalidate(): void;
|
|
166
196
|
render(width: number): readonly string[];
|
|
167
197
|
handleInput(data: string): void;
|
package/dist/types/mouse.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SGR mouse report parsing (`\x1b[<button;col;rowM` / `…m`).
|
|
3
3
|
*
|
|
4
|
-
* Mouse tracking is enabled
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the frame paints from screen row 0, hence
|
|
8
|
-
* 0-based for direct indexing into rendered lines.
|
|
4
|
+
* Mouse tracking is enabled while a fullscreen overlay holds the alternate
|
|
5
|
+
* screen (see tui.ts MOUSE_TRACKING_ON), or — opt-in via `tui.mouse` — on the
|
|
6
|
+
* normal buffer whenever no overlay is visible. Consumers hit-test
|
|
7
|
+
* against their own rendered frame: the frame paints from screen row 0, hence
|
|
8
|
+
* `row`/`col` are exposed 0-based for direct indexing into rendered lines.
|
|
9
9
|
*/
|
|
10
10
|
/** A decoded SGR mouse report. */
|
|
11
11
|
export interface SgrMouseEvent {
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -106,6 +106,13 @@ export type TerminalAppearanceRequestToken = number;
|
|
|
106
106
|
* set, 4 permanently reset) when the terminal answered DECRQM.
|
|
107
107
|
*/
|
|
108
108
|
export type PrivateModeReportHandler = (mode: number, supported: boolean, confirmed?: boolean, status?: number) => void;
|
|
109
|
+
/**
|
|
110
|
+
* Cursor shapes addressable via DECSCUSR (`CSI <n> SP q`). `"default"` (0) hands the shape back to
|
|
111
|
+
* the terminal's own configuration, which is what teardown restores rather than guessing a shape
|
|
112
|
+
* the user never chose.
|
|
113
|
+
*/
|
|
114
|
+
export type CursorShape = "default" | "block" | "underline" | "bar";
|
|
115
|
+
export declare const CURSOR_SHAPE_CODES: Record<CursorShape, number>;
|
|
109
116
|
export interface Terminal {
|
|
110
117
|
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void, options?: TerminalStartOptions): void;
|
|
111
118
|
/**
|
|
@@ -142,6 +149,7 @@ export interface Terminal {
|
|
|
142
149
|
moveBy(lines: number): void;
|
|
143
150
|
hideCursor(force?: boolean): void;
|
|
144
151
|
showCursor(force?: boolean): void;
|
|
152
|
+
setCursorShape?(shape: CursorShape): void;
|
|
145
153
|
clearLine(): void;
|
|
146
154
|
clearFromCursor(): void;
|
|
147
155
|
clearScreen(): void;
|
|
@@ -243,6 +251,12 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
243
251
|
moveBy(lines: number): void;
|
|
244
252
|
hideCursor(force?: boolean): void;
|
|
245
253
|
showCursor(force?: boolean): void;
|
|
254
|
+
/**
|
|
255
|
+
* Set the hardware cursor shape (DECSCUSR). Deduped against the last shape written so a
|
|
256
|
+
* per-keystroke mode indicator does not add a sequence to every frame; {@link stop} restores
|
|
257
|
+
* `"default"` so the user's own cursor configuration survives exit.
|
|
258
|
+
*/
|
|
259
|
+
setCursorShape(shape: CursorShape): void;
|
|
246
260
|
clearLine(): void;
|
|
247
261
|
clearFromCursor(): void;
|
|
248
262
|
clearScreen(): void;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -334,6 +334,28 @@ export declare class TUI extends Container {
|
|
|
334
334
|
hideOverlay(): void;
|
|
335
335
|
/** Check if there are any visible overlays */
|
|
336
336
|
hasOverlay(): boolean;
|
|
337
|
+
/**
|
|
338
|
+
* Mutable normal-buffer viewport from the last provider frame: screen row
|
|
339
|
+
* where it begins plus its row count. Inline click targets are indexed
|
|
340
|
+
* into this window (`screenRow - top`). Empty while the alt screen owns
|
|
341
|
+
* the display, while a resize transaction is settling, and while a Ghostty
|
|
342
|
+
* image paint is deferred — the painted rows predate the latest spans in
|
|
343
|
+
* all three cases, so hits would map to unrelated old rows.
|
|
344
|
+
* The origin is in composer rows: a replay paint replaces leading composer
|
|
345
|
+
* blanks with history rows and prepends blanks for a short viewport, so
|
|
346
|
+
* the painted top is backed out by that net pad.
|
|
347
|
+
*/
|
|
348
|
+
getMutableViewport(): {
|
|
349
|
+
top: number;
|
|
350
|
+
length: number;
|
|
351
|
+
};
|
|
352
|
+
/**
|
|
353
|
+
* Probe for opt-in normal-buffer click capture. The provider is read every
|
|
354
|
+
* frame; while it returns true (and no fullscreen overlay owns the
|
|
355
|
+
* display) the terminal reports button clicks as SGR events for inline
|
|
356
|
+
* click targets. Native text selection becomes Shift+drag while on.
|
|
357
|
+
*/
|
|
358
|
+
setInlineMouseTrackingProvider(provider: (() => boolean) | undefined): void;
|
|
337
359
|
invalidate(): void;
|
|
338
360
|
start(options?: TUIStartOptions): void;
|
|
339
361
|
/**
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modal editing state machine for {@link Editor}.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately a *pure* state machine: it reads a snapshot of the buffer and returns the edits it
|
|
5
|
+
* wants applied, so motions can be unit-tested without a terminal and the editor keeps sole
|
|
6
|
+
* ownership of undo, atomic placeholder tokens, the kill ring, and `onChange`.
|
|
7
|
+
*
|
|
8
|
+
* This is a usable subset of Vim, not a reimplementation of it (see issue #3299): Normal and Visual
|
|
9
|
+
* modes, the common motions, and operators built from those motions.
|
|
10
|
+
*/
|
|
11
|
+
export type VimMode = "insert" | "normal" | "visual" | "visual-line";
|
|
12
|
+
export type VimOperator = "d" | "y" | "c";
|
|
13
|
+
export interface VimPosition {
|
|
14
|
+
line: number;
|
|
15
|
+
col: number;
|
|
16
|
+
}
|
|
17
|
+
/** Read-only view of the editor buffer that motions resolve against. */
|
|
18
|
+
export interface VimBuffer {
|
|
19
|
+
readonly lines: readonly string[];
|
|
20
|
+
readonly cursorLine: number;
|
|
21
|
+
readonly cursorCol: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* An edit the editor should apply. `to` is always an *exclusive* end offset, so ranges compose the
|
|
25
|
+
* same way regardless of whether the originating motion was inclusive (`e`) or exclusive (`w`).
|
|
26
|
+
*/
|
|
27
|
+
export type VimCommand = {
|
|
28
|
+
kind: "move";
|
|
29
|
+
to: VimPosition;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "mode";
|
|
32
|
+
mode: VimMode;
|
|
33
|
+
} | {
|
|
34
|
+
kind: "yank";
|
|
35
|
+
from: VimPosition;
|
|
36
|
+
to: VimPosition;
|
|
37
|
+
linewise: boolean;
|
|
38
|
+
} | {
|
|
39
|
+
kind: "delete";
|
|
40
|
+
from: VimPosition;
|
|
41
|
+
to: VimPosition;
|
|
42
|
+
linewise: boolean;
|
|
43
|
+
insert: boolean;
|
|
44
|
+
} | {
|
|
45
|
+
kind: "openLine";
|
|
46
|
+
below: boolean;
|
|
47
|
+
} | {
|
|
48
|
+
kind: "paste";
|
|
49
|
+
after: boolean;
|
|
50
|
+
count: number;
|
|
51
|
+
} | {
|
|
52
|
+
kind: "undo";
|
|
53
|
+
};
|
|
54
|
+
/** Start offset of the grapheme after `col`, clamped to `text.length`. */
|
|
55
|
+
export declare function nextGraphemeStart(text: string, col: number): number;
|
|
56
|
+
/** Start offset of the grapheme before `col`, clamped to 0. */
|
|
57
|
+
export declare function prevGraphemeStart(text: string, col: number): number;
|
|
58
|
+
/** Offset of the final grapheme — where a Normal-mode cursor rests on a non-empty line. */
|
|
59
|
+
export declare function lastGraphemeStart(text: string): number;
|
|
60
|
+
export declare class VimState {
|
|
61
|
+
#private;
|
|
62
|
+
mode: VimMode;
|
|
63
|
+
/** Fixed end of a Visual selection; the cursor is the moving end. */
|
|
64
|
+
anchor: VimPosition | null;
|
|
65
|
+
/** True while a count, operator, `g`, or text-object prefix is half-typed — Escape cancels it. */
|
|
66
|
+
get pending(): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* The half-typed command as Vim would echo it (`"2"`, `"d"`, `"2d"`, `"di"`) — empty when
|
|
69
|
+
* nothing is pending. Hosts render this next to the mode so a partially entered operator is
|
|
70
|
+
* visible instead of silently swallowing the next keystroke.
|
|
71
|
+
*/
|
|
72
|
+
get pendingText(): string;
|
|
73
|
+
get visual(): boolean;
|
|
74
|
+
reset(): void;
|
|
75
|
+
/**
|
|
76
|
+
* Handle one key. `key` is either the literal `"escape"` or a single grapheme; the editor
|
|
77
|
+
* normalizes arrow/Home/End keys onto their Vim equivalents before calling.
|
|
78
|
+
*
|
|
79
|
+
* Returns the commands to apply, or `null` when the key is not ours — the editor then falls
|
|
80
|
+
* through to its regular handling (and, for Escape in Normal mode, to the app's interrupt).
|
|
81
|
+
*/
|
|
82
|
+
handleKey(key: string, buf: VimBuffer): VimCommand[] | null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Normalized, end-exclusive span covered by a Visual selection.
|
|
86
|
+
*
|
|
87
|
+
* Charwise selections include the grapheme under the cursor (Vim semantics); linewise selections
|
|
88
|
+
* span whole lines, with `to.col` at the end of the last line so the caller can decide whether the
|
|
89
|
+
* trailing newline goes too.
|
|
90
|
+
*/
|
|
91
|
+
export declare function visualRange(buf: VimBuffer, anchor: VimPosition, linewise: boolean): {
|
|
92
|
+
from: VimPosition;
|
|
93
|
+
to: VimPosition;
|
|
94
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "18.1.
|
|
4
|
+
"version": "18.1.18",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Stencil Labs, Inc.",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "18.1.
|
|
41
|
-
"@oh-my-pi/pi-utils": "18.1.
|
|
40
|
+
"@oh-my-pi/pi-natives": "18.1.18",
|
|
41
|
+
"@oh-my-pi/pi-utils": "18.1.18"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"kitty-vt-wasm": "^0.2.0"
|