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