@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,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 }