@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.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/package.json +79 -0
- package/src/app-runtime/backend-attach-runtime.ts +135 -0
- package/src/app-runtime/backend-runtime-events.ts +78 -0
- package/src/app-runtime/click-selection-resolver.ts +130 -0
- package/src/app-runtime/pty-write.ts +32 -0
- package/src/app-runtime/render-invalidation.ts +20 -0
- package/src/app-runtime/selection-clipboard.ts +69 -0
- package/src/app-runtime/selection-scroll.ts +143 -0
- package/src/app-runtime/session-actions.ts +170 -0
- package/src/app-runtime/side-effects.ts +434 -0
- package/src/app-runtime/snippet-actions.ts +90 -0
- package/src/app-runtime/split-drag-controller.ts +15 -0
- package/src/app-runtime/tab-runtime-timeouts.ts +86 -0
- package/src/app-runtime/terminal-mouse-adapter.ts +31 -0
- package/src/app-runtime/use-backend-runtime.ts +99 -0
- package/src/app-runtime/use-directory-search.ts +52 -0
- package/src/app-runtime/use-mouse-handlers.ts +183 -0
- package/src/app-runtime/use-renderer-bindings.ts +163 -0
- package/src/app-runtime/use-terminal-resize.ts +141 -0
- package/src/app-runtime/use-workspace-autosave.ts +39 -0
- package/src/app.tsx +247 -0
- package/src/config/loader.ts +36 -0
- package/src/config.ts +146 -0
- package/src/daemon/daemon.ts +236 -0
- package/src/daemon/runtime-paths.ts +69 -0
- package/src/daemon/session-manager.ts +109 -0
- package/src/daemon/session-registry.ts +207 -0
- package/src/debug/input-log.ts +29 -0
- package/src/doctor.ts +106 -0
- package/src/git/git-poller.ts +49 -0
- package/src/git/git-status.ts +183 -0
- package/src/index.tsx +56 -0
- package/src/input/keymap/build-handlers.ts +34 -0
- package/src/input/keymap/key-chord.ts +201 -0
- package/src/input/keymap/keymap-mode-handler.ts +88 -0
- package/src/input/keymap/sequence-resolver.ts +117 -0
- package/src/input/keymap/trie.ts +103 -0
- package/src/input/modes/bridge.ts +58 -0
- package/src/input/modes/handlers/index.ts +18 -0
- package/src/input/modes/handlers/shared.ts +70 -0
- package/src/input/modes/registry.ts +34 -0
- package/src/input/modes/transitions.ts +37 -0
- package/src/input/modes/types.ts +63 -0
- package/src/input/mouse-forwarding.ts +98 -0
- package/src/input/multi-click-detector.ts +55 -0
- package/src/input/paste.ts +12 -0
- package/src/input/raw-input-handler.ts +191 -0
- package/src/input/terminal-text-extraction.ts +78 -0
- package/src/ipc/protocol.ts +341 -0
- package/src/platform/clipboard.ts +13 -0
- package/src/platform/daemon-control.ts +62 -0
- package/src/platform/id.ts +7 -0
- package/src/platform/project-search.ts +75 -0
- package/src/pty/command-registry.ts +69 -0
- package/src/pty/pty-manager.ts +268 -0
- package/src/pty/terminal-snapshot.ts +210 -0
- package/src/restart-daemon.ts +28 -0
- package/src/session-backend/bootstrap.ts +107 -0
- package/src/session-backend/local-session-backend.ts +164 -0
- package/src/session-backend/remote-session-backend.ts +373 -0
- package/src/session-backend/types.ts +47 -0
- package/src/state/app-store.ts +19 -0
- package/src/state/layout-resize.ts +56 -0
- package/src/state/layout-tree.ts +323 -0
- package/src/state/reducers/git-panel-state.ts +102 -0
- package/src/state/reducers/modal-state.ts +358 -0
- package/src/state/reducers/session-state.ts +86 -0
- package/src/state/reducers/tab-state.ts +531 -0
- package/src/state/reducers/ui-state.ts +29 -0
- package/src/state/selectors.ts +30 -0
- package/src/state/session-catalog.ts +96 -0
- package/src/state/session-persistence.ts +210 -0
- package/src/state/snippet-catalog.ts +90 -0
- package/src/state/store.ts +93 -0
- package/src/state/terminal-modes.ts +11 -0
- package/src/state/types.ts +367 -0
- package/src/state/validation.ts +154 -0
- package/src/state/workspace-save.ts +33 -0
- package/src/ui/components/create-session-modal.tsx +100 -0
- package/src/ui/components/git-panel.tsx +200 -0
- package/src/ui/components/help-modal.tsx +79 -0
- package/src/ui/components/input-field.tsx +18 -0
- package/src/ui/components/list-item.tsx +30 -0
- package/src/ui/components/modal-filter-bar.tsx +18 -0
- package/src/ui/components/modal-shell.tsx +47 -0
- package/src/ui/components/new-tab-modal.tsx +52 -0
- package/src/ui/components/pending-chord-indicator.tsx +41 -0
- package/src/ui/components/session-name-modal.tsx +15 -0
- package/src/ui/components/session-picker-modal.tsx +93 -0
- package/src/ui/components/sidebar-group-metadata.ts +44 -0
- package/src/ui/components/sidebar-scroll.ts +33 -0
- package/src/ui/components/sidebar.tsx +225 -0
- package/src/ui/components/snippet-editor-modal.tsx +39 -0
- package/src/ui/components/snippet-picker-modal.tsx +56 -0
- package/src/ui/components/split-layout.tsx +201 -0
- package/src/ui/components/status-bar.tsx +60 -0
- package/src/ui/components/surface.tsx +63 -0
- package/src/ui/components/tab-item.tsx +118 -0
- package/src/ui/components/terminal-pane.tsx +215 -0
- package/src/ui/components/theme-picker-modal.tsx +35 -0
- package/src/ui/components/use-sidebar-auto-scroll.ts +63 -0
- package/src/ui/components/use-sidebar-branch.ts +36 -0
- package/src/ui/directory-search.ts +1 -0
- package/src/ui/git-branch.ts +11 -0
- package/src/ui/path-format.ts +6 -0
- package/src/ui/root.tsx +261 -0
- package/src/ui/status-bar-model.ts +87 -0
- package/src/ui/theme.ts +9 -0
- package/src/ui/themes.ts +255 -0
- package/src/ui/ui-tokens.ts +11 -0
- 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
|
+
}
|