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