@oh-my-pi/pi-tui 18.1.16 → 18.1.17

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 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;
@@ -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;
@@ -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.16",
4
+ "version": "18.1.17",
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.16",
41
- "@oh-my-pi/pi-utils": "18.1.16"
40
+ "@oh-my-pi/pi-natives": "18.1.17",
41
+ "@oh-my-pi/pi-utils": "18.1.17"
42
42
  },
43
43
  "devDependencies": {
44
44
  "kitty-vt-wasm": "^0.2.0"