@oh-my-pi/pi-tui 18.2.0 → 18.2.1

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,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's bare name (`/hum` → `skill:humanizer`).
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 on-terminal split
66
- * (`#suppressedIds`) keyed by id — order- and partiality-independent.
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 observed on
139
- * the last pass, no queued transmits, no pending purges, and no stricter
140
- * threshold left to apply. A component-scoped frame may skip the observe
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
- /** Detect whether a terminal multiplexer owns the current screen grid. */
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;
@@ -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 and write-chunking
213
- * paths stay hermetic regardless of the ambient WSL env (`WSL_DISTRO_NAME` /
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.0",
4
+ "version": "18.2.1",
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.0",
41
- "@oh-my-pi/pi-utils": "18.2.0"
40
+ "@oh-my-pi/pi-natives": "18.2.1",
41
+ "@oh-my-pi/pi-utils": "18.2.1"
42
42
  },
43
43
  "devDependencies": {
44
44
  "kitty-vt-wasm": "^0.2.0"
@@ -351,7 +351,7 @@ function buildSlashCommandCompletions(
351
351
  : isSkillCommand
352
352
  ? Math.max(
353
353
  scoreCommandTextMatch(lowerPrefix, name.toLowerCase()),
354
- scoreCommandTextMatch(lowerPrefix, name.slice(SKILL_NAMESPACE.length).toLowerCase()),
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
- * Match tier used to compare a skill's bare name against non-skill command
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 a strictly stronger tier than every non-skill command name
432
- * and alias (`/batch` → `skill:batch` while no command prefix-matches
433
- * `batch`); a tie keeps the popup command-only, and fuzzy-only skill hits
434
- * never surface. Accepting the group inserts `/skill:` without a trailing
435
- * space so the reopened popup expands to the individual skills.
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, skillBreakoutTier(lowerPrefix, name.toLowerCase()));
470
+ commandTier = Math.max(commandTier, commandBreakoutTier(lowerPrefix, name.toLowerCase()));
446
471
  for (const alias of getCommandAliases(cmd)) {
447
- commandTier = Math.max(commandTier, skillBreakoutTier(lowerPrefix, alias.toLowerCase()));
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
- skillBreakoutTier(lowerPrefix, name.slice(SKILL_NAMESPACE.length).toLowerCase()) > commandTier
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's bare name (`/hum` → `skill:humanizer`).
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 lowerName.startsWith(SKILL_NAMESPACE) && lowerName.slice(SKILL_NAMESPACE.length).startsWith(lowerToken);
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[] {
@@ -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
- const shouldChainDirectoryCompletion = isDirectoryCompletionValue(selected.value);
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