@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,191 @@
1
+ import type { FocusMode } from '../state/types'
2
+
3
+ import { logInputDebug } from '../debug/input-log'
4
+ import { type KeyChord, rawSequenceToChord } from './keymap/key-chord'
5
+ import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, buildPtyPastePayload } from './paste'
6
+
7
+ const ESC = '\x1b'
8
+ const KITTY_CTRL_RE = new RegExp(`^${ESC}\\[(\\d+);(\\d+)u$`)
9
+ const KITTY_MOD_SUPER = 8
10
+ const KITTY_MOD_HYPER = 16
11
+ const KITTY_MOD_META = 32
12
+ const KITTY_HOST_MOD_MASK = KITTY_MOD_SUPER | KITTY_MOD_HYPER | KITTY_MOD_META
13
+
14
+ function normalizeControlSequence(sequence: string): string | null {
15
+ const match = KITTY_CTRL_RE.exec(sequence)
16
+ if (!match) {
17
+ return sequence
18
+ }
19
+
20
+ const codePoint = Number(match[1])
21
+ const modifiers = Number(match[2]) - 1
22
+
23
+ if ((modifiers & KITTY_HOST_MOD_MASK) !== 0) {
24
+ return null
25
+ }
26
+
27
+ const hasCtrl = (modifiers & 4) !== 0
28
+ const hasAlt = (modifiers & 2) !== 0
29
+
30
+ if (!hasCtrl || hasAlt) {
31
+ return sequence
32
+ }
33
+
34
+ if ((codePoint >= 65 && codePoint <= 90) || (codePoint >= 97 && codePoint <= 122)) {
35
+ return String.fromCharCode(codePoint & 0x1f)
36
+ }
37
+
38
+ switch (codePoint) {
39
+ case 32:
40
+ case 50:
41
+ case 64:
42
+ return '\x00'
43
+ case 51:
44
+ case 91:
45
+ return '\x1b'
46
+ case 52:
47
+ case 92:
48
+ return '\x1c'
49
+ case 53:
50
+ case 93:
51
+ return '\x1d'
52
+ case 54:
53
+ case 94:
54
+ return '\x1e'
55
+ case 47:
56
+ case 55:
57
+ case 95:
58
+ return '\x1f'
59
+ case 56:
60
+ case 63:
61
+ return '\x7f'
62
+ default:
63
+ return sequence
64
+ }
65
+ }
66
+
67
+ export interface TerminalContentOrigin {
68
+ /** 0-based screen column of the first content cell */
69
+ x: number
70
+ /** 0-based screen row of the first content cell */
71
+ y: number
72
+ /** PTY column count */
73
+ cols: number
74
+ /** PTY row count */
75
+ rows: number
76
+ }
77
+
78
+ export function createRawInputHandler(deps: {
79
+ getFocusMode: () => FocusMode
80
+ getActiveTabId: () => string | null
81
+ getBracketedPasteModeEnabled: () => boolean
82
+ writeToPty: (tabId: string, data: string) => void
83
+ /**
84
+ * Dispatch a configured terminal-input shortcut.
85
+ * Returns true if the chord was consumed by the keymap, false otherwise.
86
+ */
87
+ handleTerminalShortcut: (chord: KeyChord) => boolean
88
+ }): (sequence: string) => boolean {
89
+ let bracketedPasteBuffer: string | null = null
90
+
91
+ function flushPaste(tabId: string, payload: string): void {
92
+ logInputDebug('raw.flushPaste', {
93
+ bracketedPasteModeEnabled: deps.getBracketedPasteModeEnabled(),
94
+ payloadLength: payload.length,
95
+ payloadPreview: payload.slice(0, 120),
96
+ tabId,
97
+ })
98
+ deps.writeToPty(tabId, buildPtyPastePayload(payload, deps.getBracketedPasteModeEnabled()))
99
+ }
100
+
101
+ function handleTerminalShortcut(sequence: string): boolean {
102
+ const chord = rawSequenceToChord(sequence)
103
+ if (chord === null) return false
104
+ return deps.handleTerminalShortcut(chord)
105
+ }
106
+
107
+ function handleSequence(tabId: string, sequence: string): boolean {
108
+ if (sequence.length === 0) {
109
+ return true
110
+ }
111
+
112
+ if (bracketedPasteBuffer !== null) {
113
+ logInputDebug('raw.collectPasteChunk', {
114
+ chunkLength: sequence.length,
115
+ chunkPreview: sequence.slice(0, 120),
116
+ tabId,
117
+ })
118
+ const endIndex = sequence.indexOf(BRACKETED_PASTE_END)
119
+ if (endIndex === -1) {
120
+ bracketedPasteBuffer += sequence
121
+ return true
122
+ }
123
+
124
+ bracketedPasteBuffer += sequence.slice(0, endIndex)
125
+ flushPaste(tabId, bracketedPasteBuffer)
126
+ bracketedPasteBuffer = null
127
+ return handleSequence(tabId, sequence.slice(endIndex + BRACKETED_PASTE_END.length))
128
+ }
129
+
130
+ const startIndex = sequence.indexOf(BRACKETED_PASTE_START)
131
+ if (startIndex !== -1) {
132
+ logInputDebug('raw.detectBracketedPasteStart', {
133
+ sequenceLength: sequence.length,
134
+ sequencePreview: sequence.slice(0, 120),
135
+ tabId,
136
+ })
137
+ if (!handleSequence(tabId, sequence.slice(0, startIndex))) {
138
+ return false
139
+ }
140
+
141
+ const afterStart = sequence.slice(startIndex + BRACKETED_PASTE_START.length)
142
+ const endIndex = afterStart.indexOf(BRACKETED_PASTE_END)
143
+ if (endIndex === -1) {
144
+ bracketedPasteBuffer = afterStart
145
+ return true
146
+ }
147
+
148
+ flushPaste(tabId, afterStart.slice(0, endIndex))
149
+ return handleSequence(tabId, afterStart.slice(endIndex + BRACKETED_PASTE_END.length))
150
+ }
151
+
152
+ if (handleTerminalShortcut(sequence)) {
153
+ return true
154
+ }
155
+
156
+ const normalized = normalizeControlSequence(sequence)
157
+ if (normalized === null) {
158
+ logInputDebug('raw.swallowHostModifier', {
159
+ sequencePreview: sequence.slice(0, 40),
160
+ tabId,
161
+ })
162
+ return true
163
+ }
164
+
165
+ deps.writeToPty(tabId, normalized)
166
+ return true
167
+ }
168
+
169
+ return (sequence: string): boolean => {
170
+ if (deps.getFocusMode() !== 'terminal-input') {
171
+ return false
172
+ }
173
+
174
+ logInputDebug('raw.sequence', {
175
+ activeTabId: deps.getActiveTabId(),
176
+ sequenceLength: sequence.length,
177
+ sequencePreview: sequence.slice(0, 120),
178
+ })
179
+
180
+ if (handleTerminalShortcut(sequence)) {
181
+ return true
182
+ }
183
+
184
+ const activeTabId = deps.getActiveTabId()
185
+ if (!activeTabId) {
186
+ return false
187
+ }
188
+
189
+ return handleSequence(activeTabId, sequence)
190
+ }
191
+ }
@@ -0,0 +1,78 @@
1
+ import type { TerminalLine } from '../state/types'
2
+
3
+ export function getLineText(line: TerminalLine): string {
4
+ return line.spans.map((span) => span.text).join('')
5
+ }
6
+
7
+ /**
8
+ * Extract text from a range of terminal lines as a single string.
9
+ *
10
+ * Multi-row selections are lossy: trailing `[ \t]+` is stripped from each
11
+ * joined segment to drop the viewport padding the snapshot layer fills blank
12
+ * cells with. Without this, shell line continuations (`\` followed by padding
13
+ * spaces then `\n`) paste as escaped-space sequences instead of continuations.
14
+ *
15
+ * Single-row selections are returned verbatim — trailing spaces the user
16
+ * explicitly dragged over are preserved.
17
+ */
18
+ export function extractStreamText(
19
+ lines: TerminalLine[],
20
+ startRow: number,
21
+ startCol: number,
22
+ endRow: number,
23
+ endCol: number
24
+ ): string {
25
+ if (startRow > endRow || (startRow === endRow && startCol > endCol)) {
26
+ ;[startRow, endRow] = [endRow, startRow]
27
+ ;[startCol, endCol] = [endCol, startCol]
28
+ }
29
+
30
+ const clampedStart = Math.max(0, startRow)
31
+ const clampedEnd = Math.min(lines.length - 1, endRow)
32
+ const parts: string[] = []
33
+
34
+ for (let row = clampedStart; row <= clampedEnd; row++) {
35
+ const text = getLineText(lines[row] as TerminalLine)
36
+ if (row === startRow && row === endRow) {
37
+ parts.push(text.slice(Math.max(0, startCol), Math.max(0, endCol)))
38
+ } else if (row === startRow) {
39
+ parts.push(rtrim(text.slice(Math.max(0, startCol))))
40
+ } else if (row === endRow) {
41
+ parts.push(rtrim(text.slice(0, Math.max(0, endCol))))
42
+ } else {
43
+ parts.push(rtrim(text))
44
+ }
45
+ }
46
+
47
+ return parts.join('\n')
48
+ }
49
+
50
+ function rtrim(text: string): string {
51
+ return text.replace(/[ \t]+$/, '')
52
+ }
53
+
54
+ export function getWordAtColumn(
55
+ lineText: string,
56
+ column: number
57
+ ): { text: string; startCol: number; endCol: number } {
58
+ if (column < 0 || column >= lineText.length) {
59
+ return { endCol: column, startCol: column, text: '' }
60
+ }
61
+
62
+ const ch = lineText[column]
63
+ if (!ch || !/\S/.test(ch)) {
64
+ return { endCol: column, startCol: column, text: '' }
65
+ }
66
+
67
+ let startCol = column
68
+ while (startCol > 0 && /\S/.test(lineText[startCol - 1] as string)) {
69
+ startCol--
70
+ }
71
+
72
+ let endCol = column
73
+ while (endCol < lineText.length && /\S/.test(lineText[endCol] as string)) {
74
+ endCol++
75
+ }
76
+
77
+ return { endCol, startCol, text: lineText.slice(startCol, endCol) }
78
+ }
@@ -0,0 +1,341 @@
1
+ import type {
2
+ TabSession,
3
+ TerminalModeState,
4
+ TerminalSnapshot,
5
+ WorkspaceSnapshotV1,
6
+ } from '../state/types'
7
+
8
+ import { isWorkspaceSnapshotV1 } from '../state/validation'
9
+
10
+ export const IPC_PROTOCOL_VERSION = 1
11
+
12
+ export interface AttachRequest {
13
+ protocolVersion: number
14
+ sessionId: string
15
+ cols: number
16
+ rows: number
17
+ workspaceSnapshot?: WorkspaceSnapshotV1
18
+ }
19
+
20
+ export interface AttachResult {
21
+ protocolVersion: number
22
+ tabs: TabSession[]
23
+ activeTabId: string | null
24
+ }
25
+
26
+ export type ClientRequest =
27
+ | { id: string; type: 'attach'; payload: AttachRequest }
28
+ | {
29
+ id: string
30
+ type: 'createTab'
31
+ payload: {
32
+ tabId: string
33
+ assistant: TabSession['assistant']
34
+ title: string
35
+ command: string
36
+ args?: string[]
37
+ cols: number
38
+ rows: number
39
+ cwd?: string
40
+ }
41
+ }
42
+ | { id: string; type: 'write'; payload: { tabId: string; data: string } }
43
+ | { id: string; type: 'resizeClient'; payload: { cols: number; rows: number } }
44
+ | { id: string; type: 'resizeTab'; payload: { tabId: string; cols: number; rows: number } }
45
+ | { id: string; type: 'scrollToBottom'; payload: { tabId: string } }
46
+ | { id: string; type: 'scroll'; payload: { tabId: string; deltaLines: number } }
47
+ | { id: string; type: 'setActiveTab'; payload: { tabId: string | null } }
48
+ | { id: string; type: 'closeTab'; payload: { tabId: string } }
49
+ | { id: string; type: 'disposeAll'; payload: Record<string, never> }
50
+ | { id: string; type: 'ping'; payload: Record<string, never> }
51
+
52
+ export type ServerResponse =
53
+ | { id: string; type: 'ok'; payload: Record<string, never> }
54
+ | { id: string; type: 'attachResult'; payload: AttachResult }
55
+ | { id: string; type: 'error'; payload: { message: string } }
56
+
57
+ export type ServerEvent =
58
+ | {
59
+ type: 'tabRender'
60
+ payload: { tabId: string; viewport: TerminalSnapshot; terminalModes: TerminalModeState }
61
+ }
62
+ | { type: 'tabExit'; payload: { tabId: string; exitCode: number } }
63
+ | { type: 'tabError'; payload: { tabId: string; message: string } }
64
+
65
+ export type IpcMessage = ClientRequest | ServerResponse | ServerEvent
66
+
67
+ export class IpcProtocolError extends Error {
68
+ constructor(message: string) {
69
+ super(message)
70
+ this.name = 'IpcProtocolError'
71
+ }
72
+ }
73
+
74
+ export class ProtocolMismatchError extends Error {
75
+ constructor(
76
+ public readonly clientVersion: number,
77
+ public readonly daemonVersion: number
78
+ ) {
79
+ super(`Protocol mismatch: client v${clientVersion}, daemon v${daemonVersion}`)
80
+ this.name = 'ProtocolMismatchError'
81
+ }
82
+ }
83
+
84
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
85
+ return typeof value === 'object' && value !== null
86
+ }
87
+
88
+ function isString(value: unknown): value is string {
89
+ return typeof value === 'string'
90
+ }
91
+
92
+ function isNullableString(value: unknown): value is string | null {
93
+ return value === null || isString(value)
94
+ }
95
+
96
+ function isFiniteNumber(value: unknown): value is number {
97
+ return typeof value === 'number' && Number.isFinite(value)
98
+ }
99
+
100
+ function isStringArray(value: unknown): value is string[] {
101
+ return Array.isArray(value) && value.every(isString)
102
+ }
103
+
104
+ function isTerminalSpan(value: unknown): boolean {
105
+ return (
106
+ isObjectRecord(value) &&
107
+ isString(value.text) &&
108
+ (value.fg === undefined || isString(value.fg)) &&
109
+ (value.bg === undefined || isString(value.bg)) &&
110
+ (value.bold === undefined || typeof value.bold === 'boolean') &&
111
+ (value.italic === undefined || typeof value.italic === 'boolean') &&
112
+ (value.underline === undefined || typeof value.underline === 'boolean') &&
113
+ (value.cursor === undefined || typeof value.cursor === 'boolean')
114
+ )
115
+ }
116
+
117
+ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
118
+ return (
119
+ isObjectRecord(value) &&
120
+ Array.isArray(value.lines) &&
121
+ value.lines.every(
122
+ (line) =>
123
+ isObjectRecord(line) && Array.isArray(line.spans) && line.spans.every(isTerminalSpan)
124
+ ) &&
125
+ isFiniteNumber(value.viewportY) &&
126
+ isFiniteNumber(value.baseY) &&
127
+ typeof value.cursorVisible === 'boolean'
128
+ )
129
+ }
130
+
131
+ function isTerminalModeState(value: unknown): value is TerminalModeState {
132
+ return (
133
+ isObjectRecord(value) &&
134
+ (value.mouseTrackingMode === 'none' ||
135
+ value.mouseTrackingMode === 'x10' ||
136
+ value.mouseTrackingMode === 'vt200' ||
137
+ value.mouseTrackingMode === 'drag' ||
138
+ value.mouseTrackingMode === 'any') &&
139
+ typeof value.sendFocusMode === 'boolean' &&
140
+ typeof value.alternateScrollMode === 'boolean' &&
141
+ typeof value.isAlternateBuffer === 'boolean' &&
142
+ typeof value.bracketedPasteMode === 'boolean'
143
+ )
144
+ }
145
+
146
+ function isAttachResult(value: unknown): value is AttachResult {
147
+ return (
148
+ isObjectRecord(value) &&
149
+ isFiniteNumber(value.protocolVersion) &&
150
+ Array.isArray(value.tabs) &&
151
+ value.tabs.every(
152
+ (tab) =>
153
+ isObjectRecord(tab) &&
154
+ isString(tab.id) &&
155
+ isString(tab.assistant) &&
156
+ tab.assistant.length > 0 &&
157
+ isString(tab.title) &&
158
+ (tab.status === 'starting' ||
159
+ tab.status === 'running' ||
160
+ tab.status === 'disconnected' ||
161
+ tab.status === 'exited' ||
162
+ tab.status === 'error') &&
163
+ (tab.activity === undefined || tab.activity === 'busy' || tab.activity === 'idle') &&
164
+ isString(tab.buffer) &&
165
+ isTerminalModeState(tab.terminalModes) &&
166
+ isString(tab.command) &&
167
+ (tab.viewport === undefined || isTerminalSnapshot(tab.viewport)) &&
168
+ (tab.errorMessage === undefined || isString(tab.errorMessage)) &&
169
+ (tab.exitCode === undefined || isFiniteNumber(tab.exitCode))
170
+ ) &&
171
+ isNullableString(value.activeTabId)
172
+ )
173
+ }
174
+
175
+ function assert(condition: boolean, message: string): asserts condition {
176
+ if (!condition) {
177
+ throw new IpcProtocolError(message)
178
+ }
179
+ }
180
+
181
+ export function parseClientRequest(value: unknown): ClientRequest {
182
+ assert(isObjectRecord(value), 'IPC message must be an object')
183
+ assert(isString(value.id), 'IPC request id must be a string')
184
+ assert(isString(value.type), 'IPC request type must be a string')
185
+ assert(isObjectRecord(value.payload), 'IPC request payload must be an object')
186
+
187
+ switch (value.type) {
188
+ case 'attach':
189
+ assert(
190
+ value.payload.protocolVersion === IPC_PROTOCOL_VERSION,
191
+ `attach.protocolVersion must be ${IPC_PROTOCOL_VERSION}`
192
+ )
193
+ assert(isString(value.payload.sessionId), 'attach.sessionId must be a string')
194
+ assert(isFiniteNumber(value.payload.cols), 'attach.cols must be a number')
195
+ assert(isFiniteNumber(value.payload.rows), 'attach.rows must be a number')
196
+ assert(
197
+ value.payload.workspaceSnapshot === undefined ||
198
+ isWorkspaceSnapshotV1(value.payload.workspaceSnapshot),
199
+ 'attach.workspaceSnapshot must be a valid workspace snapshot'
200
+ )
201
+ return value as ClientRequest
202
+ case 'createTab':
203
+ assert(isString(value.payload.tabId), 'createTab.tabId must be a string')
204
+ assert(
205
+ isString(value.payload.assistant) && value.payload.assistant.length > 0,
206
+ 'createTab.assistant must be a non-empty string'
207
+ )
208
+ assert(isString(value.payload.title), 'createTab.title must be a string')
209
+ assert(isString(value.payload.command), 'createTab.command must be a string')
210
+ assert(
211
+ value.payload.args === undefined || isStringArray(value.payload.args),
212
+ 'createTab.args must be a string array'
213
+ )
214
+ assert(isFiniteNumber(value.payload.cols), 'createTab.cols must be a number')
215
+ assert(isFiniteNumber(value.payload.rows), 'createTab.rows must be a number')
216
+ assert(
217
+ value.payload.cwd === undefined || isString(value.payload.cwd),
218
+ 'createTab.cwd must be a string'
219
+ )
220
+ return value as ClientRequest
221
+ case 'write':
222
+ assert(isString(value.payload.tabId), 'write.tabId must be a string')
223
+ assert(isString(value.payload.data), 'write.data must be a string')
224
+ return value as ClientRequest
225
+ case 'resizeClient':
226
+ assert(isFiniteNumber(value.payload.cols), 'resizeClient.cols must be a number')
227
+ assert(isFiniteNumber(value.payload.rows), 'resizeClient.rows must be a number')
228
+ return value as ClientRequest
229
+ case 'resizeTab':
230
+ assert(isString(value.payload.tabId), 'resizeTab.tabId must be a string')
231
+ assert(isFiniteNumber(value.payload.cols), 'resizeTab.cols must be a number')
232
+ assert(isFiniteNumber(value.payload.rows), 'resizeTab.rows must be a number')
233
+ return value as ClientRequest
234
+ case 'scroll':
235
+ assert(isString(value.payload.tabId), 'scroll.tabId must be a string')
236
+ assert(isFiniteNumber(value.payload.deltaLines), 'scroll.deltaLines must be a number')
237
+ return value as ClientRequest
238
+ case 'scrollToBottom':
239
+ assert(isString(value.payload.tabId), 'scrollToBottom.tabId must be a string')
240
+ return value as ClientRequest
241
+ case 'setActiveTab':
242
+ assert(isNullableString(value.payload.tabId), 'setActiveTab.tabId must be a string or null')
243
+ return value as ClientRequest
244
+ case 'closeTab':
245
+ assert(isString(value.payload.tabId), 'closeTab.tabId must be a string')
246
+ return value as ClientRequest
247
+ case 'disposeAll':
248
+ case 'ping':
249
+ return value as ClientRequest
250
+ default:
251
+ throw new IpcProtocolError(`Unknown IPC request type: ${String(value.type)}`)
252
+ }
253
+ }
254
+
255
+ export function parseServerMessage(value: unknown): ServerResponse | ServerEvent {
256
+ assert(isObjectRecord(value), 'IPC message must be an object')
257
+ assert(isString(value.type), 'IPC response type must be a string')
258
+ assert(isObjectRecord(value.payload), 'IPC response payload must be an object')
259
+
260
+ switch (value.type) {
261
+ case 'ok':
262
+ assert(isString(value.id), 'ok.id must be a string')
263
+ return value as ServerResponse
264
+ case 'attachResult':
265
+ assert(isString(value.id), 'attachResult.id must be a string')
266
+ assert(isAttachResult(value.payload), 'attachResult.payload is invalid')
267
+ return value as ServerResponse
268
+ case 'error':
269
+ assert(isString(value.id), 'error.id must be a string')
270
+ assert(isString(value.payload.message), 'error.message must be a string')
271
+ return value as ServerResponse
272
+ case 'tabRender':
273
+ assert(isString(value.payload.tabId), 'tabRender.tabId must be a string')
274
+ assert(isTerminalSnapshot(value.payload.viewport), 'tabRender.viewport is invalid')
275
+ assert(isTerminalModeState(value.payload.terminalModes), 'tabRender.terminalModes is invalid')
276
+ return value as ServerEvent
277
+ case 'tabExit':
278
+ assert(isString(value.payload.tabId), 'tabExit.tabId must be a string')
279
+ assert(isFiniteNumber(value.payload.exitCode), 'tabExit.exitCode must be a number')
280
+ return value as ServerEvent
281
+ case 'tabError':
282
+ assert(isString(value.payload.tabId), 'tabError.tabId must be a string')
283
+ assert(isString(value.payload.message), 'tabError.message must be a string')
284
+ return value as ServerEvent
285
+ default:
286
+ throw new IpcProtocolError(`Unknown IPC response type: ${String(value.type)}`)
287
+ }
288
+ }
289
+
290
+ export function encodeMessage(message: IpcMessage): Buffer {
291
+ const payload = JSON.stringify(message)
292
+ return Buffer.from(`${Buffer.byteLength(payload, 'utf8')}\n${payload}`, 'utf8')
293
+ }
294
+
295
+ export class MessageDecoder<TMessage = IpcMessage> {
296
+ private buffer = Buffer.alloc(0)
297
+ private expectedPayloadBytes: number | null = null
298
+
299
+ constructor(
300
+ private readonly parseMessage: (value: unknown) => TMessage = (value) => value as TMessage
301
+ ) {}
302
+
303
+ push(chunk: string | Uint8Array): TMessage[] {
304
+ const nextChunk = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : Buffer.from(chunk)
305
+ this.buffer = this.buffer.length === 0 ? nextChunk : Buffer.concat([this.buffer, nextChunk])
306
+
307
+ const messages: TMessage[] = []
308
+ while (true) {
309
+ if (this.expectedPayloadBytes === null) {
310
+ const separatorIndex = this.buffer.indexOf(0x0a)
311
+ if (separatorIndex === -1) {
312
+ break
313
+ }
314
+
315
+ const header = this.buffer.subarray(0, separatorIndex).toString('utf8')
316
+ if (!/^\d+$/.test(header)) {
317
+ throw new Error(`Invalid IPC frame header: ${JSON.stringify(header)}`)
318
+ }
319
+
320
+ this.expectedPayloadBytes = Number.parseInt(header, 10)
321
+ this.buffer = this.buffer.subarray(separatorIndex + 1)
322
+ }
323
+
324
+ if (this.buffer.length < this.expectedPayloadBytes) {
325
+ break
326
+ }
327
+
328
+ const payload = this.buffer.subarray(0, this.expectedPayloadBytes).toString('utf8')
329
+ this.buffer = this.buffer.subarray(this.expectedPayloadBytes)
330
+ this.expectedPayloadBytes = null
331
+ messages.push(this.parseMessage(JSON.parse(payload)))
332
+ }
333
+
334
+ return messages
335
+ }
336
+
337
+ reset(): void {
338
+ this.buffer = Buffer.alloc(0)
339
+ this.expectedPayloadBytes = null
340
+ }
341
+ }
@@ -0,0 +1,13 @@
1
+ import { logDebug } from '../debug/input-log'
2
+
3
+ export function copyToSystemClipboard(text: string): void {
4
+ try {
5
+ const proc = Bun.spawn(['pbcopy'], { stdin: 'pipe' })
6
+ proc.stdin.write(text)
7
+ proc.stdin.end()
8
+ } catch (error) {
9
+ logDebug('platform.clipboard.copyError', {
10
+ error: error instanceof Error ? error.message : String(error),
11
+ })
12
+ }
13
+ }
@@ -0,0 +1,62 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ import { getDaemonSocketPath } from '../daemon/runtime-paths'
5
+ import { logDebug } from '../debug/input-log'
6
+
7
+ const ENTRY_POINT = resolve(import.meta.dir, '..', 'index.tsx')
8
+
9
+ export async function findDaemonPid(socketPath: string): Promise<number | null> {
10
+ try {
11
+ const proc = Bun.spawn(['lsof', '-t', socketPath], { stderr: 'ignore', stdout: 'pipe' })
12
+ const text = await new Response(proc.stdout).text()
13
+ const pid = parseInt(text.trim(), 10)
14
+ return Number.isFinite(pid) ? pid : null
15
+ } catch (error) {
16
+ logDebug('platform.daemon.findPidError', {
17
+ error: error instanceof Error ? error.message : String(error),
18
+ socketPath,
19
+ })
20
+ return null
21
+ }
22
+ }
23
+
24
+ export async function killDaemon(pid: number): Promise<void> {
25
+ process.kill(pid, 'SIGTERM')
26
+
27
+ const deadline = Date.now() + 3_000
28
+ while (Date.now() < deadline) {
29
+ try {
30
+ process.kill(pid, 0)
31
+ } catch {
32
+ return
33
+ }
34
+ await Bun.sleep(50)
35
+ }
36
+
37
+ try {
38
+ process.kill(pid, 'SIGKILL')
39
+ } catch {
40
+ // already gone
41
+ }
42
+ }
43
+
44
+ export async function spawnDetachedDaemon(): Promise<boolean> {
45
+ Bun.spawn([process.execPath, 'run', ENTRY_POINT, 'daemon'], {
46
+ detached: true,
47
+ stderr: 'ignore',
48
+ stdin: 'ignore',
49
+ stdout: 'ignore',
50
+ }).unref()
51
+
52
+ const deadline = Date.now() + 2_000
53
+ const socketPath = getDaemonSocketPath()
54
+ while (Date.now() < deadline) {
55
+ if (existsSync(socketPath)) {
56
+ return true
57
+ }
58
+ await Bun.sleep(50)
59
+ }
60
+
61
+ return false
62
+ }
@@ -0,0 +1,7 @@
1
+ const RANDOM_ID_START_INDEX = 2
2
+ const RANDOM_ID_END_INDEX = 8
3
+
4
+ export function createPrefixedId(prefix: string): string {
5
+ const suffix = Math.random().toString(36).slice(RANDOM_ID_START_INDEX, RANDOM_ID_END_INDEX)
6
+ return `${prefix}-${Date.now()}-${suffix}`
7
+ }