@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,75 @@
1
+ import { $ } from 'bun'
2
+ import { stat } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ import type { DirectoryResult } from '../state/types'
7
+
8
+ import { logDebug } from '../debug/input-log'
9
+
10
+ export async function searchProjectDirectories(query: string): Promise<DirectoryResult[]> {
11
+ if (!query.trim()) {
12
+ return []
13
+ }
14
+
15
+ const home = homedir()
16
+
17
+ try {
18
+ // Step 1: Find all git repos (nothrow: find exits 1 on macOS permission errors)
19
+ const gitResult =
20
+ await $`find ${home} -maxdepth 4 -name .git -not -path '*/node_modules/*' -not -path '*/target/*' -not -path '*/dist/*' 2>/dev/null`
21
+ .quiet()
22
+ .nothrow()
23
+ const repoPaths = gitResult
24
+ .text()
25
+ .trim()
26
+ .split('\n')
27
+ .filter((line) => line.length > 0)
28
+ .map((p) => p.replace(/\/\.git$/, ''))
29
+
30
+ // Step 2: Find workspace parents (dirs with 2+ child repos that aren't repos themselves)
31
+ const repoSet = new Set(repoPaths)
32
+ const parentCount = new Map<string, number>()
33
+ for (const repo of repoPaths) {
34
+ const parent = dirname(repo)
35
+ if (!repoSet.has(parent)) {
36
+ parentCount.set(parent, (parentCount.get(parent) ?? 0) + 1)
37
+ }
38
+ }
39
+ const workspacePaths = [...parentCount.entries()]
40
+ .filter(([, count]) => count >= 2)
41
+ .map(([p]) => p)
42
+ const workspaceSet = new Set(workspacePaths)
43
+
44
+ // Step 3: Combine and fuzzy filter
45
+ const allPaths = [...repoPaths, ...workspacePaths].join('\n')
46
+ const filtered =
47
+ await $`printf '%s' ${allPaths} | fzf --filter=${query} --no-sort | head -50`.quiet()
48
+ const resultPaths = filtered
49
+ .text()
50
+ .trim()
51
+ .split('\n')
52
+ .filter((line) => line.length > 0)
53
+
54
+ // Step 4: Classify each result (shortest paths first, limit to 10)
55
+ resultPaths.sort((a, b) => a.length - b.length)
56
+ resultPaths.splice(10)
57
+ return Promise.all(
58
+ resultPaths.map(async (path) => {
59
+ if (workspaceSet.has(path)) {
60
+ return { path, type: 'workspace' as const }
61
+ }
62
+ const gitPath = join(path, '.git')
63
+ const st = await stat(gitPath).catch(() => null)
64
+ const type = st !== null && st.isFile() ? 'worktree' : 'git-repo'
65
+ return { path, type } as const
66
+ })
67
+ )
68
+ } catch (error) {
69
+ logDebug('platform.projectSearch.error', {
70
+ error: error instanceof Error ? error.message : String(error),
71
+ query,
72
+ })
73
+ return []
74
+ }
75
+ }
@@ -0,0 +1,69 @@
1
+ import type { AssistantId } from '../state/types'
2
+
3
+ export interface AssistantOption {
4
+ id: AssistantId
5
+ label: string
6
+ command: string
7
+ description: string
8
+ }
9
+
10
+ const DEFAULT_SHELL = process.env.SHELL || 'sh'
11
+ const SHELL_NAME = DEFAULT_SHELL.split('/').pop() ?? 'shell'
12
+
13
+ export const ASSISTANT_OPTIONS: AssistantOption[] = [
14
+ {
15
+ command: 'claude',
16
+ description: 'Anthropic Claude CLI',
17
+ id: 'claude',
18
+ label: 'Claude',
19
+ },
20
+ {
21
+ command: 'codex',
22
+ description: 'OpenAI Codex CLI',
23
+ id: 'codex',
24
+ label: 'Codex',
25
+ },
26
+ {
27
+ command: 'opencode',
28
+ description: 'OpenCode CLI',
29
+ id: 'opencode',
30
+ label: 'OpenCode',
31
+ },
32
+ {
33
+ command: DEFAULT_SHELL,
34
+ description: `Plain terminal (${SHELL_NAME})`,
35
+ id: 'terminal',
36
+ label: 'Terminal',
37
+ },
38
+ ]
39
+
40
+ export function getAssistantOption(index: number): AssistantOption {
41
+ const option = ASSISTANT_OPTIONS[index] ?? ASSISTANT_OPTIONS[0]
42
+ if (!option) {
43
+ throw new Error('Assistant options are not configured.')
44
+ }
45
+
46
+ return option
47
+ }
48
+
49
+ export function getAllAssistantOptions(customCommands: Record<string, string>): AssistantOption[] {
50
+ const builtinIds = new Set(ASSISTANT_OPTIONS.map((o) => o.id))
51
+ const customOptions: AssistantOption[] = Object.entries(customCommands)
52
+ .filter(([id]) => !builtinIds.has(id))
53
+ .map(([id, command]) => ({
54
+ command,
55
+ description: `Custom (${command})`,
56
+ id,
57
+ label: id.charAt(0).toUpperCase() + id.slice(1),
58
+ }))
59
+ return [...ASSISTANT_OPTIONS, ...customOptions]
60
+ }
61
+
62
+ export function isCommandAvailable(command: string): boolean {
63
+ return Bun.which(command) !== null
64
+ }
65
+
66
+ export function parseCommand(commandString: string): { executable: string; args: string[] } {
67
+ const parts = commandString.trim().split(/\s+/).filter(Boolean)
68
+ return { args: parts.slice(1), executable: parts[0] ?? '' }
69
+ }
@@ -0,0 +1,268 @@
1
+ import { Terminal as XTerm } from '@xterm/headless'
2
+ import { type IPty, spawn } from 'bun-pty'
3
+ import { EventEmitter } from 'node:events'
4
+
5
+ import type { TerminalModeState, TerminalSnapshot } from '../state/types'
6
+
7
+ import { areTerminalSnapshotsEqual, snapshotTerminal } from './terminal-snapshot'
8
+
9
+ type PtyManagerEvents = {
10
+ render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
11
+ exit: [tabId: string, exitCode: number]
12
+ error: [tabId: string, message: string]
13
+ }
14
+
15
+ interface SessionHandle {
16
+ tabId: string
17
+ pty: IPty
18
+ emulator: XTerm
19
+ lastSnapshot?: TerminalSnapshot
20
+ lastTerminalModes?: TerminalModeState
21
+ alternateScrollMode: boolean
22
+ cursorVisible: boolean
23
+ pendingModeSequence: string
24
+ pendingWrites: number
25
+ pendingExitCode: number | null
26
+ }
27
+
28
+ const ESC = '\x1b'
29
+ const PRIVATE_MODE_RE = new RegExp(`${ESC}\\[\\?([0-9;]+)([hl])`, 'g')
30
+
31
+ function getPendingModeSequence(sequence: string): string {
32
+ const escapeIndex = sequence.lastIndexOf('\x1b')
33
+ if (escapeIndex === -1) {
34
+ return ''
35
+ }
36
+
37
+ const suffix = sequence.slice(escapeIndex)
38
+ return new RegExp(`^${ESC}(?:\\[\\??[0-9;]*)?$`).test(suffix) ? suffix : ''
39
+ }
40
+
41
+ function trackPrivateModes(
42
+ alternateScrollMode: boolean,
43
+ cursorVisible: boolean,
44
+ pendingSequence: string,
45
+ data: string
46
+ ): {
47
+ alternateScrollMode: boolean
48
+ cursorVisible: boolean
49
+ pendingSequence: string
50
+ } {
51
+ const sequence = `${pendingSequence}${data}`
52
+ let nextAlternateScrollMode = alternateScrollMode
53
+ let nextCursorVisible = cursorVisible
54
+ for (const match of sequence.matchAll(PRIVATE_MODE_RE)) {
55
+ const parameters = match[1]?.split(';') ?? []
56
+ if (parameters.includes('1007')) {
57
+ nextAlternateScrollMode = match[2] === 'h'
58
+ }
59
+ if (parameters.includes('25')) {
60
+ nextCursorVisible = match[2] === 'h'
61
+ }
62
+ }
63
+
64
+ return {
65
+ alternateScrollMode: nextAlternateScrollMode,
66
+ cursorVisible: nextCursorVisible,
67
+ pendingSequence: getPendingModeSequence(sequence),
68
+ }
69
+ }
70
+
71
+ function getTerminalModes(emulator: XTerm, alternateScrollMode: boolean): TerminalModeState {
72
+ return {
73
+ alternateScrollMode,
74
+ bracketedPasteMode: emulator.modes.bracketedPasteMode,
75
+ isAlternateBuffer: emulator.buffer.active === emulator.buffer.alternate,
76
+ mouseTrackingMode: emulator.modes.mouseTrackingMode,
77
+ sendFocusMode: emulator.modes.sendFocusMode,
78
+ }
79
+ }
80
+
81
+ export class PtyManager extends EventEmitter<PtyManagerEvents> {
82
+ private sessions = new Map<string, SessionHandle>()
83
+
84
+ private emitRenderIfChanged(session: SessionHandle): void {
85
+ const nextSnapshot = snapshotTerminal(session.emulator, session.cursorVisible)
86
+ const nextTerminalModes = getTerminalModes(session.emulator, session.alternateScrollMode)
87
+ const snapshotChanged = !areTerminalSnapshotsEqual(session.lastSnapshot, nextSnapshot)
88
+ const modesChanged =
89
+ !session.lastTerminalModes ||
90
+ session.lastTerminalModes.mouseTrackingMode !== nextTerminalModes.mouseTrackingMode ||
91
+ session.lastTerminalModes.sendFocusMode !== nextTerminalModes.sendFocusMode ||
92
+ session.lastTerminalModes.alternateScrollMode !== nextTerminalModes.alternateScrollMode ||
93
+ session.lastTerminalModes.isAlternateBuffer !== nextTerminalModes.isAlternateBuffer ||
94
+ session.lastTerminalModes.bracketedPasteMode !== nextTerminalModes.bracketedPasteMode
95
+
96
+ if (!snapshotChanged && !modesChanged) {
97
+ return
98
+ }
99
+
100
+ session.lastSnapshot = nextSnapshot
101
+ session.lastTerminalModes = nextTerminalModes
102
+ this.emit('render', session.tabId, nextSnapshot, nextTerminalModes)
103
+ }
104
+
105
+ private finalizeSession(session: SessionHandle, exitCode: number): void {
106
+ const current = this.sessions.get(session.tabId)
107
+ if (current !== session) {
108
+ return
109
+ }
110
+
111
+ this.sessions.delete(session.tabId)
112
+ this.emitRenderIfChanged(session)
113
+ session.emulator.dispose()
114
+ this.emit('exit', session.tabId, exitCode)
115
+ }
116
+
117
+ createSession(options: {
118
+ tabId: string
119
+ command: string
120
+ args?: string[]
121
+ cols: number
122
+ rows: number
123
+ cwd?: string
124
+ }): void {
125
+ this.disposeSession(options.tabId)
126
+
127
+ try {
128
+ const emulator = new XTerm({
129
+ allowProposedApi: true,
130
+ cols: options.cols,
131
+ rows: options.rows,
132
+ scrollback: 1000,
133
+ })
134
+
135
+ const pty = spawn(options.command, options.args ?? [], {
136
+ cols: options.cols,
137
+ cwd: options.cwd ?? process.cwd(),
138
+ env: {
139
+ ...process.env,
140
+ TERM: 'xterm-256color',
141
+ },
142
+ name: 'xterm-256color',
143
+ rows: options.rows,
144
+ })
145
+
146
+ const session: SessionHandle = {
147
+ alternateScrollMode: false,
148
+ cursorVisible: true,
149
+ emulator,
150
+ lastSnapshot: undefined,
151
+ lastTerminalModes: undefined,
152
+ pendingExitCode: null,
153
+ pendingModeSequence: '',
154
+ pendingWrites: 0,
155
+ pty,
156
+ tabId: options.tabId,
157
+ }
158
+
159
+ pty.onData((data) => {
160
+ const trackedModes = trackPrivateModes(
161
+ session.alternateScrollMode,
162
+ session.cursorVisible,
163
+ session.pendingModeSequence,
164
+ data
165
+ )
166
+ session.alternateScrollMode = trackedModes.alternateScrollMode
167
+ session.cursorVisible = trackedModes.cursorVisible
168
+ session.pendingModeSequence = trackedModes.pendingSequence
169
+ session.pendingWrites += 1
170
+ emulator.write(data, () => {
171
+ session.pendingWrites -= 1
172
+ this.emitRenderIfChanged(session)
173
+
174
+ if (session.pendingWrites === 0 && session.pendingExitCode !== null) {
175
+ this.finalizeSession(session, session.pendingExitCode)
176
+ }
177
+ })
178
+ })
179
+
180
+ pty.onExit(({ exitCode }) => {
181
+ const current = this.sessions.get(options.tabId)
182
+ if (!current || current.pty !== pty) {
183
+ return
184
+ }
185
+
186
+ if (session.pendingWrites > 0) {
187
+ session.pendingExitCode = exitCode
188
+ return
189
+ }
190
+
191
+ this.finalizeSession(session, exitCode)
192
+ })
193
+
194
+ this.sessions.set(options.tabId, session)
195
+ this.emitRenderIfChanged(session)
196
+ } catch (error) {
197
+ const message = error instanceof Error ? error.message : String(error)
198
+ this.emit('error', options.tabId, `Failed to start session: ${message}`)
199
+ }
200
+ }
201
+
202
+ write(tabId: string, input: string): void {
203
+ this.sessions.get(tabId)?.pty.write(input)
204
+ }
205
+
206
+ scrollViewport(tabId: string, deltaLines: number): void {
207
+ const session = this.sessions.get(tabId)
208
+ if (!session) {
209
+ return
210
+ }
211
+
212
+ session.emulator.scrollLines(deltaLines)
213
+ this.emitRenderIfChanged(session)
214
+ }
215
+
216
+ scrollViewportToBottom(tabId: string): void {
217
+ const session = this.sessions.get(tabId)
218
+ if (!session) {
219
+ return
220
+ }
221
+
222
+ session.emulator.scrollToBottom()
223
+ this.emitRenderIfChanged(session)
224
+ }
225
+
226
+ resizeAll(cols: number, rows: number): void {
227
+ const safeCols = Math.max(20, cols)
228
+ const safeRows = Math.max(8, rows)
229
+
230
+ for (const session of this.sessions.values()) {
231
+ session.pty.resize(safeCols, safeRows)
232
+ session.emulator.resize(safeCols, safeRows)
233
+ this.emitRenderIfChanged(session)
234
+ }
235
+ }
236
+
237
+ resizeSession(tabId: string, cols: number, rows: number): void {
238
+ const session = this.sessions.get(tabId)
239
+ if (!session) {
240
+ return
241
+ }
242
+ const safeCols = Math.max(20, cols)
243
+ const safeRows = Math.max(8, rows)
244
+ session.pty.resize(safeCols, safeRows)
245
+ session.emulator.resize(safeCols, safeRows)
246
+ this.emitRenderIfChanged(session)
247
+ }
248
+
249
+ disposeSession(tabId: string): void {
250
+ const session = this.sessions.get(tabId)
251
+ if (!session) {
252
+ return
253
+ }
254
+
255
+ this.sessions.delete(tabId)
256
+ session.pty.kill()
257
+ session.emulator.dispose()
258
+ }
259
+
260
+ disposeAll(): void {
261
+ for (const session of this.sessions.values()) {
262
+ session.pty.kill()
263
+ session.emulator.dispose()
264
+ }
265
+
266
+ this.sessions.clear()
267
+ }
268
+ }
@@ -0,0 +1,210 @@
1
+ import type { Terminal } from '@xterm/headless'
2
+
3
+ import type { TerminalLine, TerminalSnapshot, TerminalSpan } from '../state/types'
4
+
5
+ import { theme } from '../ui/theme'
6
+
7
+ const ANSI_PALETTE = [
8
+ '#000000',
9
+ '#cd0000',
10
+ '#00cd00',
11
+ '#cdcd00',
12
+ '#0000ee',
13
+ '#cd00cd',
14
+ '#00cdcd',
15
+ '#e5e5e5',
16
+ '#7f7f7f',
17
+ '#ff0000',
18
+ '#00ff00',
19
+ '#ffff00',
20
+ '#5c5cff',
21
+ '#ff00ff',
22
+ '#00ffff',
23
+ '#ffffff',
24
+ ]
25
+
26
+ function toHex(value: number): string {
27
+ return `#${value.toString(16).padStart(6, '0')}`
28
+ }
29
+
30
+ function paletteToHex(index: number): string {
31
+ if (index < ANSI_PALETTE.length) {
32
+ return ANSI_PALETTE[index] ?? ANSI_PALETTE[0] ?? '#000000'
33
+ }
34
+
35
+ if (index >= 232) {
36
+ const shade = 8 + (index - 232) * 10
37
+ return toHex((shade << 16) | (shade << 8) | shade)
38
+ }
39
+
40
+ const normalized = index - 16
41
+ const r = Math.floor(normalized / 36)
42
+ const g = Math.floor((normalized % 36) / 6)
43
+ const b = normalized % 6
44
+ const channel = [0, 95, 135, 175, 215, 255]
45
+
46
+ return toHex(((channel[r] ?? 0) << 16) | ((channel[g] ?? 0) << 8) | (channel[b] ?? 0))
47
+ }
48
+
49
+ function getColorHex(color: number, mode: 'rgb' | 'palette' | 'default'): string | undefined {
50
+ if (mode === 'default') {
51
+ return undefined
52
+ }
53
+
54
+ return mode === 'rgb' ? toHex(color) : paletteToHex(color)
55
+ }
56
+
57
+ function getColorMode(isRgb: boolean, isPalette: boolean): 'rgb' | 'palette' | 'default' {
58
+ if (isRgb) {
59
+ return 'rgb'
60
+ }
61
+
62
+ if (isPalette) {
63
+ return 'palette'
64
+ }
65
+
66
+ return 'default'
67
+ }
68
+
69
+ function pushSpan(spans: TerminalSpan[], span: TerminalSpan): void {
70
+ const previous = spans.at(-1)
71
+ if (
72
+ previous &&
73
+ previous.fg === span.fg &&
74
+ previous.bg === span.bg &&
75
+ previous.bold === span.bold &&
76
+ previous.italic === span.italic &&
77
+ previous.underline === span.underline &&
78
+ previous.cursor === span.cursor
79
+ ) {
80
+ previous.text += span.text
81
+ return
82
+ }
83
+
84
+ spans.push(span)
85
+ }
86
+
87
+ function buildLine(
88
+ terminal: Terminal,
89
+ lineIndex: number,
90
+ cursorColumn: number | null,
91
+ cursorVisible: boolean
92
+ ): TerminalLine {
93
+ const line = terminal.buffer.active.getLine(lineIndex)
94
+
95
+ if (!line) {
96
+ return { spans: [] }
97
+ }
98
+
99
+ const cell = terminal.buffer.active.getNullCell()
100
+ const spans: TerminalSpan[] = []
101
+
102
+ for (let column = 0; column < terminal.cols; column += 1) {
103
+ const current = line.getCell(column, cell)
104
+ if (!current || current.getWidth() === 0) {
105
+ continue
106
+ }
107
+
108
+ const text = current.getChars() || ' '
109
+ const fgMode = getColorMode(current.isFgRGB(), current.isFgPalette())
110
+ const bgMode = getColorMode(current.isBgRGB(), current.isBgPalette())
111
+
112
+ let fg = getColorHex(current.getFgColor(), fgMode)
113
+ let bg = getColorHex(current.getBgColor(), bgMode)
114
+
115
+ if (current.isInverse()) {
116
+ const resolvedFg = fg ?? theme.text
117
+ const resolvedBg = bg ?? theme.background
118
+ ;[fg, bg] = [resolvedBg, resolvedFg]
119
+ }
120
+
121
+ const isCursorCell = cursorVisible && cursorColumn === column
122
+ if (isCursorCell) {
123
+ const resolvedFg = fg ?? theme.text
124
+ const resolvedBg = bg ?? theme.background
125
+ ;[fg, bg] = [resolvedBg, resolvedFg]
126
+ }
127
+
128
+ pushSpan(spans, {
129
+ bg,
130
+ bold: current.isBold() ? true : undefined,
131
+ cursor: isCursorCell ? true : undefined,
132
+ fg,
133
+ italic: current.isItalic() ? true : undefined,
134
+ text,
135
+ underline: current.isUnderline() ? true : undefined,
136
+ })
137
+ }
138
+
139
+ return { spans }
140
+ }
141
+
142
+ export function snapshotTerminal(terminal: Terminal, cursorVisible = true): TerminalSnapshot {
143
+ const buffer = terminal.buffer.active
144
+ const startLine = buffer.viewportY
145
+ const cursorRow = buffer.cursorY
146
+ const cursorColumn = Math.min(buffer.cursorX, Math.max(terminal.cols - 1, 0))
147
+ const lines: TerminalLine[] = []
148
+
149
+ for (let row = 0; row < terminal.rows; row += 1) {
150
+ lines.push(
151
+ buildLine(terminal, startLine + row, row === cursorRow ? cursorColumn : null, cursorVisible)
152
+ )
153
+ }
154
+
155
+ return {
156
+ baseY: buffer.baseY,
157
+ cursorVisible,
158
+ lines,
159
+ viewportY: buffer.viewportY,
160
+ }
161
+ }
162
+
163
+ function areSpansEqual(left: TerminalSpan, right: TerminalSpan): boolean {
164
+ return (
165
+ left.text === right.text &&
166
+ left.fg === right.fg &&
167
+ left.bg === right.bg &&
168
+ left.bold === right.bold &&
169
+ left.italic === right.italic &&
170
+ left.underline === right.underline &&
171
+ left.cursor === right.cursor
172
+ )
173
+ }
174
+
175
+ function areLinesEqual(left: TerminalLine, right: TerminalLine): boolean {
176
+ if (left.spans.length !== right.spans.length) {
177
+ return false
178
+ }
179
+
180
+ return left.spans.every((span, index) => {
181
+ const other = right.spans[index]
182
+ return other ? areSpansEqual(span, other) : false
183
+ })
184
+ }
185
+
186
+ export function areTerminalSnapshotsEqual(
187
+ left?: TerminalSnapshot,
188
+ right?: TerminalSnapshot
189
+ ): boolean {
190
+ if (!left || !right) {
191
+ return left === right
192
+ }
193
+
194
+ if (left.lines.length !== right.lines.length) {
195
+ return false
196
+ }
197
+
198
+ if (
199
+ left.viewportY !== right.viewportY ||
200
+ left.baseY !== right.baseY ||
201
+ left.cursorVisible !== right.cursorVisible
202
+ ) {
203
+ return false
204
+ }
205
+
206
+ return left.lines.every((line, index) => {
207
+ const other = right.lines[index]
208
+ return other ? areLinesEqual(line, other) : false
209
+ })
210
+ }
@@ -0,0 +1,28 @@
1
+ import { getDaemonSocketPath, removeDaemonSocketIfExists } from './daemon/runtime-paths'
2
+ import { findDaemonPid, killDaemon, spawnDetachedDaemon } from './platform/daemon-control'
3
+
4
+ export async function runRestartDaemon(): Promise<number> {
5
+ const socketPath = getDaemonSocketPath()
6
+ const pid = await findDaemonPid(socketPath)
7
+
8
+ if (pid !== null) {
9
+ process.stdout.write(`Stopping daemon (pid ${pid})...\n`)
10
+ await killDaemon(pid)
11
+ process.stdout.write('Daemon stopped.\n')
12
+ } else {
13
+ process.stdout.write('No running daemon found.\n')
14
+ }
15
+
16
+ removeDaemonSocketIfExists()
17
+
18
+ process.stdout.write('Starting daemon...\n')
19
+ const ok = await spawnDetachedDaemon()
20
+
21
+ if (ok) {
22
+ process.stdout.write('Daemon started.\n')
23
+ return 0
24
+ }
25
+
26
+ process.stderr.write('Failed to start daemon.\n')
27
+ return 1
28
+ }