@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,69 @@
1
+ import { chmodSync, constants, existsSync, lstatSync, mkdirSync, unlinkSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ function getRuntimeBaseDir(): string {
5
+ if (process.env.XDG_RUNTIME_DIR) {
6
+ return join(process.env.XDG_RUNTIME_DIR, 'aimux')
7
+ }
8
+
9
+ return join(process.env.HOME ?? '.', '.local', 'state', 'aimux')
10
+ }
11
+
12
+ export function ensureRuntimeDir(): string {
13
+ const dir = getRuntimeBaseDir()
14
+ mkdirSync(dir, { recursive: true })
15
+ try {
16
+ chmodSync(dir, 0o700)
17
+ } catch {
18
+ // best-effort permission tightening
19
+ }
20
+ return dir
21
+ }
22
+
23
+ export function getDaemonSocketPath(): string {
24
+ return join(ensureRuntimeDir(), 'daemon.sock')
25
+ }
26
+
27
+ export function ensureParentDir(filePath: string): void {
28
+ mkdirSync(dirname(filePath), { recursive: true })
29
+ }
30
+
31
+ export function tightenDaemonSocketPermissions(socketPath: string): void {
32
+ try {
33
+ chmodSync(socketPath, 0o600)
34
+ } catch {
35
+ // best-effort permission tightening
36
+ }
37
+ }
38
+
39
+ export function getDaemonSocketSecurityIssue(socketPath: string): string | null {
40
+ if (!existsSync(socketPath)) {
41
+ return 'socket missing'
42
+ }
43
+
44
+ try {
45
+ const stats = lstatSync(socketPath)
46
+ if (!stats.isSocket()) {
47
+ return 'path is not a socket'
48
+ }
49
+
50
+ if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) {
51
+ return 'socket owner does not match current user'
52
+ }
53
+
54
+ if ((stats.mode & constants.S_IWGRP) !== 0 || (stats.mode & constants.S_IWOTH) !== 0) {
55
+ return 'socket is writable by group or others'
56
+ }
57
+
58
+ return null
59
+ } catch (error) {
60
+ return error instanceof Error ? error.message : String(error)
61
+ }
62
+ }
63
+
64
+ export function removeDaemonSocketIfExists(): void {
65
+ const socketPath = getDaemonSocketPath()
66
+ if (existsSync(socketPath)) {
67
+ unlinkSync(socketPath)
68
+ }
69
+ }
@@ -0,0 +1,109 @@
1
+ import { EventEmitter } from 'node:events'
2
+
3
+ import type { TerminalModeState, TerminalSnapshot, WorkspaceSnapshotV1 } from '../state/types'
4
+
5
+ import { logDebug } from '../debug/input-log'
6
+ import { SessionRegistry } from './session-registry'
7
+
8
+ type SessionManagerEvents = {
9
+ render: [
10
+ sessionId: string,
11
+ tabId: string,
12
+ viewport: TerminalSnapshot,
13
+ terminalModes: TerminalModeState,
14
+ ]
15
+ exit: [sessionId: string, tabId: string, exitCode: number]
16
+ error: [sessionId: string, tabId: string, message: string]
17
+ }
18
+
19
+ export class SessionManager extends EventEmitter<SessionManagerEvents> {
20
+ private registries = new Map<string, SessionRegistry>()
21
+
22
+ private getOrCreateRegistry(sessionId: string): SessionRegistry {
23
+ const existing = this.registries.get(sessionId)
24
+ if (existing) {
25
+ return existing
26
+ }
27
+
28
+ const registry = new SessionRegistry()
29
+ registry.on('render', (tabId, viewport, terminalModes) => {
30
+ this.emit('render', sessionId, tabId, viewport, terminalModes)
31
+ })
32
+ registry.on('exit', (tabId, exitCode) => {
33
+ this.emit('exit', sessionId, tabId, exitCode)
34
+ })
35
+ registry.on('error', (tabId, message) => {
36
+ this.emit('error', sessionId, tabId, message)
37
+ })
38
+ this.registries.set(sessionId, registry)
39
+ return registry
40
+ }
41
+
42
+ attachSession(sessionId: string, snapshot?: WorkspaceSnapshotV1) {
43
+ logDebug('daemon.sessionManager.attachSession', {
44
+ hasSnapshot: !!snapshot,
45
+ sessionId,
46
+ snapshotTabs: snapshot?.tabs.length ?? 0,
47
+ })
48
+ return this.getOrCreateRegistry(sessionId).attachFromSnapshot(snapshot)
49
+ }
50
+
51
+ createTab(sessionId: string, options: Parameters<SessionRegistry['createSession']>[0]): void {
52
+ logDebug('daemon.sessionManager.createTab', {
53
+ command: options.command,
54
+ sessionId,
55
+ tabId: options.tabId,
56
+ title: options.title,
57
+ })
58
+ this.getOrCreateRegistry(sessionId).createSession(options)
59
+ }
60
+
61
+ write(sessionId: string, tabId: string, data: string): void {
62
+ this.getOrCreateRegistry(sessionId).write(tabId, data)
63
+ }
64
+
65
+ resize(sessionId: string, cols: number, rows: number): void {
66
+ this.getOrCreateRegistry(sessionId).resizeAll(cols, rows)
67
+ }
68
+
69
+ resizeTab(sessionId: string, tabId: string, cols: number, rows: number): void {
70
+ this.getOrCreateRegistry(sessionId).resizeTab(tabId, cols, rows)
71
+ }
72
+
73
+ scroll(sessionId: string, tabId: string, deltaLines: number): void {
74
+ this.getOrCreateRegistry(sessionId).scrollViewport(tabId, deltaLines)
75
+ }
76
+
77
+ scrollToBottom(sessionId: string, tabId: string): void {
78
+ this.getOrCreateRegistry(sessionId).scrollViewportToBottom(tabId)
79
+ }
80
+
81
+ setActiveTab(sessionId: string, tabId: string | null): void {
82
+ this.getOrCreateRegistry(sessionId).setActiveTab(tabId)
83
+ }
84
+
85
+ closeTab(sessionId: string, tabId: string): void {
86
+ this.getOrCreateRegistry(sessionId).closeTab(tabId)
87
+ }
88
+
89
+ disposeSession(sessionId: string): void {
90
+ logDebug('daemon.sessionManager.disposeSession', {
91
+ hadRegistry: this.registries.has(sessionId),
92
+ sessionId,
93
+ })
94
+ const registry = this.registries.get(sessionId)
95
+ if (!registry) {
96
+ return
97
+ }
98
+ registry.disposeAll()
99
+ this.registries.delete(sessionId)
100
+ }
101
+
102
+ disposeAll(): void {
103
+ logDebug('daemon.sessionManager.disposeAll', { sessionCount: this.registries.size })
104
+ for (const registry of this.registries.values()) {
105
+ registry.disposeAll()
106
+ }
107
+ this.registries.clear()
108
+ }
109
+ }
@@ -0,0 +1,207 @@
1
+ import { EventEmitter } from 'node:events'
2
+
3
+ import type {
4
+ TabSession,
5
+ TerminalModeState,
6
+ TerminalSnapshot,
7
+ WorkspaceSnapshotV1,
8
+ } from '../state/types'
9
+
10
+ import { logDebug } from '../debug/input-log'
11
+ import { PtyManager } from '../pty/pty-manager'
12
+ import {
13
+ normalizeGroupedTabOrder,
14
+ restoreLayoutTrees,
15
+ restoreTabsFromWorkspace,
16
+ } from '../state/session-persistence'
17
+ import { createDefaultTerminalModes } from '../state/terminal-modes'
18
+
19
+ type SessionRegistryEvents = {
20
+ render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
21
+ exit: [tabId: string, exitCode: number]
22
+ error: [tabId: string, message: string]
23
+ }
24
+
25
+ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
26
+ private readonly ptyManager = new PtyManager()
27
+ private tabs = new Map<string, TabSession>()
28
+ private activeTabId: string | null = null
29
+
30
+ constructor() {
31
+ super()
32
+ this.ptyManager.on('render', (tabId, viewport, terminalModes) => {
33
+ const tab = this.tabs.get(tabId)
34
+ if (!tab) {
35
+ return
36
+ }
37
+ tab.viewport = viewport
38
+ tab.terminalModes = terminalModes
39
+ if (tab.status === 'starting') {
40
+ tab.status = 'running'
41
+ }
42
+ logDebug('daemon.registry.render', { status: tab.status, tabId })
43
+ this.emit('render', tabId, viewport, terminalModes)
44
+ })
45
+ this.ptyManager.on('exit', (tabId, exitCode) => {
46
+ const tab = this.tabs.get(tabId)
47
+ if (!tab) {
48
+ return
49
+ }
50
+ tab.status = 'exited'
51
+ tab.exitCode = exitCode
52
+ tab.activity = undefined
53
+ logDebug('daemon.registry.exit', { exitCode, tabId })
54
+ this.emit('exit', tabId, exitCode)
55
+ })
56
+ this.ptyManager.on('error', (tabId, message) => {
57
+ const tab = this.tabs.get(tabId)
58
+ if (tab) {
59
+ tab.status = 'error'
60
+ tab.errorMessage = message
61
+ tab.activity = undefined
62
+ }
63
+ logDebug('daemon.registry.error', { message, tabId })
64
+ this.emit('error', tabId, message)
65
+ })
66
+ }
67
+
68
+ attachFromSnapshot(snapshot: WorkspaceSnapshotV1 | undefined): {
69
+ tabs: TabSession[]
70
+ activeTabId: string | null
71
+ } {
72
+ logDebug('daemon.registry.attach', {
73
+ existingTabs: this.tabs.size,
74
+ hasSnapshot: !!snapshot,
75
+ snapshotTabs: snapshot?.tabs.length ?? 0,
76
+ })
77
+ if (this.tabs.size === 0 && snapshot) {
78
+ const restoredTabs = restoreTabsFromWorkspace(snapshot)
79
+ for (const tab of restoredTabs) {
80
+ this.tabs.set(tab.id, tab)
81
+ }
82
+ this.activeTabId =
83
+ snapshot.activeTabId && restoredTabs.some((tab) => tab.id === snapshot.activeTabId)
84
+ ? snapshot.activeTabId
85
+ : (restoredTabs[0]?.id ?? null)
86
+ } else if (this.tabs.size > 0 && snapshot) {
87
+ for (const persisted of snapshot.tabs) {
88
+ const existing = this.tabs.get(persisted.id)
89
+ if (existing) {
90
+ existing.title = persisted.title
91
+ }
92
+ }
93
+ if (snapshot.activeTabId && this.tabs.has(snapshot.activeTabId)) {
94
+ this.activeTabId = snapshot.activeTabId
95
+ }
96
+ }
97
+
98
+ const tabs = this.listTabs()
99
+ if (!snapshot) {
100
+ return { activeTabId: this.activeTabId, tabs }
101
+ }
102
+
103
+ const { layoutTrees, tabGroupMap } = restoreLayoutTrees(snapshot, tabs)
104
+ const orderedSnapshotTabs = snapshot.tabs
105
+ .map((persistedTab) => this.tabs.get(persistedTab.id))
106
+ .filter((tab): tab is TabSession => tab !== undefined)
107
+ const normalizedTabs = normalizeGroupedTabOrder(orderedSnapshotTabs, layoutTrees, tabGroupMap)
108
+
109
+ return { activeTabId: this.activeTabId, tabs: normalizedTabs }
110
+ }
111
+
112
+ listTabs(): TabSession[] {
113
+ return [...this.tabs.values()]
114
+ }
115
+
116
+ createSession(options: {
117
+ tabId: string
118
+ assistant: TabSession['assistant']
119
+ title: string
120
+ command: string
121
+ args?: string[]
122
+ cols: number
123
+ rows: number
124
+ cwd?: string
125
+ }): void {
126
+ logDebug('daemon.registry.createSession', {
127
+ args: options.args ?? [],
128
+ assistant: options.assistant,
129
+ command: options.command,
130
+ tabId: options.tabId,
131
+ title: options.title,
132
+ })
133
+ const existing = this.tabs.get(options.tabId)
134
+ if (!existing) {
135
+ this.tabs.set(options.tabId, {
136
+ activity: 'idle',
137
+ assistant: options.assistant,
138
+ buffer: '',
139
+ command: [options.command, ...(options.args ?? [])].join(' '),
140
+ id: options.tabId,
141
+ status: 'starting',
142
+ terminalModes: createDefaultTerminalModes(),
143
+ title: options.title,
144
+ })
145
+ } else {
146
+ existing.status = 'starting'
147
+ existing.activity = 'idle'
148
+ existing.errorMessage = undefined
149
+ existing.exitCode = undefined
150
+ existing.viewport = undefined
151
+ existing.terminalModes = createDefaultTerminalModes()
152
+ existing.assistant = options.assistant
153
+ existing.title = options.title
154
+ existing.command = [options.command, ...(options.args ?? [])].join(' ')
155
+ }
156
+
157
+ this.activeTabId = options.tabId
158
+ this.ptyManager.createSession(options)
159
+ }
160
+
161
+ write(tabId: string, data: string): void {
162
+ this.ptyManager.write(tabId, data)
163
+ }
164
+
165
+ resizeAll(cols: number, rows: number): void {
166
+ this.ptyManager.resizeAll(cols, rows)
167
+ }
168
+
169
+ resizeTab(tabId: string, cols: number, rows: number): void {
170
+ this.ptyManager.resizeSession(tabId, cols, rows)
171
+ }
172
+
173
+ scrollViewport(tabId: string, deltaLines: number): void {
174
+ this.ptyManager.scrollViewport(tabId, deltaLines)
175
+ }
176
+
177
+ scrollViewportToBottom(tabId: string): void {
178
+ this.ptyManager.scrollViewportToBottom(tabId)
179
+ }
180
+
181
+ setActiveTab(tabId: string | null): void {
182
+ if (tabId === null) {
183
+ this.activeTabId = null
184
+ return
185
+ }
186
+
187
+ if (this.tabs.has(tabId)) {
188
+ this.activeTabId = tabId
189
+ }
190
+ }
191
+
192
+ closeTab(tabId: string): void {
193
+ logDebug('daemon.registry.closeTab', { tabId })
194
+ this.ptyManager.disposeSession(tabId)
195
+ this.tabs.delete(tabId)
196
+ if (this.activeTabId === tabId) {
197
+ this.activeTabId = this.listTabs()[0]?.id ?? null
198
+ }
199
+ }
200
+
201
+ disposeAll(): void {
202
+ logDebug('daemon.registry.disposeAll', { tabCount: this.tabs.size })
203
+ this.ptyManager.disposeAll()
204
+ this.tabs.clear()
205
+ this.activeTabId = null
206
+ }
207
+ }
@@ -0,0 +1,29 @@
1
+ import { appendFileSync } from 'node:fs'
2
+
3
+ const INPUT_DEBUG_LOG_PATH = process.env.AIMUX_INPUT_DEBUG_LOG_PATH ?? '/tmp/aimux-input-debug.log'
4
+
5
+ export function isInputDebugEnabled(): boolean {
6
+ const value = process.env.AIMUX_DEBUG_INPUT?.trim().toLowerCase()
7
+ return value === '1' || value === 'true' || value === 'yes' || value === 'on'
8
+ }
9
+
10
+ function serialize(value: unknown): string {
11
+ return JSON.stringify(value)
12
+ }
13
+
14
+ export function logInputDebug(event: string, details?: Record<string, unknown>): void {
15
+ if (!isInputDebugEnabled()) {
16
+ return
17
+ }
18
+
19
+ try {
20
+ const line = `${new Date().toISOString()} ${event}${details ? ` ${serialize(details)}` : ''}\n`
21
+ appendFileSync(INPUT_DEBUG_LOG_PATH, line)
22
+ } catch {
23
+ // Best-effort debug logging only.
24
+ }
25
+ }
26
+
27
+ export const logDebug = logInputDebug
28
+
29
+ export { INPUT_DEBUG_LOG_PATH }
package/src/doctor.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { existsSync } from 'node:fs'
2
+
3
+ import { CONFIG_PATH, loadConfigResult } from './config'
4
+ import { ASSISTANT_OPTIONS, isCommandAvailable, parseCommand } from './pty/command-registry'
5
+
6
+ export interface DoctorCheck {
7
+ name: string
8
+ ok: boolean
9
+ details: string
10
+ }
11
+
12
+ export interface DoctorReport {
13
+ checks: DoctorCheck[]
14
+ }
15
+
16
+ function formatStatus(ok: boolean): string {
17
+ return ok ? 'OK' : 'WARN'
18
+ }
19
+
20
+ function getConfigDetails(configResult: ReturnType<typeof loadConfigResult>): string {
21
+ if (configResult.issues.length > 0) {
22
+ return configResult.issues.join('; ')
23
+ }
24
+
25
+ if (existsSync(CONFIG_PATH)) {
26
+ return `loaded ${CONFIG_PATH}`
27
+ }
28
+
29
+ return `using defaults (${CONFIG_PATH} not found)`
30
+ }
31
+
32
+ function getAssistantDetails(
33
+ configuredCommand: string,
34
+ args: string[],
35
+ executable: string
36
+ ): string {
37
+ if (executable.length === 0) {
38
+ return 'empty command'
39
+ }
40
+
41
+ return args.length > 0 ? `${configuredCommand} (${args.length} args)` : configuredCommand
42
+ }
43
+
44
+ function getSummaryLine(failedChecks: DoctorCheck[]): string {
45
+ if (failedChecks.length === 0) {
46
+ return 'All core checks passed.'
47
+ }
48
+
49
+ return `${failedChecks.length} check(s) need attention before aimux will work reliably.`
50
+ }
51
+
52
+ export function buildDoctorReport(): DoctorReport {
53
+ const configResult = loadConfigResult()
54
+ const config = configResult.config
55
+ const checks: DoctorCheck[] = []
56
+
57
+ checks.push({
58
+ details: `${process.platform} ${process.arch}`,
59
+ name: 'platform',
60
+ ok: process.platform === 'darwin' || process.platform === 'linux',
61
+ })
62
+
63
+ checks.push({
64
+ details: Bun.version,
65
+ name: 'bun',
66
+ ok: typeof Bun.version === 'string' && Bun.version.length > 0,
67
+ })
68
+
69
+ checks.push({
70
+ details: getConfigDetails(configResult),
71
+ name: 'config',
72
+ ok: configResult.issues.length === 0,
73
+ })
74
+
75
+ for (const option of ASSISTANT_OPTIONS) {
76
+ const configuredCommand = config.customCommands[option.id] ?? option.command
77
+ const { args, executable } = parseCommand(configuredCommand)
78
+ checks.push({
79
+ details: getAssistantDetails(configuredCommand, args, executable),
80
+ name: `assistant:${option.id}`,
81
+ ok: executable.length > 0 && isCommandAvailable(executable),
82
+ })
83
+ }
84
+
85
+ return { checks }
86
+ }
87
+
88
+ export function formatDoctorReport(report: DoctorReport): string {
89
+ const lines = ['aimux doctor', '']
90
+
91
+ for (const check of report.checks) {
92
+ lines.push(`${formatStatus(check.ok).padEnd(4)} ${check.name} - ${check.details}`)
93
+ }
94
+
95
+ const failedChecks = report.checks.filter((check) => !check.ok)
96
+ lines.push('')
97
+ lines.push(getSummaryLine(failedChecks))
98
+
99
+ return lines.join('\n')
100
+ }
101
+
102
+ export function runDoctor(): number {
103
+ const report = buildDoctorReport()
104
+ process.stdout.write(`${formatDoctorReport(report)}\n`)
105
+ return report.checks.every((check) => check.ok) ? 0 : 1
106
+ }
@@ -0,0 +1,49 @@
1
+ import { useEffect } from 'react'
2
+
3
+ import { appStore } from '../state/app-store'
4
+ import { collectGitStatus } from './git-status'
5
+
6
+ const BASE_INTERVAL_MS = 1000
7
+ const MAX_INTERVAL_MS = 30_000
8
+
9
+ interface Options {
10
+ enabled: boolean
11
+ projectPath: string | undefined
12
+ }
13
+
14
+ export function useGitPanelPolling({ enabled, projectPath }: Options): void {
15
+ useEffect(() => {
16
+ if (!enabled || !projectPath) return undefined
17
+
18
+ appStore.getState().dispatch({ type: 'git-panel-reset' })
19
+
20
+ let cancelled = false
21
+ let timer: ReturnType<typeof setTimeout> | null = null
22
+ let delay = BASE_INTERVAL_MS
23
+
24
+ const schedule = () => {
25
+ if (cancelled) return
26
+ timer = setTimeout(() => void tick(), delay)
27
+ }
28
+
29
+ const tick = async () => {
30
+ const result = await collectGitStatus(projectPath)
31
+ if (cancelled) return
32
+ if (result.kind === 'ok') {
33
+ appStore.getState().dispatch({ payload: result.payload, type: 'git-refresh-success' })
34
+ delay = BASE_INTERVAL_MS
35
+ } else {
36
+ appStore.getState().dispatch({ kind: result.error, type: 'git-refresh-error' })
37
+ delay = Math.min(delay * 2, MAX_INTERVAL_MS)
38
+ }
39
+ schedule()
40
+ }
41
+
42
+ void tick()
43
+
44
+ return () => {
45
+ cancelled = true
46
+ if (timer) clearTimeout(timer)
47
+ }
48
+ }, [enabled, projectPath])
49
+ }