@brimveyn/aimux 1.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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/package.json +79 -0
  4. package/src/app-runtime/backend-attach-runtime.ts +135 -0
  5. package/src/app-runtime/backend-runtime-events.ts +78 -0
  6. package/src/app-runtime/click-selection-resolver.ts +130 -0
  7. package/src/app-runtime/pty-write.ts +32 -0
  8. package/src/app-runtime/render-invalidation.ts +20 -0
  9. package/src/app-runtime/selection-clipboard.ts +69 -0
  10. package/src/app-runtime/selection-scroll.ts +143 -0
  11. package/src/app-runtime/session-actions.ts +170 -0
  12. package/src/app-runtime/side-effects.ts +434 -0
  13. package/src/app-runtime/snippet-actions.ts +90 -0
  14. package/src/app-runtime/split-drag-controller.ts +15 -0
  15. package/src/app-runtime/tab-runtime-timeouts.ts +86 -0
  16. package/src/app-runtime/terminal-mouse-adapter.ts +31 -0
  17. package/src/app-runtime/use-backend-runtime.ts +99 -0
  18. package/src/app-runtime/use-directory-search.ts +52 -0
  19. package/src/app-runtime/use-mouse-handlers.ts +183 -0
  20. package/src/app-runtime/use-renderer-bindings.ts +163 -0
  21. package/src/app-runtime/use-terminal-resize.ts +141 -0
  22. package/src/app-runtime/use-workspace-autosave.ts +39 -0
  23. package/src/app.tsx +247 -0
  24. package/src/config/loader.ts +36 -0
  25. package/src/config.ts +146 -0
  26. package/src/daemon/daemon.ts +236 -0
  27. package/src/daemon/runtime-paths.ts +69 -0
  28. package/src/daemon/session-manager.ts +109 -0
  29. package/src/daemon/session-registry.ts +207 -0
  30. package/src/debug/input-log.ts +29 -0
  31. package/src/doctor.ts +106 -0
  32. package/src/git/git-poller.ts +49 -0
  33. package/src/git/git-status.ts +183 -0
  34. package/src/index.tsx +56 -0
  35. package/src/input/keymap/build-handlers.ts +34 -0
  36. package/src/input/keymap/key-chord.ts +201 -0
  37. package/src/input/keymap/keymap-mode-handler.ts +88 -0
  38. package/src/input/keymap/sequence-resolver.ts +117 -0
  39. package/src/input/keymap/trie.ts +103 -0
  40. package/src/input/modes/bridge.ts +58 -0
  41. package/src/input/modes/handlers/index.ts +18 -0
  42. package/src/input/modes/handlers/shared.ts +70 -0
  43. package/src/input/modes/registry.ts +34 -0
  44. package/src/input/modes/transitions.ts +37 -0
  45. package/src/input/modes/types.ts +63 -0
  46. package/src/input/mouse-forwarding.ts +98 -0
  47. package/src/input/multi-click-detector.ts +55 -0
  48. package/src/input/paste.ts +12 -0
  49. package/src/input/raw-input-handler.ts +191 -0
  50. package/src/input/terminal-text-extraction.ts +78 -0
  51. package/src/ipc/protocol.ts +341 -0
  52. package/src/platform/clipboard.ts +13 -0
  53. package/src/platform/daemon-control.ts +62 -0
  54. package/src/platform/id.ts +7 -0
  55. package/src/platform/project-search.ts +75 -0
  56. package/src/pty/command-registry.ts +69 -0
  57. package/src/pty/pty-manager.ts +268 -0
  58. package/src/pty/terminal-snapshot.ts +210 -0
  59. package/src/restart-daemon.ts +28 -0
  60. package/src/session-backend/bootstrap.ts +107 -0
  61. package/src/session-backend/local-session-backend.ts +164 -0
  62. package/src/session-backend/remote-session-backend.ts +373 -0
  63. package/src/session-backend/types.ts +47 -0
  64. package/src/state/app-store.ts +19 -0
  65. package/src/state/layout-resize.ts +56 -0
  66. package/src/state/layout-tree.ts +323 -0
  67. package/src/state/reducers/git-panel-state.ts +102 -0
  68. package/src/state/reducers/modal-state.ts +358 -0
  69. package/src/state/reducers/session-state.ts +86 -0
  70. package/src/state/reducers/tab-state.ts +531 -0
  71. package/src/state/reducers/ui-state.ts +29 -0
  72. package/src/state/selectors.ts +30 -0
  73. package/src/state/session-catalog.ts +96 -0
  74. package/src/state/session-persistence.ts +210 -0
  75. package/src/state/snippet-catalog.ts +90 -0
  76. package/src/state/store.ts +93 -0
  77. package/src/state/terminal-modes.ts +11 -0
  78. package/src/state/types.ts +367 -0
  79. package/src/state/validation.ts +154 -0
  80. package/src/state/workspace-save.ts +33 -0
  81. package/src/ui/components/create-session-modal.tsx +100 -0
  82. package/src/ui/components/git-panel.tsx +200 -0
  83. package/src/ui/components/help-modal.tsx +79 -0
  84. package/src/ui/components/input-field.tsx +18 -0
  85. package/src/ui/components/list-item.tsx +30 -0
  86. package/src/ui/components/modal-filter-bar.tsx +18 -0
  87. package/src/ui/components/modal-shell.tsx +47 -0
  88. package/src/ui/components/new-tab-modal.tsx +52 -0
  89. package/src/ui/components/pending-chord-indicator.tsx +41 -0
  90. package/src/ui/components/session-name-modal.tsx +15 -0
  91. package/src/ui/components/session-picker-modal.tsx +93 -0
  92. package/src/ui/components/sidebar-group-metadata.ts +44 -0
  93. package/src/ui/components/sidebar-scroll.ts +33 -0
  94. package/src/ui/components/sidebar.tsx +225 -0
  95. package/src/ui/components/snippet-editor-modal.tsx +39 -0
  96. package/src/ui/components/snippet-picker-modal.tsx +56 -0
  97. package/src/ui/components/split-layout.tsx +201 -0
  98. package/src/ui/components/status-bar.tsx +60 -0
  99. package/src/ui/components/surface.tsx +63 -0
  100. package/src/ui/components/tab-item.tsx +118 -0
  101. package/src/ui/components/terminal-pane.tsx +215 -0
  102. package/src/ui/components/theme-picker-modal.tsx +35 -0
  103. package/src/ui/components/use-sidebar-auto-scroll.ts +63 -0
  104. package/src/ui/components/use-sidebar-branch.ts +36 -0
  105. package/src/ui/directory-search.ts +1 -0
  106. package/src/ui/git-branch.ts +11 -0
  107. package/src/ui/path-format.ts +6 -0
  108. package/src/ui/root.tsx +261 -0
  109. package/src/ui/status-bar-model.ts +87 -0
  110. package/src/ui/theme.ts +9 -0
  111. package/src/ui/themes.ts +255 -0
  112. package/src/ui/ui-tokens.ts +11 -0
  113. package/src/update.ts +62 -0
@@ -0,0 +1,183 @@
1
+ import { $ } from 'bun'
2
+
3
+ import type {
4
+ GitFileEntry,
5
+ GitFileSection,
6
+ GitFileStatus,
7
+ GitPanelError,
8
+ GitRefreshPayload,
9
+ } from '../state/types'
10
+
11
+ interface NumstatRow {
12
+ added: number | null
13
+ removed: number | null
14
+ }
15
+
16
+ export type GitCollectResult =
17
+ | { kind: 'ok'; payload: GitRefreshPayload }
18
+ | { kind: 'error'; error: GitPanelError }
19
+
20
+ const STATUS_CODES = new Set(['M', 'A', 'D', 'R', 'C', 'U', '?'])
21
+
22
+ function toStatus(char: string): GitFileStatus | null {
23
+ return STATUS_CODES.has(char) ? (char as GitFileStatus) : null
24
+ }
25
+
26
+ export function parseBranchLines(output: string): {
27
+ branch: string | null
28
+ ahead: number
29
+ behind: number
30
+ } {
31
+ let branch: string | null = null
32
+ let ahead = 0
33
+ let behind = 0
34
+ for (const line of output.split('\0')) {
35
+ if (line.startsWith('# branch.head ')) {
36
+ const head = line.slice('# branch.head '.length).trim()
37
+ branch = head === '(detached)' ? null : head
38
+ } else if (line.startsWith('# branch.ab ')) {
39
+ const rest = line.slice('# branch.ab '.length).trim()
40
+ const match = /^\+(\d+)\s+-(\d+)/.exec(rest)
41
+ if (match) {
42
+ ahead = Number.parseInt(match[1] as string, 10)
43
+ behind = Number.parseInt(match[2] as string, 10)
44
+ }
45
+ }
46
+ }
47
+ return { ahead, behind, branch }
48
+ }
49
+
50
+ export function parseNumstat(output: string): Map<string, NumstatRow> {
51
+ const map = new Map<string, NumstatRow>()
52
+ for (const raw of output.split('\n')) {
53
+ if (!raw) continue
54
+ const parts = raw.split('\t')
55
+ if (parts.length < 3) continue
56
+ const [addedStr, removedStr, ...pathParts] = parts
57
+ const path = pathParts.join('\t')
58
+ if (!path || path.includes('{') || path.includes(' => ')) continue
59
+ const added = addedStr === '-' ? null : Number.parseInt(addedStr ?? '', 10)
60
+ const removed = removedStr === '-' ? null : Number.parseInt(removedStr ?? '', 10)
61
+ map.set(path, {
62
+ added: Number.isNaN(added as number) ? null : added,
63
+ removed: Number.isNaN(removed as number) ? null : removed,
64
+ })
65
+ }
66
+ return map
67
+ }
68
+
69
+ function buildEntry(
70
+ section: GitFileSection,
71
+ status: GitFileStatus,
72
+ path: string,
73
+ numstat: Map<string, NumstatRow>,
74
+ renamedFrom?: string
75
+ ): GitFileEntry {
76
+ const stats = numstat.get(path)
77
+ let added: number | null
78
+ let removed: number | null
79
+ if (status === '?') {
80
+ added = null
81
+ removed = null
82
+ } else if (stats) {
83
+ added = stats.added
84
+ removed = stats.removed
85
+ } else {
86
+ added = 0
87
+ removed = 0
88
+ }
89
+ const entry: GitFileEntry = { added, path, removed, section, status }
90
+ if (renamedFrom) entry.renamedFrom = renamedFrom
91
+ return entry
92
+ }
93
+
94
+ export function parsePorcelainEntries(
95
+ output: string,
96
+ stagedNumstat: Map<string, NumstatRow>,
97
+ unstagedNumstat: Map<string, NumstatRow>
98
+ ): GitFileEntry[] {
99
+ const files: GitFileEntry[] = []
100
+ const records = output.split('\0')
101
+ let i = 0
102
+ while (i < records.length) {
103
+ const line = records[i] ?? ''
104
+ if (!line || line.startsWith('#') || line.startsWith('!')) {
105
+ i++
106
+ continue
107
+ }
108
+
109
+ if (line.startsWith('? ')) {
110
+ files.push(buildEntry('untracked', '?', line.slice(2), unstagedNumstat))
111
+ i++
112
+ continue
113
+ }
114
+
115
+ if (line.startsWith('1 ')) {
116
+ const parts = line.split(' ')
117
+ if (parts.length >= 9) {
118
+ const xy = parts[1] ?? '..'
119
+ const path = parts.slice(8).join(' ')
120
+ const x = toStatus(xy[0] ?? '.')
121
+ const y = toStatus(xy[1] ?? '.')
122
+ if (x) files.push(buildEntry('staged', x, path, stagedNumstat))
123
+ if (y) files.push(buildEntry('unstaged', y, path, unstagedNumstat))
124
+ }
125
+ i++
126
+ continue
127
+ }
128
+
129
+ if (line.startsWith('2 ')) {
130
+ const parts = line.split(' ')
131
+ const origPath = records[i + 1] ?? ''
132
+ if (parts.length >= 10 && origPath) {
133
+ const xy = parts[1] ?? '..'
134
+ const newPath = parts.slice(9).join(' ')
135
+ if (newPath) {
136
+ const x = toStatus(xy[0] ?? '.')
137
+ const y = toStatus(xy[1] ?? '.')
138
+ if (x) files.push(buildEntry('staged', x, newPath, stagedNumstat, origPath))
139
+ if (y) files.push(buildEntry('unstaged', y, newPath, unstagedNumstat, origPath))
140
+ }
141
+ }
142
+ i += 2
143
+ continue
144
+ }
145
+
146
+ if (line.startsWith('u ')) {
147
+ const parts = line.split(' ')
148
+ if (parts.length >= 11) {
149
+ const path = parts.slice(10).join(' ')
150
+ files.push(buildEntry('unstaged', 'U', path, unstagedNumstat))
151
+ }
152
+ i++
153
+ continue
154
+ }
155
+
156
+ i++
157
+ }
158
+ return files
159
+ }
160
+
161
+ export async function collectGitStatus(cwd: string): Promise<GitCollectResult> {
162
+ try {
163
+ const [statusResult, unstagedDiff, stagedDiff] = await Promise.all([
164
+ $`git -C ${cwd} status --porcelain=v2 -b -z --untracked-files=all`.quiet().nothrow(),
165
+ $`git -C ${cwd} -c core.quotePath=false diff --numstat`.quiet().nothrow(),
166
+ $`git -C ${cwd} -c core.quotePath=false diff --cached --numstat`.quiet().nothrow(),
167
+ ])
168
+
169
+ if (statusResult.exitCode !== 0) {
170
+ return { error: 'not-a-repo', kind: 'error' }
171
+ }
172
+
173
+ const statusText = statusResult.text()
174
+ const { ahead, behind, branch } = parseBranchLines(statusText)
175
+ const unstagedNumstat = parseNumstat(unstagedDiff.text())
176
+ const stagedNumstat = parseNumstat(stagedDiff.text())
177
+ const files = parsePorcelainEntries(statusText, stagedNumstat, unstagedNumstat)
178
+
179
+ return { kind: 'ok', payload: { ahead, behind, branch, files } }
180
+ } catch {
181
+ return { error: 'unknown', kind: 'error' }
182
+ }
183
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bun
2
+ import { createCliRenderer } from '@opentui/core'
3
+ import { createRoot } from '@opentui/react'
4
+
5
+ import { App } from './app'
6
+ import { runDaemon } from './daemon/daemon'
7
+ import { logDebug } from './debug/input-log'
8
+ import { runDoctor } from './doctor'
9
+ import { runRestartDaemon } from './restart-daemon'
10
+ import { createSessionBackend } from './session-backend/bootstrap'
11
+ import { runUpdate } from './update'
12
+
13
+ const command = process.argv[2]
14
+
15
+ if (command === '--version' || command === '-v' || command === 'version') {
16
+ const { version } = await import('../package.json')
17
+ process.stdout.write(`aimux ${version}\n`)
18
+ process.exit(0)
19
+ }
20
+
21
+ if (command === 'doctor' || command === '--doctor') {
22
+ process.exit(runDoctor())
23
+ }
24
+
25
+ if (command === 'restart-daemon') {
26
+ process.exit(await runRestartDaemon())
27
+ }
28
+
29
+ if (command === 'update') {
30
+ process.exit(await runUpdate())
31
+ }
32
+
33
+ if (command === 'daemon') {
34
+ logDebug('index.daemonMode')
35
+ await runDaemon()
36
+ }
37
+
38
+ if (command === '--help' || command === '-h') {
39
+ process.stdout.write(
40
+ 'aimux -- terminal multiplexer for AI CLIs\n\nUsage:\n aimux Start aimux\n aimux update Update to latest version\n aimux doctor Diagnose setup issues\n aimux restart-daemon Restart background daemon\n\n'
41
+ )
42
+ process.exit(0)
43
+ }
44
+
45
+ const renderer = await createCliRenderer({
46
+ autoFocus: true,
47
+ exitOnCtrlC: false,
48
+ useAlternateScreen: true,
49
+ useConsole: false,
50
+ useMouse: true,
51
+ })
52
+
53
+ const backend = await createSessionBackend()
54
+ logDebug('index.backendReady', { backend: backend.constructor.name })
55
+
56
+ createRoot(renderer).render(<App backend={backend} />)
@@ -0,0 +1,34 @@
1
+ import type { ResolvedKeymapConfig } from '@brimveyn/aimux-config'
2
+
3
+ import { parseKeyNotation } from './key-chord'
4
+ import { KeymapModeHandler } from './keymap-mode-handler'
5
+ import { KeyTrie } from './trie'
6
+
7
+ /**
8
+ * Build a KeymapModeHandler for each mode defined in the resolved config.
9
+ */
10
+ export function buildKeymapHandlers(config: ResolvedKeymapConfig): KeymapModeHandler[] {
11
+ const leaderChord = parseKeyNotation(config.leader)[0]
12
+ const handlers: KeymapModeHandler[] = []
13
+
14
+ for (const [modeId, modeDef] of config.modes) {
15
+ const trie = new KeyTrie()
16
+
17
+ for (const binding of modeDef.bindings) {
18
+ const sequence = parseKeyNotation(binding.keys, leaderChord)
19
+ trie.insert(sequence, {
20
+ group: binding.group,
21
+ result: binding.result,
22
+ })
23
+ }
24
+
25
+ const handler = new KeymapModeHandler(modeId, trie, {
26
+ passthrough: modeDef.isPassthrough,
27
+ timeoutMs: config.timeout,
28
+ })
29
+
30
+ handlers.push(handler)
31
+ }
32
+
33
+ return handlers
34
+ }
@@ -0,0 +1,201 @@
1
+ import type { KeyInput } from '../modes/types'
2
+
3
+ /**
4
+ * Canonical string representation of a single key press.
5
+ * Examples: "C-n", "d", "J", "escape", "space", "?", "C-M-x"
6
+ * Modifier prefix order: C- (ctrl), M- (meta/alt), then the key name.
7
+ * Shift+letter is encoded as uppercase: Shift+j → "J".
8
+ */
9
+ export type KeyChord = string
10
+
11
+ const SPECIAL_NAMES: Record<string, string> = {
12
+ BS: 'backspace',
13
+ CR: 'return',
14
+ Down: 'down',
15
+ Esc: 'escape',
16
+ Left: 'left',
17
+ Right: 'right',
18
+ Space: 'space',
19
+ Tab: 'tab',
20
+ Up: 'up',
21
+ }
22
+
23
+ /**
24
+ * Convert a runtime KeyInput (from @opentui/core KeyEvent) to a canonical KeyChord.
25
+ */
26
+ export function keyInputToChord(key: KeyInput): KeyChord {
27
+ const { ctrl, meta, name, sequence, shift } = key
28
+
29
+ // For single-character sequences that differ from name (e.g., '?' comes as sequence='?' name='?')
30
+ // Use sequence for punctuation/symbols not captured by name
31
+ const baseName = name === 'undefined' || name === '' ? sequence : name
32
+
33
+ // Shift+letter → uppercase letter (no S- prefix)
34
+ if (shift && !ctrl && !meta && baseName.length === 1 && /^[a-z]$/.test(baseName)) {
35
+ return baseName.toUpperCase()
36
+ }
37
+
38
+ let prefix = ''
39
+ if (ctrl) prefix += 'C-'
40
+ if (meta) prefix += 'M-'
41
+
42
+ return `${prefix}${baseName}`
43
+ }
44
+
45
+ // Regex to match <...> notation tokens
46
+ const ANGLE_BRACKET_RE = /<([^>]+)>/g
47
+
48
+ /**
49
+ * Parse a vim-style key notation string into an array of KeyChords.
50
+ *
51
+ * Supports:
52
+ * - `<C-n>`, `<M-x>`, `<C-M-a>` — modifier combos
53
+ * - `<CR>`, `<Esc>`, `<Space>`, `<BS>`, `<Tab>` — special keys
54
+ * - `<Down>`, `<Up>`, `<Left>`, `<Right>` — arrow keys
55
+ * - `<leader>` — substituted with the provided leader KeyChord
56
+ * - `dd`, `gj` — bare characters produce one KeyChord each
57
+ *
58
+ * Examples:
59
+ * - `"<C-n>"` → `["C-n"]`
60
+ * - `"dd"` → `["d", "d"]`
61
+ * - `"<leader>tn"` → `["space", "t", "n"]` (if leader is "space")
62
+ */
63
+ export function parseKeyNotation(notation: string, leader?: KeyChord): KeyChord[] {
64
+ const chords: KeyChord[] = []
65
+ let lastIndex = 0
66
+
67
+ for (const match of notation.matchAll(ANGLE_BRACKET_RE)) {
68
+ const matchIndex = match.index ?? 0
69
+ const matchText = match[0] ?? ''
70
+ const inner = match[1] ?? ''
71
+
72
+ // Process bare characters before this angle-bracket token
73
+ for (let i = lastIndex; i < matchIndex; i++) {
74
+ const ch = notation[i]
75
+ if (ch !== undefined) chords.push(ch)
76
+ }
77
+
78
+ // Handle <leader>
79
+ if (inner.toLowerCase() === 'leader') {
80
+ if (!leader) {
81
+ throw new Error('parseKeyNotation: <leader> used but no leader key configured')
82
+ }
83
+ chords.push(leader)
84
+ lastIndex = matchIndex + matchText.length
85
+ continue
86
+ }
87
+
88
+ // Parse modifier+key combo: C-n, M-x, C-M-a, S-g
89
+ chords.push(parseAngleBracketToken(inner))
90
+ lastIndex = matchIndex + matchText.length
91
+ }
92
+
93
+ // Process remaining bare characters after last angle-bracket match
94
+ for (let i = lastIndex; i < notation.length; i++) {
95
+ const ch = notation[i]
96
+ if (ch !== undefined) chords.push(ch)
97
+ }
98
+
99
+ return chords
100
+ }
101
+
102
+ function parseAngleBracketToken(inner: string): KeyChord {
103
+ // Check if it's a special name (no modifiers)
104
+ const specialInner = SPECIAL_NAMES[inner]
105
+ if (specialInner) {
106
+ return specialInner
107
+ }
108
+
109
+ // Parse modifiers: split by '-', last part is the key
110
+ const parts = inner.split('-')
111
+ let ctrl = false
112
+ let meta = false
113
+ let shift = false
114
+
115
+ // All parts except the last are modifiers
116
+ for (let i = 0; i < parts.length - 1; i++) {
117
+ const mod = (parts[i] ?? '').toUpperCase()
118
+ if (mod === 'C') ctrl = true
119
+ else if (mod === 'M' || mod === 'A') meta = true
120
+ else if (mod === 'S') shift = true
121
+ }
122
+
123
+ let keyName = parts[parts.length - 1] ?? ''
124
+
125
+ // Check if the key part is a special name
126
+ const specialKey = SPECIAL_NAMES[keyName]
127
+ if (specialKey) {
128
+ keyName = specialKey
129
+ }
130
+
131
+ // Shift+letter → uppercase
132
+ if (shift && !ctrl && !meta && keyName.length === 1 && /^[a-z]$/.test(keyName)) {
133
+ return keyName.toUpperCase()
134
+ }
135
+
136
+ let prefix = ''
137
+ if (ctrl) prefix += 'C-'
138
+ if (meta) prefix += 'M-'
139
+
140
+ return `${prefix}${keyName}`
141
+ }
142
+
143
+ /**
144
+ * Convert a raw terminal byte sequence into a canonical KeyChord.
145
+ *
146
+ * Handles:
147
+ * - Single control bytes (0x01..0x1a) → `"C-<letter>"` (e.g., 0x1a → `"C-z"`)
148
+ * - Kitty CSI sequences `<ESC>[<code>;<mod>u` → `"C-<letter>"` (if ctrl modifier)
149
+ *
150
+ * Returns null if the sequence doesn't map to a recognizable chord.
151
+ */
152
+ const KITTY_CTRL_CSI_RE = new RegExp(`^${String.fromCharCode(0x1b)}\\[(\\d+);(\\d+)u$`)
153
+ const KITTY_MOD_CTRL = 4
154
+ const KITTY_MOD_SHIFT = 1
155
+ const KITTY_MOD_ALT = 2
156
+
157
+ export function rawSequenceToChord(sequence: string): KeyChord | null {
158
+ if (sequence.length === 1) {
159
+ const code = sequence.charCodeAt(0)
160
+
161
+ // Control bytes: Ctrl+A (0x01) through Ctrl+Z (0x1a)
162
+ if (code >= 0x01 && code <= 0x1a) {
163
+ return `C-${String.fromCharCode(code + 0x60)}`
164
+ }
165
+
166
+ // DEL (0x7f) is backspace
167
+ if (code === 0x7f) return 'backspace'
168
+
169
+ // Printable ASCII (space through ~): return the char as-is.
170
+ // Uppercase letters stay uppercase (shift convention).
171
+ if (code >= 0x20 && code <= 0x7e) {
172
+ return sequence
173
+ }
174
+
175
+ return null
176
+ }
177
+
178
+ // Kitty CSI: <ESC>[<code>;<mod>u
179
+ const match = KITTY_CTRL_CSI_RE.exec(sequence)
180
+ if (!match) return null
181
+
182
+ const codePoint = Number(match[1])
183
+ const modifiers = Number(match[2]) - 1
184
+ const hasCtrl = (modifiers & KITTY_MOD_CTRL) !== 0
185
+ const hasShift = (modifiers & KITTY_MOD_SHIFT) !== 0
186
+ const hasAlt = (modifiers & KITTY_MOD_ALT) !== 0
187
+
188
+ if (!hasCtrl || hasAlt) return null
189
+
190
+ // Only handle ASCII letters for now
191
+ if (codePoint >= 97 && codePoint <= 122) {
192
+ const letter = String.fromCharCode(codePoint)
193
+ const chord = hasShift ? `C-${letter.toUpperCase()}` : `C-${letter}`
194
+ return chord
195
+ }
196
+ if (codePoint >= 65 && codePoint <= 90) {
197
+ return `C-${String.fromCharCode(codePoint)}`
198
+ }
199
+
200
+ return null
201
+ }
@@ -0,0 +1,88 @@
1
+ import type { ActionFn } from '@brimveyn/aimux-config'
2
+
3
+ import type { KeyInput, KeyResult, ModeContext, ModeHandler, ModeId } from '../modes/types'
4
+ import type { KeyTrie, TrieBinding } from './trie'
5
+
6
+ import { handleTextInput } from '../modes/handlers/shared'
7
+ import { type KeyChord, keyInputToChord } from './key-chord'
8
+ import { SequenceResolver } from './sequence-resolver'
9
+
10
+ const EMPTY_RESULT: KeyResult = { actions: [], effects: [] }
11
+
12
+ function evalBinding(binding: TrieBinding, ctx: ModeContext): KeyResult | null {
13
+ if (typeof binding.result === 'function') {
14
+ return (binding.result as ActionFn)(ctx)
15
+ }
16
+ return binding.result
17
+ }
18
+
19
+ export class KeymapModeHandler implements ModeHandler {
20
+ readonly id: ModeId
21
+ private readonly resolver: SequenceResolver
22
+ private readonly isPassthrough: boolean
23
+
24
+ constructor(id: ModeId, trie: KeyTrie, opts: { timeoutMs: number; passthrough?: boolean }) {
25
+ this.id = id
26
+ this.isPassthrough = opts.passthrough ?? false
27
+ this.resolver = new SequenceResolver(trie, { timeoutMs: opts.timeoutMs })
28
+ }
29
+
30
+ handleKey(key: KeyInput, ctx: ModeContext): KeyResult | null {
31
+ const chord = keyInputToChord(key)
32
+ const match = this.resolver.feed(chord)
33
+
34
+ switch (match.type) {
35
+ case 'resolved':
36
+ return evalBinding(match.binding, ctx)
37
+ case 'pending':
38
+ return EMPTY_RESULT
39
+ case 'passthrough':
40
+ if (this.isPassthrough) return handleTextInput(key)
41
+ return null
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Feed a KeyChord directly (bypassing KeyInput conversion).
47
+ * Used by raw-input-handler for terminal-input shortcuts.
48
+ * Returns the resolved KeyResult if matched, otherwise null.
49
+ */
50
+ handleChord(chord: KeyChord, ctx: ModeContext): KeyResult | null {
51
+ const match = this.resolver.feed(chord)
52
+
53
+ switch (match.type) {
54
+ case 'resolved':
55
+ return evalBinding(match.binding, ctx)
56
+ case 'pending':
57
+ return EMPTY_RESULT
58
+ case 'passthrough':
59
+ return null
60
+ }
61
+ }
62
+
63
+ onEnter(_ctx: ModeContext, _from: ModeId): KeyResult {
64
+ this.resolver.reset()
65
+ return EMPTY_RESULT
66
+ }
67
+
68
+ onExit(_ctx: ModeContext, _to: ModeId): KeyResult {
69
+ this.resolver.reset()
70
+ return EMPTY_RESULT
71
+ }
72
+
73
+ /**
74
+ * Set the callback for when an ambiguous sequence times out.
75
+ * Must be wired from app.tsx to call processKeyResult.
76
+ */
77
+ setTimeoutCallback(cb: (binding: TrieBinding) => void): void {
78
+ this.resolver.setTimeoutCallback(cb)
79
+ }
80
+
81
+ /**
82
+ * Set the callback for pending chord state changes.
83
+ * Fires with the current pending chord sequence (or null when cleared).
84
+ */
85
+ setPendingChangeCallback(cb: (chords: KeyChord[] | null) => void): void {
86
+ this.resolver.setPendingChangeCallback(cb)
87
+ }
88
+ }
@@ -0,0 +1,117 @@
1
+ import type { KeyChord } from './key-chord'
2
+ import type { KeyTrie, TrieBinding, TrieNode } from './trie'
3
+
4
+ export type ResolveResult =
5
+ | { type: 'resolved'; binding: TrieBinding }
6
+ | { type: 'pending' }
7
+ | { type: 'passthrough' }
8
+
9
+ export interface SequenceResolverConfig {
10
+ timeoutMs: number
11
+ }
12
+
13
+ export class SequenceResolver {
14
+ private currentNode: TrieNode | null = null
15
+ private pendingBinding: TrieBinding | null = null
16
+ private pendingChords: KeyChord[] = []
17
+ private timeoutHandle: ReturnType<typeof setTimeout> | null = null
18
+ private onTimeout: ((binding: TrieBinding) => void) | null = null
19
+ private onPendingChange: ((chords: KeyChord[] | null) => void) | null = null
20
+
21
+ constructor(
22
+ private readonly trie: KeyTrie,
23
+ private readonly config: SequenceResolverConfig
24
+ ) {}
25
+
26
+ /**
27
+ * Feed a key chord into the resolver.
28
+ *
29
+ * Algorithm:
30
+ * 1. Lookup from current position (or root if no pending sequence).
31
+ * 2. 'none' → reset, try from root. If still none → passthrough.
32
+ * 3. 'exact' → fire immediately.
33
+ * 4. 'prefix' → wait for more keys.
34
+ * 5. 'exact+prefix' → start timeout. If next key arrives in time, advance.
35
+ * If timeout fires, resolve the exact match.
36
+ */
37
+ feed(chord: KeyChord): ResolveResult {
38
+ this.clearTimeout()
39
+
40
+ const fromNode = this.currentNode ?? this.trie.root
41
+ const match = this.trie.lookup(chord, fromNode)
42
+
43
+ switch (match.type) {
44
+ case 'exact': {
45
+ this.resetState()
46
+ return { binding: match.binding, type: 'resolved' }
47
+ }
48
+
49
+ case 'prefix': {
50
+ this.currentNode = match.node
51
+ this.pendingBinding = null
52
+ this.pendingChords.push(chord)
53
+ this.emitPendingChange()
54
+ return { type: 'pending' }
55
+ }
56
+
57
+ case 'exact+prefix': {
58
+ this.currentNode = match.node
59
+ this.pendingBinding = match.binding
60
+ this.pendingChords.push(chord)
61
+ this.emitPendingChange()
62
+ this.startTimeout(match.binding)
63
+ return { type: 'pending' }
64
+ }
65
+
66
+ case 'none': {
67
+ // If we were mid-sequence, try the chord from root
68
+ if (this.currentNode !== null) {
69
+ this.resetState()
70
+ return this.feed(chord)
71
+ }
72
+ return { type: 'passthrough' }
73
+ }
74
+ }
75
+ }
76
+
77
+ reset(): void {
78
+ this.clearTimeout()
79
+ this.resetState()
80
+ }
81
+
82
+ setTimeoutCallback(cb: (binding: TrieBinding) => void): void {
83
+ this.onTimeout = cb
84
+ }
85
+
86
+ setPendingChangeCallback(cb: (chords: KeyChord[] | null) => void): void {
87
+ this.onPendingChange = cb
88
+ }
89
+
90
+ private resetState(): void {
91
+ const hadPending = this.pendingChords.length > 0
92
+ this.currentNode = null
93
+ this.pendingBinding = null
94
+ this.pendingChords = []
95
+ if (hadPending) this.emitPendingChange()
96
+ }
97
+
98
+ private emitPendingChange(): void {
99
+ if (!this.onPendingChange) return
100
+ this.onPendingChange(this.pendingChords.length === 0 ? null : [...this.pendingChords])
101
+ }
102
+
103
+ private clearTimeout(): void {
104
+ if (this.timeoutHandle !== null) {
105
+ clearTimeout(this.timeoutHandle)
106
+ this.timeoutHandle = null
107
+ }
108
+ }
109
+
110
+ private startTimeout(binding: TrieBinding): void {
111
+ this.timeoutHandle = setTimeout(() => {
112
+ this.timeoutHandle = null
113
+ this.resetState()
114
+ this.onTimeout?.(binding)
115
+ }, this.config.timeoutMs)
116
+ }
117
+ }