@oh-my-pi/pi-tui 18.2.0 → 18.2.2
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 +20 -0
- package/dist/types/autocomplete.d.ts +2 -1
- package/dist/types/components/editor.d.ts +10 -0
- package/dist/types/components/image.d.ts +49 -7
- package/dist/types/terminal-capabilities.d.ts +18 -0
- package/dist/types/terminal-multiplexer.d.ts +17 -1
- package/dist/types/terminal.d.ts +17 -2
- package/package.json +3 -3
- package/src/autocomplete.ts +48 -19
- package/src/components/editor.ts +42 -1
- package/src/components/image.ts +244 -45
- package/src/terminal-capabilities.ts +52 -4
- package/src/terminal-multiplexer.ts +31 -10
- package/src/terminal.ts +25 -2
- package/src/tui.ts +149 -44
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.2.1] - 2026-09-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `Editor.deleteCharForward()` exposes the `tui.editor.deleteCharForward` operation to hosts that resolve the chord themselves, applying the same transient-state teardown the key dispatch does (pending character jump, spelling-assist popup) and routing through Vim's `x` in Normal and Visual modes.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Inline image limits now bound Kitty graphics retained from earlier frames and fullscreen overlays; older scrollback images are evicted until replayed.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Enter now runs a slash command whose argument completes to a directory, instead of descending into it. ([#12112](https://github.com/can1357/oh-my-pi/pull/12112) by [@Huang-404-Q](https://github.com/Huang-404-Q))
|
|
18
|
+
- Fixed a resize on Windows leaving the screen with a scrolled-up duplicate transcript and no visible response to input. The in-place resize path (default-on for Warp) anchors one settled repaint on a DSR round trip, but a ConPTY host owns that grid: measured on conhost, resizing the pseudoconsole re-emits its whole viewport from `CSI H` with absolute addressing while the application writes nothing, and re-homes the cursor, so the reply reports column 1 and can never be attributed to its probe tag. ConPTY sessions now keep the alternate-screen borrow, whose settled transaction ends in the `ResizeScrollbackMode` rebuild, and skip that unattributable anchor probe — except inside a multiplexer, which answers the DSR from its own grid, and under `PI_TUI_RESIZE_IN_PLACE=1`, which restores the whole pre-change path ([#11625](https://github.com/can1357/oh-my-pi/pull/11625) by [@bse-ai](https://github.com/bse-ai)).
|
|
19
|
+
- Stopped exact-width live rows from entering native scrollback during ConPTY repaints ([#9783](https://github.com/can1357/oh-my-pi/issues/9783)).
|
|
20
|
+
- Forward delete no longer leaves the Vim Normal-mode cursor one column past the end of a line after deleting the final grapheme.
|
|
21
|
+
- Fixed Tabby CMD sessions enabling synchronized output from a spoofed `WT_SESSION`, which caused streaming repaints to overlap when Tabby's terminal chain mishandled DEC 2026.
|
|
22
|
+
- Extension command argument completions now refresh after typing a Space when the previous argument had no suggestions. ([#11060](https://github.com/can1357/oh-my-pi/issues/11060))
|
|
23
|
+
- Detect the wmux Windows terminal multiplexer (`WMUX` / `WMUX_SURFACE_ID`) so its panes take the in-place viewport repaint path instead of the direct-terminal scrollback path.
|
|
24
|
+
|
|
5
25
|
## [18.2.0] - 2026-09-15
|
|
6
26
|
|
|
7
27
|
### Added
|
|
@@ -99,7 +99,8 @@ export declare const SKILL_NAMESPACE = "skill:";
|
|
|
99
99
|
* popup alive through fuzzy name/description hits, so a token only matches as
|
|
100
100
|
* - a prefix of the `skill:` namespace (incl. the bare `/` entry point),
|
|
101
101
|
* - an explicit `skill:…` query (full fuzzy name/description search), or
|
|
102
|
-
* - a prefix of the skill
|
|
102
|
+
* - a prefix at the start of the skill bare name or one of its hyphen-delimited
|
|
103
|
+
* segments (`/hum` → `skill:humanizer`, `/last` → `skill:research-last30days`).
|
|
103
104
|
* Anything else yields no items, letting the caller fall through to path
|
|
104
105
|
* completion or close the popup. Shared with the editor's accept-time
|
|
105
106
|
* staleness guard so Tab/Enter never accepts a skill the refreshed popup
|
|
@@ -228,6 +228,16 @@ export declare class Editor implements Component, Focusable {
|
|
|
228
228
|
moveToLineEnd(): void;
|
|
229
229
|
moveToMessageStart(): void;
|
|
230
230
|
moveToMessageEnd(): void;
|
|
231
|
+
/** The `tui.editor.deleteCharForward` operation, callable by hosts that resolve the chord
|
|
232
|
+
* themselves rather than redispatching the raw key (see CustomEditor's exit-chord overlap).
|
|
233
|
+
* Mirrors the transient state the key dispatch tears down before this action so the two
|
|
234
|
+
* cannot diverge: a pending character jump is cancelled by any other key, and an open
|
|
235
|
+
* spelling-assist popup is dismissed by anything that is not one of its accept keys (its
|
|
236
|
+
* debounced refresh skips assist mode, so a surviving list would hang around forever).
|
|
237
|
+
* While Vim owns the buffer (Normal or Visual) the operation is Vim's `x` — deleting the
|
|
238
|
+
* selection and returning to Normal in Visual mode, the grapheme under the cursor
|
|
239
|
+
* otherwise. Only Insert mode and Vim-off editors delete straight through. */
|
|
240
|
+
deleteCharForward(): void;
|
|
231
241
|
/**
|
|
232
242
|
* Undo the last meaningful edit while ignoring transient text that is still present at the cursor.
|
|
233
243
|
* Used for command-like autocomplete actions whose typed trigger should not count as the edit being undone.
|
|
@@ -32,6 +32,16 @@ export declare const DEFAULT_MAX_INLINE_IMAGES = 8;
|
|
|
32
32
|
* rewritten) plus an explicit graphics purge of the demoted ids. {@link Image}
|
|
33
33
|
* reports display order via {@link observe}; when that reveals a stricter split,
|
|
34
34
|
* the TUI repeats the pass before emitting its terminal frame.
|
|
35
|
+
* Retired frames no longer observe their images, so the resident store is also
|
|
36
|
+
* bounded across passes. Evicting retired graphics removes their scrollback
|
|
37
|
+
* placements; a later replay can render or demote those images again.
|
|
38
|
+
*
|
|
39
|
+
* `cap` bounds one surface's live images, not the terminal's whole store. A
|
|
40
|
+
* fullscreen overlay's frame and the normal screen standing behind it are both
|
|
41
|
+
* on the terminal, and neither may delete the other's graphics — see
|
|
42
|
+
* {@link limitResidentImages} — so while a modal is up the store legitimately
|
|
43
|
+
* holds up to `cap` per surface. Read `cap` as "how many images one frame shows
|
|
44
|
+
* as graphics", not as a hard residency ceiling.
|
|
35
45
|
*
|
|
36
46
|
* `cap <= 0` disables budgeting: every image stays a live graphic.
|
|
37
47
|
*/
|
|
@@ -48,22 +58,40 @@ export declare class ImageBudget {
|
|
|
48
58
|
* gets a fresh id every call.
|
|
49
59
|
*/
|
|
50
60
|
acquireId(key?: string): number;
|
|
61
|
+
/**
|
|
62
|
+
* Start an alternate-buffer lifecycle. Call once per `?1049h`, before the
|
|
63
|
+
* first pass of the fullscreen overlay or resize borrow that owns the buffer.
|
|
64
|
+
*
|
|
65
|
+
* The alt split is a claim about the frame standing on that surface, and
|
|
66
|
+
* `?1049h` hands over a cleared one: the previous occupant's threshold would
|
|
67
|
+
* suppress this buffer's leading images against a frame that no longer
|
|
68
|
+
* exists, painting them as text until a corrective render lands. Passes
|
|
69
|
+
* *within* one lifecycle must keep sharing the split — that is what lets an
|
|
70
|
+
* over-cap discovery pass converge before the frame is emitted.
|
|
71
|
+
*/
|
|
72
|
+
beginAltScreenLifecycle(): void;
|
|
51
73
|
/**
|
|
52
74
|
* Begin a render pass. Called by the renderer before composing the frame.
|
|
53
75
|
* Pass `stable: true` for a partial/throwaway pass that does not walk the
|
|
54
76
|
* whole tree in display order (the resize viewport fast path): {@link observe}
|
|
55
77
|
* then replays the last committed per-id decision instead of one derived from
|
|
56
78
|
* call order, and the pass must NOT be closed with {@link endPass}.
|
|
79
|
+
*
|
|
80
|
+
* Pass `altScreen: true` when the frame is painted on the alternate buffer
|
|
81
|
+
* (fullscreen overlay, resize borrow). The pass then reads and writes that
|
|
82
|
+
* surface's own {@link SurfaceSplit} and its live set adds to the recorded
|
|
83
|
+
* normal-screen one instead of replacing it, so a modal's threshold never
|
|
84
|
+
* reaches the transcript standing behind it.
|
|
57
85
|
*/
|
|
58
|
-
beginPass(stable?: boolean): void;
|
|
86
|
+
beginPass(stable?: boolean, altScreen?: boolean): void;
|
|
59
87
|
/**
|
|
60
88
|
* Record an image in display order and report whether it must render its text
|
|
61
89
|
* fallback this frame. Called by every {@link Image} during render — including
|
|
62
90
|
* on a cache hit, so the image keeps its display-order slot.
|
|
63
91
|
*
|
|
64
92
|
* During a `stable` pass ({@link beginPass}) the call order and visible subset
|
|
65
|
-
* are not authoritative, so the decision is the committed
|
|
66
|
-
*
|
|
93
|
+
* are not authoritative, so the decision is the surface's committed
|
|
94
|
+
* on-terminal split, keyed by id — order- and partiality-independent.
|
|
67
95
|
*/
|
|
68
96
|
observe(imageId: number): boolean;
|
|
69
97
|
/**
|
|
@@ -71,6 +99,20 @@ export declare class ImageBudget {
|
|
|
71
99
|
* and must be repeated before its terminal frame is emitted.
|
|
72
100
|
*/
|
|
73
101
|
endPass(): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Bound the terminal's image store to `cap`. Demotion ({@link endPass}) already
|
|
104
|
+
* retires the graphics this frame replaced with text; this sweeps the ones no
|
|
105
|
+
* frame shows any more — images the pass simply stopped observing.
|
|
106
|
+
*
|
|
107
|
+
* Also records what this frame leaves standing on its surface, which is how
|
|
108
|
+
* the next pass on the *other* surface knows what it may not destroy.
|
|
109
|
+
*/
|
|
110
|
+
limitResidentImages(): void;
|
|
111
|
+
/**
|
|
112
|
+
* Image ids a destructive reset must delete explicitly, alongside its `d=A`.
|
|
113
|
+
* Emit only from that reset's repaint; clears the queue.
|
|
114
|
+
*/
|
|
115
|
+
takeResetPurgeIds(): readonly number[];
|
|
74
116
|
/** Image ids to delete from the terminal this frame; clears the pending set. */
|
|
75
117
|
takePurgeIds(): readonly number[];
|
|
76
118
|
/** All image ids believed to be loaded in the terminal store; clears tracking. */
|
|
@@ -135,10 +177,10 @@ export declare class ImageBudget {
|
|
|
135
177
|
/** Whether a frame has image data queued but not yet written to the terminal. */
|
|
136
178
|
hasPendingTransmits(): boolean;
|
|
137
179
|
/**
|
|
138
|
-
* True when the budget has nothing in flight: no live images
|
|
139
|
-
* the last pass, no queued transmits, no pending purges, and no
|
|
140
|
-
* threshold left to apply. A component-scoped frame may skip the
|
|
141
|
-
* pass only then — a partial tree walk would under-count display order.
|
|
180
|
+
* True when the budget has nothing in flight on either surface: no live images
|
|
181
|
+
* observed on the last pass, no queued transmits, no pending purges, and no
|
|
182
|
+
* stricter threshold left to apply. A component-scoped frame may skip the
|
|
183
|
+
* observe pass only then — a partial tree walk would under-count display order.
|
|
142
184
|
*/
|
|
143
185
|
get quiescent(): boolean;
|
|
144
186
|
/** Transmit sequences to write before this frame's placements; clears the queue. */
|
|
@@ -125,6 +125,22 @@ export declare function shouldEnableSynchronizedOutputByDefault(env?: NodeJS.Pro
|
|
|
125
125
|
* `PI_NO_DECCARA` kill switch. Pure helper for tests and `TERMINAL` construction.
|
|
126
126
|
*/
|
|
127
127
|
export declare function detectRectangularSgrSupport(terminalId: TerminalId, env?: NodeJS.ProcessEnv): boolean;
|
|
128
|
+
/**
|
|
129
|
+
* Whether the terminal implements colon-subparameter SGR styled underlines —
|
|
130
|
+
* `CSI 4 : 3 m` (curly) plus `CSI 58` / `CSI 59` underline color — as opposed to
|
|
131
|
+
* only the legacy `CSI 4 m` / `CSI 24 m` on/off underline.
|
|
132
|
+
*
|
|
133
|
+
* This is an underline-style capability, not a color depth, so it is keyed on
|
|
134
|
+
* the detected terminal, never on `TERM`/`COLORTERM`. kitty, Ghostty, WezTerm,
|
|
135
|
+
* and iTerm2 (>= 3.5) implement the full pair. Apple Terminal does NOT: it
|
|
136
|
+
* renders `CSI 4 : 0 m` (the reset half) as a solid black background that
|
|
137
|
+
* persists to end of line, and ignores SGR 58/59 — so it, along with every
|
|
138
|
+
* other unproven terminal, gets the flat underline instead. Disabled under any
|
|
139
|
+
* multiplexer: GNU screen and older tmux drop colon-form SGR, and the outer
|
|
140
|
+
* terminal's id leaks into the session env, so a proven id is not proof the
|
|
141
|
+
* bytes survive — the same reason DECCARA and synchronized output gate on it.
|
|
142
|
+
*/
|
|
143
|
+
export declare function detectStyledUnderlineSupport(terminalId: TerminalId, env?: NodeJS.ProcessEnv): boolean;
|
|
128
144
|
/**
|
|
129
145
|
* Resolve an explicit user override for OSC 8 hyperlinks. Returns `false` for
|
|
130
146
|
* an opt-out, `true` for a force-on, or `null` when the user has expressed no
|
|
@@ -197,6 +213,8 @@ export interface RuntimeTerminal extends TerminalInfo {
|
|
|
197
213
|
supportsScreenToScrollback: boolean;
|
|
198
214
|
/** Whether OSC 66 text sizing is currently enabled. */
|
|
199
215
|
textSizing: boolean;
|
|
216
|
+
/** Whether the terminal implements colon-subparameter styled underlines (curly + colored). */
|
|
217
|
+
styledUnderlines: boolean;
|
|
200
218
|
}
|
|
201
219
|
export declare const TERMINAL: RuntimeTerminal;
|
|
202
220
|
/**
|
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
/** True when this process is running inside a Herdr pane. */
|
|
2
2
|
export declare function isInsideHerdr(env?: NodeJS.ProcessEnv): boolean;
|
|
3
|
-
/**
|
|
3
|
+
/** Terminal multiplexers omp recognizes as owning the screen grid. */
|
|
4
|
+
export type TerminalMultiplexer = "herdr" | "tmux" | "screen" | "zellij" | "cmux" | "wmux";
|
|
5
|
+
/**
|
|
6
|
+
* Classify which terminal multiplexer owns the current screen grid, or `null`
|
|
7
|
+
* for a direct terminal. Single source of truth for both the render-path gate
|
|
8
|
+
* (`isInsideTerminalMultiplexer`) and the debug snapshot label.
|
|
9
|
+
*
|
|
10
|
+
* TMUX/STY/ZELLIJ, Herdr, and the CMUX/WMUX workspace/surface/remote-transport
|
|
11
|
+
* markers are authoritative session signals. TERM can also survive when those
|
|
12
|
+
* are stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Do not
|
|
13
|
+
* use CMUX_SOCKET_PATH / WMUX_CLI / WMUX_PIPE here: they are CLI socket/path
|
|
14
|
+
* overrides and can be set outside a CMUX/WMUX terminal. wmux is a Windows
|
|
15
|
+
* multiplexer (Electron + xterm.js) modeled on cmux/herdr that repaints its
|
|
16
|
+
* pane in place and exports WMUX=1 plus a native WMUX_SURFACE_ID.
|
|
17
|
+
*/
|
|
18
|
+
export declare function classifyTerminalMultiplexer(env?: NodeJS.ProcessEnv): TerminalMultiplexer | null;
|
|
19
|
+
/** True when a terminal multiplexer owns the current screen grid. */
|
|
4
20
|
export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): boolean;
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -142,6 +142,19 @@ export interface Terminal {
|
|
|
142
142
|
* built against older pi-tui versions keep working.
|
|
143
143
|
*/
|
|
144
144
|
readonly pendingOutputBytes?: number;
|
|
145
|
+
/**
|
|
146
|
+
* Whether a pseudoconsole host owns the grid this terminal writes to, so
|
|
147
|
+
* neither the cursor nor the painted rows survive a resize under the
|
|
148
|
+
* application's own model. Measured on Windows conhost: resizing the
|
|
149
|
+
* pseudoconsole makes it re-emit its whole viewport from `CSI H` with
|
|
150
|
+
* absolute addressing while the application writes nothing, and it re-homes
|
|
151
|
+
* the cursor, so a DSR reply after a resize reports column 1 instead of the
|
|
152
|
+
* column the application parked. The renderer's resize anchor recovery needs
|
|
153
|
+
* both properties, so it takes the rebuild path instead when this is set.
|
|
154
|
+
* Optional so custom Terminals built against older pi-tui versions keep
|
|
155
|
+
* working; absent means the terminal itself owns the grid.
|
|
156
|
+
*/
|
|
157
|
+
readonly hostOwnsGridOnResize?: boolean;
|
|
145
158
|
get kittyProtocolActive(): boolean;
|
|
146
159
|
get kittyEnableSequence(): string | null;
|
|
147
160
|
readonly keyboardEnhancementEnterSequence?: string | null;
|
|
@@ -209,8 +222,9 @@ export declare function isConPTYHosted(): boolean;
|
|
|
209
222
|
export interface ProcessTerminalOptions {
|
|
210
223
|
/**
|
|
211
224
|
* Force ConPTY-hosted behavior on or off. Defaults to live detection via
|
|
212
|
-
* {@link isConPTYHosted}. Tests set this so the kitty-flag
|
|
213
|
-
*
|
|
225
|
+
* {@link isConPTYHosted}. Tests set this so the kitty-flag, write-chunking
|
|
226
|
+
* and resize-routing ({@link Terminal.hostOwnsGridOnResize}) paths stay
|
|
227
|
+
* hermetic regardless of the ambient WSL env (`WSL_DISTRO_NAME` /
|
|
214
228
|
* `WSL_INTEROP`) — the suite must behave identically on WSL and on CI.
|
|
215
229
|
*/
|
|
216
230
|
conpty?: boolean;
|
|
@@ -247,6 +261,7 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
247
261
|
write(data: string): void;
|
|
248
262
|
get columns(): number;
|
|
249
263
|
get pendingOutputBytes(): number;
|
|
264
|
+
get hostOwnsGridOnResize(): boolean;
|
|
250
265
|
get rows(): number;
|
|
251
266
|
moveBy(lines: number): void;
|
|
252
267
|
hideCursor(force?: boolean): void;
|
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.2.
|
|
4
|
+
"version": "18.2.2",
|
|
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.2.
|
|
41
|
-
"@oh-my-pi/pi-utils": "18.2.
|
|
40
|
+
"@oh-my-pi/pi-natives": "18.2.2",
|
|
41
|
+
"@oh-my-pi/pi-utils": "18.2.2"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"kitty-vt-wasm": "^0.2.0"
|
package/src/autocomplete.ts
CHANGED
|
@@ -351,7 +351,7 @@ function buildSlashCommandCompletions(
|
|
|
351
351
|
: isSkillCommand
|
|
352
352
|
? Math.max(
|
|
353
353
|
scoreCommandTextMatch(lowerPrefix, name.toLowerCase()),
|
|
354
|
-
|
|
354
|
+
skillBareNameBreakoutTier(lowerPrefix, name.slice(SKILL_NAMESPACE.length).toLowerCase()),
|
|
355
355
|
)
|
|
356
356
|
: scoreCommandTextMatch(lowerPrefix, name.toLowerCase());
|
|
357
357
|
const lowerDesc = staticDesc.toLowerCase();
|
|
@@ -410,29 +410,54 @@ function hasPromptTextBeforeSlash(
|
|
|
410
410
|
|
|
411
411
|
export const SKILL_NAMESPACE = "skill:";
|
|
412
412
|
|
|
413
|
-
/**
|
|
414
|
-
|
|
415
|
-
* names when deciding whether the skill may break out of the collapsed
|
|
416
|
-
* `skill:` group: exact (1000) > prefix (900) > anything weaker (0). Fuzzy
|
|
417
|
-
* hits deliberately map to 0 — a fuzzy skill match is never strong enough to
|
|
418
|
-
* mix skills into the command popup.
|
|
419
|
-
*/
|
|
420
|
-
function skillBreakoutTier(lowerPrefix: string, lowerTarget: string): number {
|
|
413
|
+
/** Exact/leading-prefix tier for ordinary command names and aliases. */
|
|
414
|
+
function commandBreakoutTier(lowerPrefix: string, lowerTarget: string): number {
|
|
421
415
|
if (lowerPrefix === lowerTarget) return 1000;
|
|
422
416
|
if (lowerTarget.startsWith(lowerPrefix)) return 900;
|
|
423
417
|
return 0;
|
|
424
418
|
}
|
|
425
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Match a bare skill name from the beginning of any hyphen-delimited segment.
|
|
422
|
+
* This stays allocation-free on the hot path: it scans segment boundaries
|
|
423
|
+
* in-place and never materializes split/slice arrays.
|
|
424
|
+
*/
|
|
425
|
+
function skillBareNameBreakoutTier(lowerPrefix: string, lowerBareName: string): number {
|
|
426
|
+
if (lowerPrefix.length === 0) return 0;
|
|
427
|
+
if (lowerPrefix === lowerBareName) return 1000;
|
|
428
|
+
if (lowerBareName.startsWith(lowerPrefix)) return 900;
|
|
429
|
+
|
|
430
|
+
let segmentStart = 0;
|
|
431
|
+
while (segmentStart < lowerBareName.length) {
|
|
432
|
+
while (segmentStart < lowerBareName.length && lowerBareName.charCodeAt(segmentStart) !== 45) {
|
|
433
|
+
segmentStart += 1;
|
|
434
|
+
}
|
|
435
|
+
segmentStart += 1;
|
|
436
|
+
if (segmentStart >= lowerBareName.length) break;
|
|
437
|
+
|
|
438
|
+
if (lowerBareName.startsWith(lowerPrefix, segmentStart)) {
|
|
439
|
+
let segmentEnd = segmentStart;
|
|
440
|
+
while (segmentEnd < lowerBareName.length && lowerBareName.charCodeAt(segmentEnd) !== 45) {
|
|
441
|
+
segmentEnd += 1;
|
|
442
|
+
}
|
|
443
|
+
return lowerPrefix.length === segmentEnd - segmentStart ? 1000 : 900;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
449
|
+
|
|
426
450
|
/**
|
|
427
451
|
* Collapse `skill:*` commands into a single `/skill:` namespace row while the
|
|
428
452
|
* typed prefix has not committed to the namespace. A lone group entry (shown
|
|
429
453
|
* only while the prefix is still a prefix of `skill:`) keeps the `/` popup
|
|
430
454
|
* readable. A skill breaks out of the group only when its bare name matches
|
|
431
|
-
* the prefix at
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
* never surface. Accepting the group
|
|
435
|
-
* space so the reopened popup expands to
|
|
455
|
+
* the prefix at the beginning of the name or a hyphen-delimited segment, at a
|
|
456
|
+
* strictly stronger tier than every non-skill command name and alias. Ordinary
|
|
457
|
+
* commands keep exact/leading-prefix tiers only; a tie keeps the popup
|
|
458
|
+
* command-only, and fuzzy-only skill hits never surface. Accepting the group
|
|
459
|
+
* inserts `/skill:` without a trailing space so the reopened popup expands to
|
|
460
|
+
* the individual skills.
|
|
436
461
|
*/
|
|
437
462
|
function collapseSkillNamespace(commands: CommandEntry[], lowerPrefix: string): CommandEntry[] {
|
|
438
463
|
if (lowerPrefix.startsWith(SKILL_NAMESPACE)) return commands;
|
|
@@ -442,9 +467,9 @@ function collapseSkillNamespace(commands: CommandEntry[], lowerPrefix: string):
|
|
|
442
467
|
for (const cmd of commands) {
|
|
443
468
|
const name = getCommandName(cmd);
|
|
444
469
|
if (!name || name.startsWith(SKILL_NAMESPACE)) continue;
|
|
445
|
-
commandTier = Math.max(commandTier,
|
|
470
|
+
commandTier = Math.max(commandTier, commandBreakoutTier(lowerPrefix, name.toLowerCase()));
|
|
446
471
|
for (const alias of getCommandAliases(cmd)) {
|
|
447
|
-
commandTier = Math.max(commandTier,
|
|
472
|
+
commandTier = Math.max(commandTier, commandBreakoutTier(lowerPrefix, alias.toLowerCase()));
|
|
448
473
|
}
|
|
449
474
|
if (commandTier === 1000) break;
|
|
450
475
|
}
|
|
@@ -458,7 +483,7 @@ function collapseSkillNamespace(commands: CommandEntry[], lowerPrefix: string):
|
|
|
458
483
|
skillIcon ??= cmd.icon;
|
|
459
484
|
return (
|
|
460
485
|
!approachesNamespace &&
|
|
461
|
-
|
|
486
|
+
skillBareNameBreakoutTier(lowerPrefix, name.slice(SKILL_NAMESPACE.length).toLowerCase()) > commandTier
|
|
462
487
|
);
|
|
463
488
|
});
|
|
464
489
|
if (skillCount === 0) return commands;
|
|
@@ -478,7 +503,8 @@ function collapseSkillNamespace(commands: CommandEntry[], lowerPrefix: string):
|
|
|
478
503
|
* popup alive through fuzzy name/description hits, so a token only matches as
|
|
479
504
|
* - a prefix of the `skill:` namespace (incl. the bare `/` entry point),
|
|
480
505
|
* - an explicit `skill:…` query (full fuzzy name/description search), or
|
|
481
|
-
* - a prefix of the skill
|
|
506
|
+
* - a prefix at the start of the skill bare name or one of its hyphen-delimited
|
|
507
|
+
* segments (`/hum` → `skill:humanizer`, `/last` → `skill:research-last30days`).
|
|
482
508
|
* Anything else yields no items, letting the caller fall through to path
|
|
483
509
|
* completion or close the popup. Shared with the editor's accept-time
|
|
484
510
|
* staleness guard so Tab/Enter never accepts a skill the refreshed popup
|
|
@@ -491,7 +517,10 @@ export function midPromptSkillTokenMatches(lowerToken: string, name: string, des
|
|
|
491
517
|
if (scoreCommandTextMatch(lowerToken, lowerName) > 0) return true;
|
|
492
518
|
return !!description && scoreCommandTextMatch(lowerToken, description.toLowerCase()) > 0;
|
|
493
519
|
}
|
|
494
|
-
return
|
|
520
|
+
return (
|
|
521
|
+
lowerName.startsWith(SKILL_NAMESPACE) &&
|
|
522
|
+
skillBareNameBreakoutTier(lowerToken, lowerName.slice(SKILL_NAMESPACE.length)) > 0
|
|
523
|
+
);
|
|
495
524
|
}
|
|
496
525
|
|
|
497
526
|
function buildMidPromptSkillCompletions(commands: CommandEntry[], lowerPrefix: string): AutocompleteItem[] {
|
package/src/components/editor.ts
CHANGED
|
@@ -1719,7 +1719,14 @@ export class Editor implements Component, Focusable {
|
|
|
1719
1719
|
} else {
|
|
1720
1720
|
if (selected && this.#autocompleteProvider) {
|
|
1721
1721
|
const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
|
|
1722
|
-
|
|
1722
|
+
// Directory chaining exists so an @ mention can be browsed deeper
|
|
1723
|
+
// without retyping the path. It must not apply to a slash
|
|
1724
|
+
// command's directory argument: there the accepted value is the
|
|
1725
|
+
// whole argument, so chaining reopens the popup on the directory's
|
|
1726
|
+
// children, the command never submits, and every further Enter
|
|
1727
|
+
// descends another level (#12107).
|
|
1728
|
+
const shouldChainDirectoryCompletion =
|
|
1729
|
+
this.#autocompletePrefix.startsWith("@") && isDirectoryCompletionValue(selected.value);
|
|
1723
1730
|
const result = this.#autocompleteProvider.applyCompletion(
|
|
1724
1731
|
this.#state.lines,
|
|
1725
1732
|
this.#state.cursorLine,
|
|
@@ -2498,6 +2505,29 @@ export class Editor implements Component, Focusable {
|
|
|
2498
2505
|
this.#moveToMessageEnd();
|
|
2499
2506
|
}
|
|
2500
2507
|
|
|
2508
|
+
/** The `tui.editor.deleteCharForward` operation, callable by hosts that resolve the chord
|
|
2509
|
+
* themselves rather than redispatching the raw key (see CustomEditor's exit-chord overlap).
|
|
2510
|
+
* Mirrors the transient state the key dispatch tears down before this action so the two
|
|
2511
|
+
* cannot diverge: a pending character jump is cancelled by any other key, and an open
|
|
2512
|
+
* spelling-assist popup is dismissed by anything that is not one of its accept keys (its
|
|
2513
|
+
* debounced refresh skips assist mode, so a surviving list would hang around forever).
|
|
2514
|
+
* While Vim owns the buffer (Normal or Visual) the operation is Vim's `x` — deleting the
|
|
2515
|
+
* selection and returning to Normal in Visual mode, the grapheme under the cursor
|
|
2516
|
+
* otherwise. Only Insert mode and Vim-off editors delete straight through. */
|
|
2517
|
+
deleteCharForward(): void {
|
|
2518
|
+
this.#jumpMode = null;
|
|
2519
|
+
if (this.#autocompleteState === "assist") {
|
|
2520
|
+
this.#cancelAutocomplete();
|
|
2521
|
+
this.onAutocompleteUpdate?.();
|
|
2522
|
+
}
|
|
2523
|
+
const vim = this.#vim;
|
|
2524
|
+
if (vim !== null && vim.mode !== "insert") {
|
|
2525
|
+
this.#runVimKey("x", vim);
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
this.#handleForwardDelete();
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2501
2531
|
/**
|
|
2502
2532
|
* Undo the last meaningful edit while ignoring transient text that is still present at the cursor.
|
|
2503
2533
|
* Used for command-like autocomplete actions whose typed trigger should not count as the edit being undone.
|
|
@@ -2761,6 +2791,10 @@ export class Editor implements Component, Focusable {
|
|
|
2761
2791
|
else if (char === "#") {
|
|
2762
2792
|
this.#tryTriggerAutocomplete();
|
|
2763
2793
|
}
|
|
2794
|
+
// Argument providers may expose candidates only after a separator.
|
|
2795
|
+
else if (char === " " && this.#isInSubmittedSlashCommandContext()) {
|
|
2796
|
+
this.#tryTriggerAutocomplete();
|
|
2797
|
+
}
|
|
2764
2798
|
// Also auto-trigger when typing letters/path chars in a completable context
|
|
2765
2799
|
else if (/[a-zA-Z0-9.\-_/]/.test(char)) {
|
|
2766
2800
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
@@ -3543,6 +3577,13 @@ export class Editor implements Component, Focusable {
|
|
|
3543
3577
|
this.#state.lines.splice(this.#state.cursorLine + 1, 1);
|
|
3544
3578
|
}
|
|
3545
3579
|
|
|
3580
|
+
// Deleting the final grapheme can leave the cursor one past the end of the line, which
|
|
3581
|
+
// Normal mode never allows (it rests *on* a grapheme). Vim's own `x` clamps via
|
|
3582
|
+
// #applyVimCommands; callers that invoke this operation directly — hosts resolving a
|
|
3583
|
+
// chord themselves, or a key bound to deleteCharForward that Vim does not map — get the
|
|
3584
|
+
// same treatment here so the cursor can't sit off the buffer.
|
|
3585
|
+
this.#clampVimCursor();
|
|
3586
|
+
|
|
3546
3587
|
this.#notifyChange();
|
|
3547
3588
|
|
|
3548
3589
|
// Update or re-trigger autocomplete after forward delete
|