@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,103 @@
|
|
|
1
|
+
import type { KeyResult, ModeContext } from '../modes/types'
|
|
2
|
+
import type { KeyChord } from './key-chord'
|
|
3
|
+
|
|
4
|
+
export type ActionFn = (ctx: ModeContext) => KeyResult | null
|
|
5
|
+
|
|
6
|
+
export interface TrieBinding {
|
|
7
|
+
result: KeyResult | ActionFn
|
|
8
|
+
group?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TrieNode {
|
|
12
|
+
binding: TrieBinding | null
|
|
13
|
+
children: Map<string, TrieNode>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type TrieMatch =
|
|
17
|
+
| { type: 'exact'; binding: TrieBinding }
|
|
18
|
+
| { type: 'prefix'; node: TrieNode }
|
|
19
|
+
| { type: 'exact+prefix'; binding: TrieBinding; node: TrieNode }
|
|
20
|
+
| { type: 'none' }
|
|
21
|
+
|
|
22
|
+
function createNode(): TrieNode {
|
|
23
|
+
return { binding: null, children: new Map() }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class KeyTrie {
|
|
27
|
+
readonly root: TrieNode = createNode()
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Insert a key sequence with a binding.
|
|
31
|
+
* Later inserts for the same sequence overwrite earlier ones.
|
|
32
|
+
*/
|
|
33
|
+
insert(sequence: KeyChord[], binding: TrieBinding): void {
|
|
34
|
+
if (sequence.length === 0) return
|
|
35
|
+
|
|
36
|
+
let node = this.root
|
|
37
|
+
for (const chord of sequence) {
|
|
38
|
+
let child = node.children.get(chord)
|
|
39
|
+
if (!child) {
|
|
40
|
+
child = createNode()
|
|
41
|
+
node.children.set(chord, child)
|
|
42
|
+
}
|
|
43
|
+
node = child
|
|
44
|
+
}
|
|
45
|
+
node.binding = binding
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Remove a key sequence. Returns true if it was found and removed.
|
|
50
|
+
*/
|
|
51
|
+
remove(sequence: KeyChord[]): boolean {
|
|
52
|
+
if (sequence.length === 0) return false
|
|
53
|
+
|
|
54
|
+
const path: Array<{ node: TrieNode; chord: KeyChord }> = []
|
|
55
|
+
let node = this.root
|
|
56
|
+
|
|
57
|
+
for (const chord of sequence) {
|
|
58
|
+
const child = node.children.get(chord)
|
|
59
|
+
if (!child) return false
|
|
60
|
+
path.push({ chord, node })
|
|
61
|
+
node = child
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!node.binding) return false
|
|
65
|
+
node.binding = null
|
|
66
|
+
|
|
67
|
+
// Prune empty branches from leaf upward
|
|
68
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
69
|
+
const entry = path[i]
|
|
70
|
+
if (!entry) break
|
|
71
|
+
const { chord, node: parent } = entry
|
|
72
|
+
const child = parent.children.get(chord)
|
|
73
|
+
if (child && child.binding === null && child.children.size === 0) {
|
|
74
|
+
parent.children.delete(chord)
|
|
75
|
+
} else {
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Look up a single KeyChord from a given node (defaults to root).
|
|
85
|
+
*/
|
|
86
|
+
lookup(chord: KeyChord, from?: TrieNode): TrieMatch {
|
|
87
|
+
const node = from ?? this.root
|
|
88
|
+
const child = node.children.get(chord)
|
|
89
|
+
|
|
90
|
+
if (!child) return { type: 'none' }
|
|
91
|
+
|
|
92
|
+
const hasChildren = child.children.size > 0
|
|
93
|
+
|
|
94
|
+
if (child.binding && hasChildren) {
|
|
95
|
+
return { binding: child.binding, node: child, type: 'exact+prefix' }
|
|
96
|
+
}
|
|
97
|
+
if (child.binding) {
|
|
98
|
+
return { binding: child.binding, type: 'exact' }
|
|
99
|
+
}
|
|
100
|
+
// hasChildren must be true (otherwise the node wouldn't exist after pruning)
|
|
101
|
+
return { node: child, type: 'prefix' }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { AppState, FocusMode, ModalType } from '../../state/types'
|
|
2
|
+
import type { ModeId } from './types'
|
|
3
|
+
|
|
4
|
+
type SupportedModalType = Exclude<ModalType, null>
|
|
5
|
+
|
|
6
|
+
const DIRECT_FOCUS_MODE_IDS: Partial<Record<FocusMode, ModeId>> = {
|
|
7
|
+
'layout': 'layout',
|
|
8
|
+
'navigation': 'navigation',
|
|
9
|
+
'terminal-input': 'terminal-input',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
13
|
+
'create-session': 'modal.create-session',
|
|
14
|
+
'new-tab': 'modal.new-tab.command-edit',
|
|
15
|
+
'rename-tab': 'modal.rename-tab',
|
|
16
|
+
'session-name': 'modal.session-name',
|
|
17
|
+
'session-picker': 'modal.session-picker.filtering',
|
|
18
|
+
'snippet-editor': 'modal.snippet-editor',
|
|
19
|
+
'snippet-picker': 'modal.snippet-picker.filtering',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
23
|
+
'help': 'modal.help',
|
|
24
|
+
'new-tab': 'modal.new-tab',
|
|
25
|
+
'session-picker': 'modal.session-picker',
|
|
26
|
+
'snippet-picker': 'modal.snippet-picker',
|
|
27
|
+
'split-picker': 'modal.split-picker',
|
|
28
|
+
'theme-picker': 'modal.theme-picker',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function deriveModeId(state: AppState): ModeId {
|
|
32
|
+
const directMode = DIRECT_FOCUS_MODE_IDS[state.focusMode]
|
|
33
|
+
if (directMode) {
|
|
34
|
+
return directMode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (state.focusMode === 'command-edit') {
|
|
38
|
+
const modalType = state.modal.type
|
|
39
|
+
const commandEditMode = modalType ? COMMAND_EDIT_MODE_IDS[modalType] : undefined
|
|
40
|
+
if (commandEditMode) {
|
|
41
|
+
return commandEditMode
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return 'navigation'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (state.focusMode === 'modal') {
|
|
48
|
+
const modalType = state.modal.type
|
|
49
|
+
const modalMode = modalType ? MODAL_MODE_IDS[modalType] : undefined
|
|
50
|
+
if (modalMode) {
|
|
51
|
+
return modalMode
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return 'navigation'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return 'navigation'
|
|
58
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ResolvedKeymapConfig } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import type { KeymapModeHandler } from '../../keymap/keymap-mode-handler'
|
|
4
|
+
|
|
5
|
+
import { buildKeymapHandlers } from '../../keymap/build-handlers'
|
|
6
|
+
import { registerMode } from '../registry'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build and register all mode handlers from a resolved keymap config.
|
|
10
|
+
* Returns the handlers array so app.tsx can wire timeout callbacks.
|
|
11
|
+
*/
|
|
12
|
+
export function registerAllModes(config: ResolvedKeymapConfig): KeymapModeHandler[] {
|
|
13
|
+
const handlers = buildKeymapHandlers(config)
|
|
14
|
+
for (const handler of handlers) {
|
|
15
|
+
registerMode(handler)
|
|
16
|
+
}
|
|
17
|
+
return handlers
|
|
18
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { AppAction } from '../../../state/types'
|
|
2
|
+
import type { KeyInput, KeyResult, ModeId, SideEffect } from '../types'
|
|
3
|
+
|
|
4
|
+
type SelectionDelta = -1 | 1
|
|
5
|
+
|
|
6
|
+
export function result(
|
|
7
|
+
actions: AppAction[] = [],
|
|
8
|
+
effects: SideEffect[] = [],
|
|
9
|
+
transition?: ModeId
|
|
10
|
+
): KeyResult {
|
|
11
|
+
return transition ? { actions, effects, transition } : { actions, effects }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function closeModalResult(
|
|
15
|
+
effects: SideEffect[] = [],
|
|
16
|
+
transition: ModeId = 'navigation'
|
|
17
|
+
): KeyResult {
|
|
18
|
+
return result([{ type: 'close-modal' }], effects, transition)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function moveModalSelectionResult(
|
|
22
|
+
delta: SelectionDelta,
|
|
23
|
+
effects: SideEffect[] = []
|
|
24
|
+
): KeyResult {
|
|
25
|
+
return result([{ delta, type: 'move-modal-selection' }], effects)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function handleModalSelectionKeys(
|
|
29
|
+
key: KeyInput,
|
|
30
|
+
getEffects?: (delta: SelectionDelta) => SideEffect[]
|
|
31
|
+
): KeyResult | null {
|
|
32
|
+
if (key.name === 'j' || key.name === 'down') {
|
|
33
|
+
return moveModalSelectionResult(1, getEffects?.(1) ?? [])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (key.name === 'k' || key.name === 'up') {
|
|
37
|
+
return moveModalSelectionResult(-1, getEffects?.(-1) ?? [])
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function handleTextInput(key: KeyInput): KeyResult | null {
|
|
44
|
+
if (key.name === 'backspace') {
|
|
45
|
+
return result([{ char: '\b', type: 'update-command-edit' }])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (key.name === 'space') {
|
|
49
|
+
return result([{ char: ' ', type: 'update-command-edit' }])
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (key.name.length === 1) {
|
|
53
|
+
const char = key.shift ? key.name.toUpperCase() : key.name
|
|
54
|
+
return result([{ char, type: 'update-command-edit' }])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function handleCtrlNavigation(key: KeyInput): KeyResult | null {
|
|
61
|
+
if (key.ctrl && key.name === 'n') {
|
|
62
|
+
return moveModalSelectionResult(1)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (key.ctrl && key.name === 'p') {
|
|
66
|
+
return moveModalSelectionResult(-1)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { KeyResult, ModeContext, ModeHandler, ModeId } from './types'
|
|
2
|
+
|
|
3
|
+
import { logDebug } from '../../debug/input-log'
|
|
4
|
+
import { isValidTransition } from './transitions'
|
|
5
|
+
|
|
6
|
+
const handlers = new Map<ModeId, ModeHandler>()
|
|
7
|
+
const EMPTY_RESULT: KeyResult = { actions: [], effects: [] }
|
|
8
|
+
|
|
9
|
+
export function registerMode(handler: ModeHandler): void {
|
|
10
|
+
handlers.set(handler.id, handler)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getHandler(id: ModeId): ModeHandler | undefined {
|
|
14
|
+
return handlers.get(id)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function transitionTo(from: ModeId, to: ModeId, ctx: ModeContext): KeyResult {
|
|
18
|
+
if (!isValidTransition(from, to)) {
|
|
19
|
+
logDebug('mode.invalidTransition', { from, to })
|
|
20
|
+
return EMPTY_RESULT
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const fromHandler = handlers.get(from)
|
|
24
|
+
const toHandler = handlers.get(to)
|
|
25
|
+
|
|
26
|
+
const exitResult = fromHandler?.onExit?.(ctx, to) ?? EMPTY_RESULT
|
|
27
|
+
const enterResult = toHandler?.onEnter?.(ctx, from) ?? EMPTY_RESULT
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
actions: [...exitResult.actions, ...enterResult.actions],
|
|
31
|
+
effects: [...exitResult.effects, ...enterResult.effects],
|
|
32
|
+
transition: to,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ModeId } from './types'
|
|
2
|
+
|
|
3
|
+
const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
4
|
+
'layout': ['terminal-input', 'navigation', 'modal.split-picker'],
|
|
5
|
+
'modal.create-session': ['navigation', 'modal.session-picker'],
|
|
6
|
+
'modal.help': ['navigation'],
|
|
7
|
+
'modal.new-tab': ['navigation', 'modal.new-tab.command-edit'],
|
|
8
|
+
'modal.new-tab.command-edit': ['modal.new-tab'],
|
|
9
|
+
'modal.rename-tab': ['navigation'],
|
|
10
|
+
'modal.session-name': ['modal.session-picker', 'navigation'],
|
|
11
|
+
'modal.session-picker': [
|
|
12
|
+
'navigation',
|
|
13
|
+
'modal.session-picker.filtering',
|
|
14
|
+
'modal.session-name',
|
|
15
|
+
'modal.create-session',
|
|
16
|
+
],
|
|
17
|
+
'modal.session-picker.filtering': ['modal.session-picker'],
|
|
18
|
+
'modal.snippet-editor': ['navigation', 'modal.snippet-picker'],
|
|
19
|
+
'modal.snippet-picker': ['navigation', 'modal.snippet-picker.filtering', 'modal.snippet-editor'],
|
|
20
|
+
'modal.snippet-picker.filtering': ['modal.snippet-picker'],
|
|
21
|
+
'modal.split-picker': ['navigation'],
|
|
22
|
+
'modal.theme-picker': ['navigation'],
|
|
23
|
+
'navigation': [
|
|
24
|
+
'terminal-input',
|
|
25
|
+
'modal.new-tab',
|
|
26
|
+
'modal.session-picker',
|
|
27
|
+
'modal.help',
|
|
28
|
+
'modal.snippet-picker',
|
|
29
|
+
'modal.theme-picker',
|
|
30
|
+
'modal.rename-tab',
|
|
31
|
+
],
|
|
32
|
+
'terminal-input': ['navigation', 'layout'],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isValidTransition(from: ModeId, to: ModeId): boolean {
|
|
36
|
+
return TRANSITIONS[from].includes(to)
|
|
37
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { KeyEvent } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import type { AppAction, AppState, TabSession } from '../../state/types'
|
|
4
|
+
|
|
5
|
+
export type ModeId =
|
|
6
|
+
| 'navigation'
|
|
7
|
+
| 'terminal-input'
|
|
8
|
+
| 'layout'
|
|
9
|
+
| 'modal.new-tab'
|
|
10
|
+
| 'modal.new-tab.command-edit'
|
|
11
|
+
| 'modal.session-picker'
|
|
12
|
+
| 'modal.session-picker.filtering'
|
|
13
|
+
| 'modal.session-name'
|
|
14
|
+
| 'modal.create-session'
|
|
15
|
+
| 'modal.rename-tab'
|
|
16
|
+
| 'modal.snippet-picker'
|
|
17
|
+
| 'modal.snippet-picker.filtering'
|
|
18
|
+
| 'modal.snippet-editor'
|
|
19
|
+
| 'modal.theme-picker'
|
|
20
|
+
| 'modal.help'
|
|
21
|
+
| 'modal.split-picker'
|
|
22
|
+
|
|
23
|
+
export type SideEffect =
|
|
24
|
+
| { type: 'quit'; state: AppState }
|
|
25
|
+
| { type: 'launch-selected-assistant' }
|
|
26
|
+
| { type: 'confirm-selected-session' }
|
|
27
|
+
| { type: 'delete-selected-session' }
|
|
28
|
+
| { type: 'open-rename-selected-session' }
|
|
29
|
+
| { type: 'create-session'; name: string; projectPath?: string }
|
|
30
|
+
| { type: 'close-tab'; tabId: string }
|
|
31
|
+
| { type: 'restart-tab'; tab: TabSession }
|
|
32
|
+
| { type: 'paste-selected-snippet' }
|
|
33
|
+
| { type: 'paste-snippet-to-group' }
|
|
34
|
+
| { type: 'edit-selected-snippet' }
|
|
35
|
+
| { type: 'delete-selected-snippet' }
|
|
36
|
+
| { type: 'save-snippet-editor' }
|
|
37
|
+
| { type: 'save-custom-command' }
|
|
38
|
+
| { type: 'apply-theme'; action: 'open' }
|
|
39
|
+
| { type: 'apply-theme'; action: 'restore' }
|
|
40
|
+
| { type: 'apply-theme'; action: 'confirm' }
|
|
41
|
+
| { type: 'apply-theme'; action: 'preview'; delta: 1 | -1 }
|
|
42
|
+
| { type: 'rename-session'; sessionId: string; name: string }
|
|
43
|
+
| { type: 'split-pane'; direction: import('../../state/layout-tree').SplitDirection }
|
|
44
|
+
| { type: 'confirm-split' }
|
|
45
|
+
|
|
46
|
+
export interface KeyResult {
|
|
47
|
+
actions: AppAction[]
|
|
48
|
+
effects: SideEffect[]
|
|
49
|
+
transition?: ModeId
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ModeContext {
|
|
53
|
+
readonly state: AppState
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type KeyInput = Pick<KeyEvent, 'name' | 'ctrl' | 'meta' | 'shift' | 'sequence'>
|
|
57
|
+
|
|
58
|
+
export interface ModeHandler {
|
|
59
|
+
readonly id: ModeId
|
|
60
|
+
handleKey(key: KeyInput, ctx: ModeContext): KeyResult | null
|
|
61
|
+
onEnter?(ctx: ModeContext, from: ModeId): KeyResult
|
|
62
|
+
onExit?(ctx: ModeContext, to: ModeId): KeyResult
|
|
63
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { MouseButton, type MouseEvent } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import type { TerminalContentOrigin } from './raw-input-handler'
|
|
4
|
+
|
|
5
|
+
function clampPtyCoordinates(
|
|
6
|
+
event: MouseEvent,
|
|
7
|
+
origin: TerminalContentOrigin
|
|
8
|
+
): { x: number; y: number } | null {
|
|
9
|
+
const x = event.x + 1 - origin.x
|
|
10
|
+
const y = event.y + 1 - origin.y
|
|
11
|
+
|
|
12
|
+
if (x < 1 || y < 1 || x > origin.cols || y > origin.rows) {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return { x, y }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getModifierBits(event: MouseEvent): number {
|
|
20
|
+
return (
|
|
21
|
+
(event.modifiers.shift ? 4 : 0) |
|
|
22
|
+
(event.modifiers.alt ? 8 : 0) |
|
|
23
|
+
(event.modifiers.ctrl ? 16 : 0)
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getBaseButtonCode(event: MouseEvent): number | null {
|
|
28
|
+
if (event.type === 'scroll') {
|
|
29
|
+
switch (event.scroll?.direction) {
|
|
30
|
+
case 'up':
|
|
31
|
+
return 64
|
|
32
|
+
case 'down':
|
|
33
|
+
return 65
|
|
34
|
+
case 'left':
|
|
35
|
+
return 66
|
|
36
|
+
case 'right':
|
|
37
|
+
return 67
|
|
38
|
+
default:
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
switch (event.button) {
|
|
44
|
+
case MouseButton.LEFT:
|
|
45
|
+
return 0
|
|
46
|
+
case MouseButton.MIDDLE:
|
|
47
|
+
return 1
|
|
48
|
+
case MouseButton.RIGHT:
|
|
49
|
+
return 2
|
|
50
|
+
case MouseButton.WHEEL_UP:
|
|
51
|
+
return 64
|
|
52
|
+
case MouseButton.WHEEL_DOWN:
|
|
53
|
+
return 65
|
|
54
|
+
default:
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function encodeMouseEventForPty(
|
|
60
|
+
event: MouseEvent,
|
|
61
|
+
origin: TerminalContentOrigin
|
|
62
|
+
): string | null {
|
|
63
|
+
const coordinates = clampPtyCoordinates(event, origin)
|
|
64
|
+
if (!coordinates) {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const modifierBits = getModifierBits(event)
|
|
69
|
+
|
|
70
|
+
switch (event.type) {
|
|
71
|
+
case 'down': {
|
|
72
|
+
const buttonCode = getBaseButtonCode(event)
|
|
73
|
+
if (buttonCode === null) {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
return `\x1b[<${buttonCode | modifierBits};${coordinates.x};${coordinates.y}M`
|
|
77
|
+
}
|
|
78
|
+
case 'up': {
|
|
79
|
+
return `\x1b[<${3 | modifierBits};${coordinates.x};${coordinates.y}m`
|
|
80
|
+
}
|
|
81
|
+
case 'drag': {
|
|
82
|
+
const buttonCode = getBaseButtonCode(event)
|
|
83
|
+
if (buttonCode === null) {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
return `\x1b[<${buttonCode | 32 | modifierBits};${coordinates.x};${coordinates.y}M`
|
|
87
|
+
}
|
|
88
|
+
case 'scroll': {
|
|
89
|
+
const buttonCode = getBaseButtonCode(event)
|
|
90
|
+
if (buttonCode === null) {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
return `\x1b[<${buttonCode | modifierBits};${coordinates.x};${coordinates.y}M`
|
|
94
|
+
}
|
|
95
|
+
default:
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { logInputDebug } from '../debug/input-log'
|
|
2
|
+
|
|
3
|
+
const MULTI_CLICK_TIMEOUT_MS = 400
|
|
4
|
+
const MULTI_CLICK_MAX_DISTANCE = 1
|
|
5
|
+
|
|
6
|
+
export class MultiClickDetector {
|
|
7
|
+
private lastClickTime = 0
|
|
8
|
+
private lastX = -1
|
|
9
|
+
private lastY = -1
|
|
10
|
+
private clickCount = 0
|
|
11
|
+
|
|
12
|
+
track(x: number, y: number): number {
|
|
13
|
+
const now = Date.now()
|
|
14
|
+
const prevX = this.lastX
|
|
15
|
+
const prevY = this.lastY
|
|
16
|
+
const prevTime = this.lastClickTime
|
|
17
|
+
// X tolerance allows slight horizontal drift during rapid clicks.
|
|
18
|
+
// Y must match exactly because 1 pixel = 1 terminal row — a 1-pixel
|
|
19
|
+
// vertical shift means a completely different line.
|
|
20
|
+
const samePosition = Math.abs(x - this.lastX) <= MULTI_CLICK_MAX_DISTANCE && y === this.lastY
|
|
21
|
+
const withinTimeout = now - this.lastClickTime < MULTI_CLICK_TIMEOUT_MS
|
|
22
|
+
|
|
23
|
+
if (samePosition && withinTimeout && this.clickCount < 3) {
|
|
24
|
+
this.clickCount += 1
|
|
25
|
+
} else {
|
|
26
|
+
this.clickCount = 1
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
this.lastClickTime = now
|
|
30
|
+
this.lastX = x
|
|
31
|
+
this.lastY = y
|
|
32
|
+
|
|
33
|
+
logInputDebug('multiclick.track', {
|
|
34
|
+
count: this.clickCount,
|
|
35
|
+
dt: now - prevTime,
|
|
36
|
+
dX: Math.abs(x - prevX),
|
|
37
|
+
dY: Math.abs(y - prevY),
|
|
38
|
+
inTime: withinTimeout,
|
|
39
|
+
prevX,
|
|
40
|
+
prevY,
|
|
41
|
+
samePos: samePosition,
|
|
42
|
+
x,
|
|
43
|
+
y,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return this.clickCount
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
reset(): void {
|
|
50
|
+
this.clickCount = 0
|
|
51
|
+
this.lastClickTime = 0
|
|
52
|
+
this.lastX = -1
|
|
53
|
+
this.lastY = -1
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const BRACKETED_PASTE_START = '\x1b[200~'
|
|
2
|
+
const BRACKETED_PASTE_END = '\x1b[201~'
|
|
3
|
+
|
|
4
|
+
export function buildPtyPastePayload(text: string, bracketedPasteMode: boolean): string {
|
|
5
|
+
if (!bracketedPasteMode) {
|
|
6
|
+
return text
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export { BRACKETED_PASTE_END, BRACKETED_PASTE_START }
|