@sayknow-cli/tui 0.3.6 → 0.3.7

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.
Files changed (35) hide show
  1. package/dist/types/autocomplete.d.ts +82 -0
  2. package/dist/types/bracketed-paste.d.ts +26 -0
  3. package/dist/types/components/box.d.ts +20 -0
  4. package/dist/types/components/cancellable-loader.d.ts +21 -0
  5. package/dist/types/components/editor.d.ts +117 -0
  6. package/dist/types/components/image.d.ts +16 -0
  7. package/dist/types/components/input.d.ts +16 -0
  8. package/dist/types/components/loader.d.ts +14 -0
  9. package/dist/types/components/markdown.d.ts +64 -0
  10. package/dist/types/components/select-list.d.ts +46 -0
  11. package/dist/types/components/settings-list.d.ts +39 -0
  12. package/dist/types/components/spacer.d.ts +11 -0
  13. package/dist/types/components/tab-bar.d.ts +56 -0
  14. package/dist/types/components/text.d.ts +13 -0
  15. package/dist/types/components/truncated-text.d.ts +10 -0
  16. package/dist/types/editor-component.d.ts +36 -0
  17. package/dist/types/fuzzy.d.ts +15 -0
  18. package/dist/types/index.d.ts +26 -0
  19. package/dist/types/keybindings.d.ts +201 -0
  20. package/dist/types/keys.d.ts +208 -0
  21. package/dist/types/kill-ring.d.ts +27 -0
  22. package/dist/types/metrics.d.ts +85 -0
  23. package/dist/types/stdin-buffer.d.ts +50 -0
  24. package/dist/types/symbols.d.ts +23 -0
  25. package/dist/types/terminal-capabilities.d.ts +75 -0
  26. package/dist/types/terminal.d.ts +88 -0
  27. package/dist/types/ttyid.d.ts +9 -0
  28. package/dist/types/tui.d.ts +197 -0
  29. package/dist/types/utils.d.ts +75 -0
  30. package/package.json +9 -8
  31. package/src/components/editor.ts +58 -13
  32. package/src/components/input.ts +2 -1
  33. package/src/components/select-list.ts +8 -1
  34. package/src/terminal.ts +44 -8
  35. package/src/tui.ts +151 -2
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Whether SKC may reprogram the keyboard with enhanced input protocols
3
+ * (the Kitty keyboard protocol and the xterm modifyOtherKeys fallback).
4
+ *
5
+ * Enabled by default. Set `SKC_TUI_KEYBOARD_PROTOCOL=0` to leave the keyboard in
6
+ * its default mode. Some terminals — notably Android Termius — break IME
7
+ * composition (e.g. Korean/Hangul syllable composition) while these enhanced
8
+ * modes are active, committing every intermediate composing jamo/syllable
9
+ * instead of only the final character. Disabling the protocol restores normal
10
+ * IME behavior, matching how other TUIs that leave the keyboard untouched render
11
+ * Korean correctly.
12
+ */
13
+ export declare function keyboardEnhancementEnabled(): boolean;
14
+ /** Error codes for terminal/pipe write failures that should never crash the process. */
15
+ export declare function isBenignTerminalWriteError(err: unknown): boolean;
16
+ /**
17
+ * Emergency terminal restore - call this from signal/crash handlers
18
+ * Resets terminal state without requiring access to the ProcessTerminal instance
19
+ */
20
+ export declare function emergencyTerminalRestore(): void;
21
+ /** Terminal-reported appearance (dark/light mode). */
22
+ export type TerminalAppearance = "dark" | "light";
23
+ export interface Terminal {
24
+ start(onInput: (data: string) => void, onResize: () => void): void;
25
+ stop(): void;
26
+ /**
27
+ * Drain stdin before exiting to prevent Kitty key release events from
28
+ * leaking to the parent shell over slow SSH connections.
29
+ * @param maxMs - Maximum time to drain (default: 1000ms)
30
+ * @param idleMs - Exit early if no input arrives within this time (default: 50ms)
31
+ */
32
+ drainInput(maxMs?: number, idleMs?: number): Promise<void>;
33
+ write(data: string): void;
34
+ get available(): boolean;
35
+ get columns(): number;
36
+ get rows(): number;
37
+ get kittyProtocolActive(): boolean;
38
+ moveBy(lines: number): void;
39
+ hideCursor(): void;
40
+ showCursor(): void;
41
+ clearLine(): void;
42
+ clearFromCursor(): void;
43
+ clearScreen(): void;
44
+ setTitle(title: string): void;
45
+ setProgress(active: boolean): void;
46
+ /**
47
+ * Register a callback for terminal appearance (dark/light) changes.
48
+ * Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
49
+ * Fires when the detected appearance changes, including the initial detection.
50
+ */
51
+ onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
52
+ /** The last detected terminal appearance, or undefined if not yet known. */
53
+ get appearance(): TerminalAppearance | undefined;
54
+ }
55
+ interface TerminalSizeStream {
56
+ columns?: number;
57
+ rows?: number;
58
+ getWindowSize?: () => [number, number] | number[];
59
+ }
60
+ export declare function resolveTerminalColumns(stream?: TerminalSizeStream, envColumns?: string | undefined): number;
61
+ export declare function resolveTerminalRows(stream?: TerminalSizeStream, envRows?: string | undefined): number;
62
+ /**
63
+ * Real terminal using process.stdin/stdout
64
+ */
65
+ export declare class ProcessTerminal implements Terminal {
66
+ #private;
67
+ get kittyProtocolActive(): boolean;
68
+ get appearance(): TerminalAppearance | undefined;
69
+ onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
70
+ start(onInput: (data: string) => void, onResize: () => void): void;
71
+ drainInput(maxMs?: number, idleMs?: number): Promise<void>;
72
+ stop(): void;
73
+ write(data: string): void;
74
+ /** Invoked by the durable module-level stdout write guard (see installStdoutWriteGuard). */
75
+ markStdoutUnavailable(err: unknown): void;
76
+ get available(): boolean;
77
+ get columns(): number;
78
+ get rows(): number;
79
+ moveBy(lines: number): void;
80
+ hideCursor(): void;
81
+ showCursor(): void;
82
+ clearLine(): void;
83
+ clearFromCursor(): void;
84
+ clearScreen(): void;
85
+ setTitle(title: string): void;
86
+ setProgress(active: boolean): void;
87
+ }
88
+ export {};
@@ -0,0 +1,9 @@
1
+ /** Resolve the TTY device path for stdin (fd 0) via POSIX `ttyname(3)`. */
2
+ export declare function getTtyPath(): string | null;
3
+ /**
4
+ * Get a stable identifier for the current terminal.
5
+ * Uses the TTY device path (e.g., /dev/pts/3), falling back to environment
6
+ * variables for terminal multiplexers or terminal emulators.
7
+ * Returns null if no terminal can be identified (e.g., piped input).
8
+ */
9
+ export declare function getTerminalId(): string | null;
@@ -0,0 +1,197 @@
1
+ import type { Terminal } from "./terminal";
2
+ import { visibleWidth } from "./utils";
3
+ type InputListenerResult = {
4
+ consume?: boolean;
5
+ data?: string;
6
+ } | undefined;
7
+ type InputListener = (data: string) => InputListenerResult;
8
+ /**
9
+ * Component interface - all components must implement this
10
+ */
11
+ export interface Component {
12
+ /**
13
+ * Render the component to lines for the given viewport width
14
+ * @param width - Current viewport width
15
+ * @returns Array of strings, each representing a line
16
+ */
17
+ render(width: number): string[];
18
+ /**
19
+ * Optional handler for keyboard input when component has focus
20
+ */
21
+ handleInput?(data: string): void;
22
+ /**
23
+ * If true, component receives key release events (Kitty protocol).
24
+ * Default is false - release events are filtered out.
25
+ */
26
+ wantsKeyRelease?: boolean;
27
+ /**
28
+ * Invalidate any cached rendering state.
29
+ * Called when theme changes or when component needs to re-render from scratch.
30
+ */
31
+ invalidate(): void;
32
+ /**
33
+ * Optional cleanup hook. Called once when the component is permanently
34
+ * removed from the tree via removeChild/clear/dispose. Implementations MUST
35
+ * be idempotent. Components meant to be re-added should be detached, not
36
+ * removed/cleared.
37
+ */
38
+ dispose?(): void;
39
+ }
40
+ /**
41
+ * Interface for components that can receive focus and display a hardware cursor.
42
+ * When focused, the component should emit CURSOR_MARKER at the cursor position
43
+ * in its render output. TUI will find this marker and position the hardware
44
+ * cursor there for proper IME candidate window positioning.
45
+ */
46
+ export interface Focusable {
47
+ /** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */
48
+ focused: boolean;
49
+ }
50
+ /** Type guard to check if a component implements Focusable */
51
+ export declare function isFocusable(component: Component | null): component is Component & Focusable;
52
+ /**
53
+ * Cursor position marker - APC (Application Program Command) sequence.
54
+ * This is a zero-width escape sequence that terminals ignore.
55
+ * Components emit this at the cursor position when focused.
56
+ * TUI finds and strips this marker, then positions the hardware cursor there.
57
+ */
58
+ export declare const CURSOR_MARKER = "\u001B_pi:c\u0007";
59
+ export { visibleWidth };
60
+ /**
61
+ * Anchor position for overlays
62
+ */
63
+ export type OverlayAnchor = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "top-center" | "bottom-center" | "left-center" | "right-center";
64
+ /**
65
+ * Margin configuration for overlays
66
+ */
67
+ export interface OverlayMargin {
68
+ top?: number;
69
+ right?: number;
70
+ bottom?: number;
71
+ left?: number;
72
+ }
73
+ /** Value that can be absolute (number) or percentage (string like "50%") */
74
+ export type SizeValue = number | `${number}%`;
75
+ /**
76
+ * Options for overlay positioning and sizing.
77
+ * Values can be absolute numbers or percentage strings (e.g., "50%").
78
+ */
79
+ export interface OverlayOptions {
80
+ /** Width in columns, or percentage of terminal width (e.g., "50%") */
81
+ width?: SizeValue;
82
+ /** Minimum width in columns */
83
+ minWidth?: number;
84
+ /** Maximum height in rows, or percentage of terminal height (e.g., "50%") */
85
+ maxHeight?: SizeValue;
86
+ /** Anchor point for positioning (default: 'center') */
87
+ anchor?: OverlayAnchor;
88
+ /** Horizontal offset from anchor position (positive = right) */
89
+ offsetX?: number;
90
+ /** Vertical offset from anchor position (positive = down) */
91
+ offsetY?: number;
92
+ /** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */
93
+ row?: SizeValue;
94
+ /** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */
95
+ col?: SizeValue;
96
+ /** Margin from terminal edges. Number applies to all sides. */
97
+ margin?: OverlayMargin | number;
98
+ /**
99
+ * Control overlay visibility based on terminal dimensions.
100
+ * If provided, overlay is only rendered when this returns true.
101
+ * Called each render cycle with current terminal dimensions.
102
+ */
103
+ visible?: (termWidth: number, termHeight: number) => boolean;
104
+ }
105
+ /**
106
+ * Handle returned by showOverlay for controlling the overlay
107
+ */
108
+ export interface OverlayHandle {
109
+ /** Permanently remove the overlay (cannot be shown again) */
110
+ hide(): void;
111
+ /** Temporarily hide or show the overlay */
112
+ setHidden(hidden: boolean): void;
113
+ /** Check if overlay is temporarily hidden */
114
+ isHidden(): boolean;
115
+ }
116
+ /**
117
+ * Container - a component that contains other components
118
+ */
119
+ export declare class Container implements Component {
120
+ #private;
121
+ children: Component[];
122
+ addChild(component: Component): void;
123
+ removeChild(component: Component): void;
124
+ /** Remove a child without disposing it (for detach-then-readd reuse). */
125
+ detachChild(component: Component): void;
126
+ clear(): void;
127
+ /** Remove all children without disposing them (for detach-then-readd reuse). */
128
+ detachAll(): void;
129
+ dispose(): void;
130
+ invalidate(): void;
131
+ render(width: number): string[];
132
+ }
133
+ /**
134
+ * TUI - Main class for managing terminal UI with differential rendering
135
+ */
136
+ export declare class TUI extends Container {
137
+ #private;
138
+ terminal: Terminal;
139
+ /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
140
+ onDebug?: () => void;
141
+ overlayStack: {
142
+ component: Component;
143
+ options?: OverlayOptions;
144
+ preFocus: Component | null;
145
+ hidden: boolean;
146
+ }[];
147
+ constructor(terminal: Terminal, showHardwareCursor?: boolean);
148
+ get fullRedraws(): number;
149
+ getShowHardwareCursor(): boolean;
150
+ setShowHardwareCursor(enabled: boolean): void;
151
+ getClearOnShrink(): boolean;
152
+ /**
153
+ * Set whether to trigger full re-render when content shrinks.
154
+ * When true (default), empty rows are cleared when content shrinks.
155
+ * When false, empty rows remain (reduces redraws on slower terminals).
156
+ */
157
+ setClearOnShrink(enabled: boolean): void;
158
+ setFocus(component: Component | null): void;
159
+ setBottomPinnedComponent(component: Component | null): void;
160
+ scrollViewportPages(direction: -1 | 1): boolean;
161
+ followLiveViewport(): boolean;
162
+ /**
163
+ * Show an overlay component with configurable positioning and sizing.
164
+ * Returns a handle to control the overlay's visibility.
165
+ */
166
+ showOverlay(component: Component, options?: OverlayOptions): OverlayHandle;
167
+ /** Hide the topmost overlay and restore previous focus. */
168
+ hideOverlay(): void;
169
+ /** Check if there are any visible overlays */
170
+ hasOverlay(): boolean;
171
+ invalidate(): void;
172
+ start(): void;
173
+ get terminalAvailable(): boolean;
174
+ addInputListener(listener: InputListener): () => void;
175
+ removeInputListener(listener: InputListener): void;
176
+ stop(): void;
177
+ /**
178
+ * Multiplexer-aware resize render request.
179
+ *
180
+ * A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
181
+ * to -1, which makes `#doRender` treat the frame as a width change and fall into the
182
+ * `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
183
+ * `3J` escape (users navigate scrollback history), so replaying every transcript line
184
+ * piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
185
+ * high speed" resize storm. Here we keep force off in multiplexers so `#doRender`'s
186
+ * height-change branch takes the viewport-only `multiplexerViewportRepaint` path instead.
187
+ * Set `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy forced redraw.
188
+ */
189
+ requestResizeRender(): void;
190
+ requestRender(force?: boolean, source?: string): void;
191
+ getLineRenderCacheStats(): {
192
+ normalizationSize: number;
193
+ truncationSize: number;
194
+ normalizationLimit: number;
195
+ truncationLimit: number;
196
+ };
197
+ }
@@ -0,0 +1,75 @@
1
+ import { Ellipsis, type ExtractSegmentsResult, type SliceResult } from "@sayknow-cli/natives";
2
+ export { Ellipsis } from "@sayknow-cli/natives";
3
+ export { getDefaultTabWidth, getIndentation } from "@sayknow-cli/utils";
4
+ export declare function isPrintableAscii(text: string): boolean;
5
+ export declare function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult;
6
+ export declare function truncateToWidth(text: string, maxWidth: number, ellipsisKind?: Ellipsis | null, pad?: boolean | null): string;
7
+ export declare function wrapTextWithAnsi(text: string, width: number): string[];
8
+ export declare function extractSegments(line: string, beforeEnd: number, afterStart: number, afterLen: number, strictAfter: boolean): ExtractSegmentsResult;
9
+ /**
10
+ * Tab width in columns for `file`, using `process.cwd()` as the project root for relative paths.
11
+ */
12
+ export declare function getIndentationNoescape(file?: string): number;
13
+ export declare function replaceTabs(text: string, file?: string): string;
14
+ /**
15
+ * Returns a string of n spaces. Uses a pre-allocated buffer for efficiency.
16
+ */
17
+ export declare function padding(n: number): string;
18
+ /**
19
+ * Get the shared grapheme segmenter instance.
20
+ */
21
+ export declare function getSegmenter(): Intl.Segmenter;
22
+ export declare function visibleWidthRaw(str: string): number;
23
+ /**
24
+ * Calculate the visible width of a string in terminal columns.
25
+ */
26
+ export declare function visibleWidth(str: string): number;
27
+ /**
28
+ * Normalize text for terminal output without changing logical editor content.
29
+ * Some terminals render canonically decomposed Hangul jamo or precomposed
30
+ * Thai/Lao AM vowels inconsistently during differential repaint. Emit a stable
31
+ * terminal form while keeping the component/source strings unchanged.
32
+ */
33
+ export declare function normalizeTerminalOutput(str: string): string;
34
+ /**
35
+ * Check if a character is whitespace.
36
+ */
37
+ export declare function isWhitespaceChar(char: string): boolean;
38
+ /**
39
+ * Check if a character is punctuation.
40
+ */
41
+ export declare function isPunctuationChar(char: string): boolean;
42
+ export type WordNavKind = "whitespace" | "delimiter" | "cjk" | "word" | "other";
43
+ /**
44
+ * Coarse Unicode-aware character classification for word navigation (Option/Alt + Left/Right).
45
+ * This intentionally avoids language-specific word segmentation for predictability across scripts.
46
+ */
47
+ export declare function getWordNavKind(grapheme: string): WordNavKind;
48
+ export declare function isWordNavJoiner(grapheme: string): boolean;
49
+ /**
50
+ * Move the cursor one "word" to the left using Unicode-aware coarse navigation.
51
+ *
52
+ * Returns a new cursor index in the range [0, text.length].
53
+ */
54
+ export declare function moveWordLeft(text: string, cursor: number): number;
55
+ /**
56
+ * Move the cursor one "word" to the right using Unicode-aware coarse navigation.
57
+ *
58
+ * Returns a new cursor index in the range [0, text.length].
59
+ */
60
+ export declare function moveWordRight(text: string, cursor: number): number;
61
+ /**
62
+ * Apply background color to a line, padding to full width.
63
+ *
64
+ * @param line - Line of text (may contain ANSI codes)
65
+ * @param width - Total width to pad to
66
+ * @param bgFn - Background color function
67
+ * @returns Line with background applied and padded to width
68
+ */
69
+ export declare function applyBackgroundToLine(line: string, width: number, bgFn: (text: string) => string): string;
70
+ /**
71
+ * Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
72
+ *
73
+ * @param strict - If true, exclude wide chars at boundary that would extend past the range
74
+ */
75
+ export declare function sliceByColumn(line: string, startCol: number, length: number, strict?: boolean): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/tui",
4
- "version": "0.3.6",
4
+ "version": "0.3.7",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://github.com/jaybeyond/Sayknow_CLI",
7
7
  "author": "jaybeyond",
@@ -27,7 +27,7 @@
27
27
  "cli"
28
28
  ],
29
29
  "main": "./src/index.ts",
30
- "types": "./src/index.ts",
30
+ "types": "./dist/types/index.d.ts",
31
31
  "scripts": {
32
32
  "check": "biome check . && bun run check:types",
33
33
  "check:types": "tsgo -p tsconfig.json --noEmit",
@@ -38,8 +38,8 @@
38
38
  "fmt": "biome format --write ."
39
39
  },
40
40
  "dependencies": {
41
- "@sayknow-cli/natives": "0.3.6",
42
- "@sayknow-cli/utils": "0.3.6",
41
+ "@sayknow-cli/natives": "0.3.7",
42
+ "@sayknow-cli/utils": "0.3.7",
43
43
  "lru-cache": "11.3.6",
44
44
  "marked": "^18.0.3"
45
45
  },
@@ -53,19 +53,20 @@
53
53
  "files": [
54
54
  "src",
55
55
  "README.md",
56
- "CHANGELOG.md"
56
+ "CHANGELOG.md",
57
+ "dist/types"
57
58
  ],
58
59
  "exports": {
59
60
  ".": {
60
- "types": "./src/index.ts",
61
+ "types": "./dist/types/index.d.ts",
61
62
  "import": "./src/index.ts"
62
63
  },
63
64
  "./*": {
64
- "types": "./src/*.ts",
65
+ "types": "./dist/types/*.d.ts",
65
66
  "import": "./src/*.ts"
66
67
  },
67
68
  "./components/*": {
68
- "types": "./src/components/*.ts",
69
+ "types": "./dist/types/components/*.d.ts",
69
70
  "import": "./src/components/*.ts"
70
71
  },
71
72
  "./*.js": "./src/*.ts"
@@ -450,6 +450,11 @@ export class Editor implements Component, Focusable {
450
450
  onChange?: (text: string) => void;
451
451
  onAutocompleteCancel?: () => void;
452
452
  onTabDeclined?: (text: string) => void;
453
+ /**
454
+ * Called before Tab opens/applies autocomplete. Return true to consume Tab
455
+ * for app-level behavior (for example, queueing a draft while a turn runs).
456
+ */
457
+ onTab?: (text: string) => boolean | undefined;
453
458
  disableSubmit: boolean = false;
454
459
 
455
460
  // Custom top border (for status line integration)
@@ -1080,6 +1085,10 @@ export class Editor implements Component, Focusable {
1080
1085
  return;
1081
1086
  }
1082
1087
 
1088
+ if (kb.matches(data, "tui.input.tab") && this.onTab?.(this.getText())) {
1089
+ return;
1090
+ }
1091
+
1083
1092
  // Handle autocomplete special keys first (but don't block other input)
1084
1093
  if (this.#autocompleteState && this.#autocompleteList) {
1085
1094
  // Escape - cancel autocomplete
@@ -1147,7 +1156,7 @@ export class Editor implements Component, Focusable {
1147
1156
  // Check for stale autocomplete state due to debounce
1148
1157
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1149
1158
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1150
- if (currentTextBeforeCursor !== this.#autocompletePrefix) {
1159
+ if (!currentTextBeforeCursor.endsWith(this.#autocompletePrefix)) {
1151
1160
  // Autocomplete is stale - cancel and fall through to normal submission
1152
1161
  this.#cancelAutocomplete();
1153
1162
  } else {
@@ -1285,8 +1294,8 @@ export class Editor implements Component, Focusable {
1285
1294
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1286
1295
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1287
1296
  if (
1288
- textBeforeCursor.startsWith("/") &&
1289
- this.#isInSubmittedSlashCommandContext() &&
1297
+ (this.#isInSubmittedSlashCommandContext() ||
1298
+ this.#getSlashTokenBeforeCursor()?.startsWith("/skill") === true) &&
1290
1299
  this.#autocompleteProvider?.trySyncSlashCompletion
1291
1300
  ) {
1292
1301
  const syncResult = this.#autocompleteProvider.trySyncSlashCompletion(textBeforeCursor);
@@ -1509,10 +1518,34 @@ export class Editor implements Component, Focusable {
1509
1518
  }
1510
1519
 
1511
1520
  if (hasCursorInChunk) {
1521
+ let displayChunkText = chunk.text;
1522
+ let displayCursorPos = adjustedCursorPos;
1523
+ if (displayCursorPos > displayChunkText.length) {
1524
+ let hiddenWhitespaceWidth = displayCursorPos - displayChunkText.length;
1525
+ const displayChunkWidth = visibleWidth(displayChunkText);
1526
+ if (displayChunkWidth + hiddenWhitespaceWidth <= contentWidth) {
1527
+ displayChunkText += padding(hiddenWhitespaceWidth);
1528
+ } else {
1529
+ layoutLines.push({
1530
+ text: displayChunkText,
1531
+ hasCursor: false,
1532
+ });
1533
+ hiddenWhitespaceWidth -= Math.max(0, contentWidth - displayChunkWidth);
1534
+ while (hiddenWhitespaceWidth > contentWidth) {
1535
+ layoutLines.push({
1536
+ text: padding(contentWidth),
1537
+ hasCursor: false,
1538
+ });
1539
+ hiddenWhitespaceWidth -= contentWidth;
1540
+ }
1541
+ displayChunkText = padding(hiddenWhitespaceWidth);
1542
+ displayCursorPos = hiddenWhitespaceWidth;
1543
+ }
1544
+ }
1512
1545
  layoutLines.push({
1513
- text: chunk.text,
1546
+ text: displayChunkText,
1514
1547
  hasCursor: true,
1515
- cursorPos: adjustedCursorPos,
1548
+ cursorPos: displayCursorPos,
1516
1549
  });
1517
1550
  } else {
1518
1551
  layoutLines.push({
@@ -1699,7 +1732,8 @@ export class Editor implements Component, Focusable {
1699
1732
 
1700
1733
  // Check if we should trigger or update autocomplete
1701
1734
  if (!this.#autocompleteState) {
1702
- // Auto-trigger for "/" at the start of a line (slash commands)
1735
+ // Auto-trigger for "/" at the start of a submitted command.
1736
+ // Inline skill autocomplete starts after the token becomes "/skill...".
1703
1737
  if (char === "/" && this.#isAtStartOfSubmittedMessage()) {
1704
1738
  this.#tryTriggerAutocomplete();
1705
1739
  }
@@ -1721,8 +1755,8 @@ export class Editor implements Component, Focusable {
1721
1755
  else if (/[a-zA-Z0-9.\-_/]/.test(char)) {
1722
1756
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1723
1757
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1724
- // Check if we're in a slash command (with or without space for arguments)
1725
- if (this.#isInSubmittedSlashCommandContext()) {
1758
+ // Check if we're in a slash command or inline slash token
1759
+ if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
1726
1760
  this.#tryTriggerAutocomplete();
1727
1761
  }
1728
1762
  // Check if we're in an @ file reference context
@@ -1917,8 +1951,8 @@ export class Editor implements Component, Focusable {
1917
1951
  // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
1918
1952
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1919
1953
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1920
- // Slash command context
1921
- if (this.#isInSubmittedSlashCommandContext()) {
1954
+ // Slash command or inline slash token context
1955
+ if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
1922
1956
  this.#tryTriggerAutocomplete();
1923
1957
  }
1924
1958
  // @ file reference context
@@ -2074,7 +2108,7 @@ export class Editor implements Component, Focusable {
2074
2108
  } else {
2075
2109
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2076
2110
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2077
- if (this.#isInSubmittedSlashCommandContext()) {
2111
+ if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
2078
2112
  this.#tryTriggerAutocomplete();
2079
2113
  } else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
2080
2114
  this.#tryTriggerAutocomplete();
@@ -2388,8 +2422,8 @@ export class Editor implements Component, Focusable {
2388
2422
  } else {
2389
2423
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2390
2424
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2391
- // Slash command context
2392
- if (this.#isInSubmittedSlashCommandContext()) {
2425
+ // Slash command or inline slash token context
2426
+ if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
2393
2427
  this.#tryTriggerAutocomplete();
2394
2428
  }
2395
2429
  // @ file reference context
@@ -2618,6 +2652,17 @@ export class Editor implements Component, Focusable {
2618
2652
  return this.#hasOnlyWhitespaceBeforeCursorLine() && beforeCursor.trimStart().startsWith("/");
2619
2653
  }
2620
2654
 
2655
+ #getSlashTokenBeforeCursor(): string | null {
2656
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2657
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2658
+ const match = beforeCursor.match(/(?:^|\s)(\/[^\s]*)$/);
2659
+ return match?.[1] ?? null;
2660
+ }
2661
+
2662
+ #isInSlashTokenContext(): boolean {
2663
+ return this.#getSlashTokenBeforeCursor()?.startsWith("/skill") === true;
2664
+ }
2665
+
2621
2666
  #isSlashCommandNameAutocompleteSelection(): boolean {
2622
2667
  if (this.#autocompleteState !== "regular") {
2623
2668
  return false;
@@ -58,7 +58,8 @@ export class Input implements Component, Focusable {
58
58
  setValue(value: string): void {
59
59
  const normalized = value.normalize("NFC");
60
60
  this.#value = normalized;
61
- this.#cursor = Math.min(this.#cursor, normalized.length);
61
+ this.#cursor = normalized.length;
62
+ this.#lastAction = null;
62
63
  }
63
64
 
64
65
  handleInput(data: string): void {
@@ -117,8 +117,15 @@ export class SelectList implements Component {
117
117
  }
118
118
 
119
119
  handleInput(keyData: string): void {
120
- if (this.#filteredItems.length === 0) return;
121
120
  const kb = getKeybindings();
121
+ if (this.#filteredItems.length === 0) {
122
+ if (kb.matches(keyData, "tui.select.cancel")) {
123
+ if (this.onCancel) {
124
+ this.onCancel();
125
+ }
126
+ }
127
+ return;
128
+ }
122
129
  // Up arrow - wrap to bottom when at top
123
130
  if (kb.matches(keyData, "tui.select.up")) {
124
131
  this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1;