@nghyane/arcane-tui 0.1.0

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.
@@ -0,0 +1,61 @@
1
+ import type { Component } from "../tui";
2
+ import { padding, truncateToWidth } from "../utils";
3
+
4
+ /**
5
+ * Text component that truncates to fit viewport width
6
+ */
7
+ export class TruncatedText implements Component {
8
+ #text: string;
9
+ #paddingX: number;
10
+ #paddingY: number;
11
+
12
+ constructor(text: string, paddingX: number = 0, paddingY: number = 0) {
13
+ this.#text = text;
14
+ this.#paddingX = paddingX;
15
+ this.#paddingY = paddingY;
16
+ }
17
+
18
+ invalidate(): void {
19
+ // No cached state to invalidate currently
20
+ }
21
+
22
+ render(width: number): string[] {
23
+ const result: string[] = [];
24
+
25
+ // Empty line padded to width
26
+ const emptyLine = padding(width);
27
+
28
+ // Add vertical padding above
29
+ for (let i = 0; i < this.#paddingY; i++) {
30
+ result.push(emptyLine);
31
+ }
32
+
33
+ // Calculate available width after horizontal padding
34
+ const availableWidth = Math.max(1, width - this.#paddingX * 2);
35
+
36
+ // Take only the first line (stop at newline)
37
+ let singleLineText = this.#text;
38
+ const newlineIndex = this.#text.indexOf("\n");
39
+ if (newlineIndex !== -1) {
40
+ singleLineText = this.#text.substring(0, newlineIndex);
41
+ }
42
+
43
+ // Truncate text if needed (accounting for ANSI codes)
44
+ const displayText = truncateToWidth(singleLineText, availableWidth);
45
+
46
+ // Add horizontal padding
47
+ const leftPadding = padding(this.#paddingX);
48
+ const rightPadding = padding(this.#paddingX);
49
+ const lineWithPadding = leftPadding + displayText + rightPadding;
50
+
51
+ // Don't pad to full width - avoids trailing spaces when copying
52
+ result.push(lineWithPadding);
53
+
54
+ // Add vertical padding below
55
+ for (let i = 0; i < this.#paddingY; i++) {
56
+ result.push(emptyLine);
57
+ }
58
+
59
+ return result;
60
+ }
61
+ }
@@ -0,0 +1,71 @@
1
+ import type { AutocompleteProvider } from "./autocomplete";
2
+ import type { Component } from "./tui";
3
+
4
+ /**
5
+ * Interface for custom editor components.
6
+ *
7
+ * This allows extensions to provide their own editor implementation
8
+ * (e.g., vim mode, emacs mode, custom keybindings) while maintaining
9
+ * compatibility with the core application.
10
+ */
11
+ export interface EditorComponent extends Component {
12
+ // =========================================================================
13
+ // Core text access (required)
14
+ // =========================================================================
15
+
16
+ /** Get the current text content */
17
+ getText(): string;
18
+
19
+ /** Set the text content */
20
+ setText(text: string): void;
21
+
22
+ /** Handle raw terminal input (key presses, paste sequences, etc.) */
23
+ handleInput(data: string): void;
24
+
25
+ // =========================================================================
26
+ // Callbacks (required)
27
+ // =========================================================================
28
+
29
+ /** Called when user submits (e.g., Enter key) */
30
+ onSubmit?: (text: string) => void;
31
+
32
+ /** Called when text changes */
33
+ onChange?: (text: string) => void;
34
+
35
+ // =========================================================================
36
+ // History support (optional)
37
+ // =========================================================================
38
+
39
+ /** Add text to history for up/down navigation */
40
+ addToHistory?(text: string): void;
41
+
42
+ // =========================================================================
43
+ // Advanced text manipulation (optional)
44
+ // =========================================================================
45
+
46
+ /** Insert text at current cursor position */
47
+ insertTextAtCursor?(text: string): void;
48
+
49
+ /**
50
+ * Get text with any markers expanded (e.g., paste markers).
51
+ * Falls back to getText() if not implemented.
52
+ */
53
+ getExpandedText?(): string;
54
+
55
+ // =========================================================================
56
+ // Autocomplete support (optional)
57
+ // =========================================================================
58
+
59
+ /** Set the autocomplete provider */
60
+ setAutocompleteProvider?(provider: AutocompleteProvider): void;
61
+
62
+ // =========================================================================
63
+ // Appearance (optional)
64
+ // =========================================================================
65
+
66
+ /** Border color function */
67
+ borderColor?: (str: string) => string;
68
+
69
+ /** Set horizontal padding */
70
+ setPaddingX?(padding: number): void;
71
+ }
package/src/fuzzy.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Fuzzy matching utilities.
3
+ * Matches if all query characters appear in order (not necessarily consecutive).
4
+ * Lower score = better match.
5
+ */
6
+
7
+ export interface FuzzyMatch {
8
+ matches: boolean;
9
+ score: number;
10
+ }
11
+
12
+ const ALPHANUMERIC_SWAP_PENALTY = 5;
13
+
14
+ function scoreMatch(queryLower: string, textLower: string): FuzzyMatch {
15
+ if (queryLower.length === 0) {
16
+ return { matches: true, score: 0 };
17
+ }
18
+
19
+ if (queryLower.length > textLower.length) {
20
+ return { matches: false, score: 0 };
21
+ }
22
+
23
+ let queryIndex = 0;
24
+ let score = 0;
25
+ let lastMatchIndex = -1;
26
+ let consecutiveMatches = 0;
27
+
28
+ for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
29
+ if (textLower[i] === queryLower[queryIndex]) {
30
+ const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
31
+
32
+ // Reward consecutive matches
33
+ if (lastMatchIndex === i - 1) {
34
+ consecutiveMatches++;
35
+ score -= consecutiveMatches * 5;
36
+ } else {
37
+ consecutiveMatches = 0;
38
+ // Penalize gaps
39
+ if (lastMatchIndex >= 0) {
40
+ score += (i - lastMatchIndex - 1) * 2;
41
+ }
42
+ }
43
+
44
+ // Reward word boundary matches
45
+ if (isWordBoundary) {
46
+ score -= 10;
47
+ }
48
+
49
+ // Slight penalty for later matches
50
+ score += i * 0.1;
51
+
52
+ lastMatchIndex = i;
53
+ queryIndex++;
54
+ }
55
+ }
56
+
57
+ if (queryIndex < queryLower.length) {
58
+ return { matches: false, score: 0 };
59
+ }
60
+
61
+ return { matches: true, score };
62
+ }
63
+
64
+ function buildAlphanumericSwapQueries(queryLower: string): string[] {
65
+ const variants = new Set<string>();
66
+ for (let i = 0; i < queryLower.length - 1; i++) {
67
+ const current = queryLower[i];
68
+ const next = queryLower[i + 1];
69
+ const isAlphaNumSwap =
70
+ (current && /[a-z]/.test(current) && next && /\d/.test(next)) ||
71
+ (current && /\d/.test(current) && next && /[a-z]/.test(next));
72
+ if (!isAlphaNumSwap) continue;
73
+ const swapped = queryLower.slice(0, i) + next + current + queryLower.slice(i + 2);
74
+ variants.add(swapped);
75
+ }
76
+ return [...variants];
77
+ }
78
+
79
+ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
80
+ const queryLower = query.toLowerCase();
81
+ const textLower = text.toLowerCase();
82
+
83
+ const direct = scoreMatch(queryLower, textLower);
84
+ if (direct.matches) {
85
+ return direct;
86
+ }
87
+
88
+ let bestSwap: FuzzyMatch | null = null;
89
+ for (const variant of buildAlphanumericSwapQueries(queryLower)) {
90
+ const match = scoreMatch(variant, textLower);
91
+ if (!match.matches) continue;
92
+ const score = match.score + ALPHANUMERIC_SWAP_PENALTY;
93
+ if (!bestSwap || score < bestSwap.score) {
94
+ bestSwap = { matches: true, score };
95
+ }
96
+ }
97
+
98
+ return bestSwap ?? direct;
99
+ }
100
+
101
+ /**
102
+ * Filter and sort items by fuzzy match quality (best matches first).
103
+ * Supports space-separated tokens: all tokens must match.
104
+ */
105
+ export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
106
+ if (!query.trim()) {
107
+ return items;
108
+ }
109
+
110
+ const tokens = query
111
+ .trim()
112
+ .split(/\s+/)
113
+ .filter(t => t.length > 0);
114
+
115
+ if (tokens.length === 0) {
116
+ return items;
117
+ }
118
+
119
+ const results: { item: T; totalScore: number }[] = [];
120
+
121
+ for (const item of items) {
122
+ const text = getText(item);
123
+ let totalScore = 0;
124
+ let allMatch = true;
125
+
126
+ for (const token of tokens) {
127
+ const match = fuzzyMatch(token, text);
128
+ if (match.matches) {
129
+ totalScore += match.score;
130
+ } else {
131
+ allMatch = false;
132
+ break;
133
+ }
134
+ }
135
+
136
+ if (allMatch) {
137
+ results.push({ item, totalScore });
138
+ }
139
+ }
140
+
141
+ results.sort((a, b) => a.totalScore - b.totalScore);
142
+ return results.map(r => r.item);
143
+ }
package/src/index.ts ADDED
@@ -0,0 +1,69 @@
1
+ // Core TUI interfaces and classes
2
+
3
+ // Autocomplete support
4
+ export {
5
+ type AutocompleteItem,
6
+ type AutocompleteProvider,
7
+ CombinedAutocompleteProvider,
8
+ type SlashCommand,
9
+ } from "./autocomplete";
10
+ // Cell-based buffer rendering
11
+ export * from "./buffer/index";
12
+ // Components
13
+ export { Box } from "./components/box";
14
+ export { CancellableLoader } from "./components/cancellable-loader";
15
+ export { Editor, type EditorTheme, type EditorTopBorder } from "./components/editor";
16
+ export { Image, type ImageOptions, type ImageTheme } from "./components/image";
17
+ export { Input } from "./components/input";
18
+ export { Loader } from "./components/loader";
19
+ export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown";
20
+ export { type SelectItem, SelectList, type SelectListTheme } from "./components/select-list";
21
+ export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list";
22
+ export { Spacer } from "./components/spacer";
23
+ export { type Tab, TabBar, type TabBarTheme } from "./components/tab-bar";
24
+ export { Text } from "./components/text";
25
+ export { TruncatedText } from "./components/truncated-text";
26
+ // Editor component interface (for custom editors)
27
+ export type { EditorComponent } from "./editor-component";
28
+ // Fuzzy matching
29
+ export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy";
30
+ // Keybindings
31
+ export {
32
+ DEFAULT_EDITOR_KEYBINDINGS,
33
+ type EditorAction,
34
+ type EditorKeybindingsConfig,
35
+ EditorKeybindingsManager,
36
+ getEditorKeybindings,
37
+ setEditorKeybindings,
38
+ } from "./keybindings";
39
+ // Kitty keyboard protocol helpers
40
+ export {
41
+ isKeyRelease,
42
+ isKeyRepeat,
43
+ isKittyProtocolActive,
44
+ type KeyId,
45
+ matchesKey,
46
+ parseKey,
47
+ parseKittySequence,
48
+ setKittyProtocolActive,
49
+ } from "./keys";
50
+ // Mermaid diagram support
51
+ export {
52
+ extractMermaidBlocks,
53
+ type MermaidImage,
54
+ type MermaidRenderOptions,
55
+ prerenderMermaidBlocks,
56
+ renderMermaidToPng,
57
+ } from "./mermaid";
58
+ // Input buffering for batch splitting
59
+ export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer";
60
+ export type { BoxSymbols, SymbolTheme } from "./symbols";
61
+ // Terminal interface and implementations
62
+ export { emergencyTerminalRestore, ProcessTerminal, type Terminal } from "./terminal";
63
+ // Terminal image support
64
+ export * from "./terminal-capabilities";
65
+ // TTY ID
66
+ export { getTerminalId, getTtyPath } from "./ttyid";
67
+ export { type Component, Container, type OverlayHandle, type SizeValue, TUI } from "./tui";
68
+ // Utilities
69
+ export { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils";
@@ -0,0 +1,197 @@
1
+ import { type KeyId, matchesKey, parseKey } from "./keys";
2
+
3
+ /**
4
+ * Editor actions that can be bound to keys.
5
+ */
6
+ export type EditorAction =
7
+ // Cursor movement
8
+ | "cursorUp"
9
+ | "cursorDown"
10
+ | "cursorLeft"
11
+ | "cursorRight"
12
+ | "cursorWordLeft"
13
+ | "cursorWordRight"
14
+ | "cursorLineStart"
15
+ | "cursorLineEnd"
16
+ | "jumpForward"
17
+ | "jumpBackward"
18
+ // Deletion
19
+ | "deleteCharBackward"
20
+ | "deleteCharForward"
21
+ | "deleteWordBackward"
22
+ | "deleteWordForward"
23
+ | "deleteToLineStart"
24
+ | "deleteToLineEnd"
25
+ // Text input
26
+ | "newLine"
27
+ | "submit"
28
+ | "tab"
29
+ // Selection/autocomplete
30
+ | "selectUp"
31
+ | "selectDown"
32
+ | "selectPageUp"
33
+ | "selectPageDown"
34
+ | "selectConfirm"
35
+ | "selectCancel"
36
+ // Clipboard
37
+ | "copy"
38
+ // Kill ring / undo
39
+ | "undo"
40
+ | "yank"
41
+ | "yankPop";
42
+
43
+ // Re-export KeyId from keys.ts
44
+ export type { KeyId };
45
+
46
+ /**
47
+ * Editor keybindings configuration.
48
+ */
49
+ export type EditorKeybindingsConfig = {
50
+ [K in EditorAction]?: KeyId | KeyId[];
51
+ };
52
+
53
+ /**
54
+ * Default editor keybindings.
55
+ */
56
+ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
57
+ // Cursor movement
58
+ cursorUp: "up",
59
+ cursorDown: "down",
60
+ cursorLeft: ["left", "ctrl+b"],
61
+ cursorRight: ["right", "ctrl+f"],
62
+ cursorWordLeft: ["alt+left", "ctrl+left", "alt+b"],
63
+ cursorWordRight: ["alt+right", "ctrl+right", "alt+f"],
64
+ cursorLineStart: ["home", "ctrl+a"],
65
+ cursorLineEnd: ["end", "ctrl+e"],
66
+ jumpForward: "ctrl+]",
67
+ jumpBackward: "ctrl+alt+]",
68
+ // Deletion
69
+ deleteCharBackward: "backspace",
70
+ deleteCharForward: ["delete", "ctrl+d"],
71
+ deleteWordBackward: ["ctrl+w", "alt+backspace", "ctrl+backspace"],
72
+ deleteWordForward: ["alt+delete", "alt+d"],
73
+ deleteToLineStart: "ctrl+u",
74
+ deleteToLineEnd: "ctrl+k",
75
+ // Text input
76
+ newLine: "shift+enter",
77
+ submit: "enter",
78
+ tab: "tab",
79
+ // Selection/autocomplete
80
+ selectUp: "up",
81
+ selectDown: "down",
82
+ selectPageUp: "pageUp",
83
+ selectPageDown: "pageDown",
84
+ selectConfirm: "enter",
85
+ selectCancel: ["escape", "ctrl+c"],
86
+ // Clipboard
87
+ copy: "ctrl+c",
88
+ // Kill ring / undo
89
+ undo: "ctrl+-",
90
+ yank: "ctrl+y",
91
+ yankPop: "alt+y",
92
+ };
93
+
94
+ const SHIFTED_SYMBOL_KEYS = new Set<string>([
95
+ "!",
96
+ "@",
97
+ "#",
98
+ "$",
99
+ "%",
100
+ "^",
101
+ "&",
102
+ "*",
103
+ "(",
104
+ ")",
105
+ "_",
106
+ "+",
107
+ "{",
108
+ "}",
109
+ "|",
110
+ ":",
111
+ "<",
112
+ ">",
113
+ "?",
114
+ "~",
115
+ ]);
116
+
117
+ const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId;
118
+
119
+ /**
120
+ * Manages keybindings for the editor.
121
+ */
122
+ export class EditorKeybindingsManager {
123
+ #actionToKeys: Map<EditorAction, KeyId[]>;
124
+
125
+ constructor(config: EditorKeybindingsConfig = {}) {
126
+ this.#actionToKeys = new Map();
127
+ this.#buildMaps(config);
128
+ }
129
+
130
+ #buildMaps(config: EditorKeybindingsConfig): void {
131
+ this.#actionToKeys.clear();
132
+
133
+ // Start with defaults
134
+ for (const [action, keys] of Object.entries(DEFAULT_EDITOR_KEYBINDINGS)) {
135
+ const keyArray = Array.isArray(keys) ? keys : [keys];
136
+ this.#actionToKeys.set(
137
+ action as EditorAction,
138
+ keyArray.map(key => normalizeKeyId(key as KeyId)),
139
+ );
140
+ }
141
+
142
+ // Override with user config
143
+ for (const [action, keys] of Object.entries(config)) {
144
+ if (keys === undefined) continue;
145
+ const keyArray = Array.isArray(keys) ? keys : [keys];
146
+ this.#actionToKeys.set(
147
+ action as EditorAction,
148
+ keyArray.map(key => normalizeKeyId(key as KeyId)),
149
+ );
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Check if input matches a specific action.
155
+ */
156
+ matches(data: string, action: EditorAction): boolean {
157
+ const keys = this.#actionToKeys.get(action);
158
+ if (!keys) return false;
159
+ for (const key of keys) {
160
+ if (matchesKey(data, key)) return true;
161
+ }
162
+
163
+ const parsed = parseKey(data);
164
+ if (!parsed || !parsed.startsWith("shift+")) return false;
165
+ const keyName = parsed.slice("shift+".length);
166
+ if (!SHIFTED_SYMBOL_KEYS.has(keyName)) return false;
167
+ return keys.includes(keyName as KeyId);
168
+ }
169
+
170
+ /**
171
+ * Get keys bound to an action.
172
+ */
173
+ getKeys(action: EditorAction): KeyId[] {
174
+ return this.#actionToKeys.get(action) ?? [];
175
+ }
176
+
177
+ /**
178
+ * Update configuration.
179
+ */
180
+ setConfig(config: EditorKeybindingsConfig): void {
181
+ this.#buildMaps(config);
182
+ }
183
+ }
184
+
185
+ // Global instance
186
+ let globalEditorKeybindings: EditorKeybindingsManager | null = null;
187
+
188
+ export function getEditorKeybindings(): EditorKeybindingsManager {
189
+ if (!globalEditorKeybindings) {
190
+ globalEditorKeybindings = new EditorKeybindingsManager();
191
+ }
192
+ return globalEditorKeybindings;
193
+ }
194
+
195
+ export function setEditorKeybindings(manager: EditorKeybindingsManager): void {
196
+ globalEditorKeybindings = manager;
197
+ }