@brimveyn/aimux 1.6.2 → 1.7.1
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/README.md +3 -0
- package/package.json +2 -2
- package/src/app-runtime/backend-attach-runtime.ts +6 -0
- package/src/app-runtime/backend-runtime-events.ts +21 -21
- package/src/app-runtime/side-effects.ts +24 -12
- package/src/app-runtime/use-backend-runtime.ts +2 -20
- package/src/app-runtime/use-directory-search.ts +6 -1
- package/src/app.tsx +2 -1
- package/src/config.ts +9 -0
- package/src/daemon/daemon.ts +197 -15
- package/src/daemon/session-manager.ts +9 -0
- package/src/daemon/session-registry.ts +5 -5
- package/src/index.tsx +10 -2
- package/src/input/keymap/help-entries.ts +5 -5
- package/src/input/modes/bridge.ts +5 -8
- package/src/input/modes/transitions.ts +15 -23
- package/src/input/modes/types.ts +2 -5
- package/src/ipc/manager-protocol.ts +2 -2
- package/src/ipc/protocol.ts +49 -5
- package/src/platform/project-search.ts +45 -12
- package/src/pty/assistant-status-detection-loop.ts +192 -0
- package/src/pty/assistant-status-detector.ts +226 -0
- package/src/pty/pty-manager.ts +3 -37
- package/src/session-backend/bootstrap.ts +25 -2
- package/src/session-backend/local-session-backend.ts +43 -56
- package/src/session-backend/remote-session-backend.ts +15 -0
- package/src/session-backend/types.ts +10 -1
- package/src/state/reducers/modal-state.ts +91 -134
- package/src/state/reducers/session-state.ts +13 -6
- package/src/state/reducers/tab-state.ts +0 -10
- package/src/state/selectors.ts +12 -0
- package/src/state/session-persistence.ts +20 -15
- package/src/state/store.ts +11 -3
- package/src/state/types.ts +29 -13
- package/src/ui/breaking-update-screen.tsx +31 -0
- package/src/ui/components/bare-input.tsx +44 -0
- package/src/ui/components/create-session-modal.tsx +16 -8
- package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
- package/src/ui/components/diff-renderer/split-view.tsx +11 -17
- package/src/ui/components/diff-renderer/stacked-view.tsx +7 -14
- package/src/ui/components/git-panel.tsx +10 -3
- package/src/ui/components/git-view.tsx +4 -8
- package/src/ui/components/help-modal.tsx +45 -160
- package/src/ui/components/input-field.tsx +6 -2
- package/src/ui/components/list-item.tsx +36 -28
- package/src/ui/components/modal-shell.tsx +51 -13
- package/src/ui/components/new-tab-modal.tsx +78 -45
- package/src/ui/components/picker.tsx +179 -0
- package/src/ui/components/session-bar.tsx +47 -21
- package/src/ui/components/session-picker-modal.tsx +58 -56
- package/src/ui/components/sidebar.tsx +49 -22
- package/src/ui/components/snippet-picker-modal.tsx +43 -34
- package/src/ui/components/status-bar.tsx +3 -2
- package/src/ui/components/surface.tsx +11 -8
- package/src/ui/components/tab-item.tsx +38 -22
- package/src/ui/components/terminal-pane.tsx +8 -8
- package/src/ui/components/theme-picker-modal.tsx +51 -91
- package/src/ui/root.tsx +14 -23
- package/src/ui/status-bar-model.ts +5 -5
- package/src/ui/theme-store.ts +26 -2
- package/src/ui/theme.ts +9 -1
- package/src/ui/components/modal-filter-bar.tsx +0 -19
package/src/index.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import { runRestartDaemon } from './restart-daemon'
|
|
|
12
12
|
import { runRestartTerminalManager } from './restart-terminal-manager'
|
|
13
13
|
import { createSessionBackend } from './session-backend/bootstrap'
|
|
14
14
|
import { runTerminalManager } from './terminal-manager/terminal-manager'
|
|
15
|
+
import { BreakingUpdateScreen } from './ui/breaking-update-screen'
|
|
15
16
|
import { runUpdate } from './update'
|
|
16
17
|
|
|
17
18
|
const command = process.argv[2]
|
|
@@ -64,7 +65,14 @@ const renderer = await createCliRenderer({
|
|
|
64
65
|
useMouse: true,
|
|
65
66
|
})
|
|
66
67
|
|
|
67
|
-
const
|
|
68
|
+
const root = createRoot(renderer)
|
|
69
|
+
|
|
70
|
+
const backend = await createSessionBackend({
|
|
71
|
+
onBreakingUpdateRequired: () =>
|
|
72
|
+
new Promise<void>((resolve) => {
|
|
73
|
+
root.render(<BreakingUpdateScreen onConfirm={resolve} />)
|
|
74
|
+
}),
|
|
75
|
+
})
|
|
68
76
|
logDebug('index.backendReady', { backend: backend.constructor.name, runtimeProfile })
|
|
69
77
|
|
|
70
78
|
const resolvedConfig = await loadUserConfig()
|
|
@@ -73,4 +81,4 @@ logDebug('index.userConfigLoaded', {
|
|
|
73
81
|
modeCount: resolvedConfig.keymaps.modes.size,
|
|
74
82
|
})
|
|
75
83
|
|
|
76
|
-
|
|
84
|
+
root.render(<App backend={backend} resolvedConfig={resolvedConfig} />)
|
|
@@ -12,19 +12,19 @@ export const HELP_MODE_LABELS: { modeId: ModeId; label: string }[] = [
|
|
|
12
12
|
{ label: 'Terminal input', modeId: 'terminal-input' },
|
|
13
13
|
{ label: 'Git mode', modeId: 'git-mode' },
|
|
14
14
|
{ label: 'Git commit', modeId: 'modal.git-commit' },
|
|
15
|
-
{ label: 'New tab', modeId: 'modal.new-tab' },
|
|
15
|
+
{ label: 'New tab', modeId: 'modal.new-tab.command-edit' },
|
|
16
16
|
{ label: 'New tab — command', modeId: 'modal.new-tab.command-edit' },
|
|
17
|
-
{ label: 'Session picker', modeId: 'modal.session-picker' },
|
|
17
|
+
{ label: 'Session picker', modeId: 'modal.session-picker.filtering' },
|
|
18
18
|
{ label: 'Session picker — filter', modeId: 'modal.session-picker.filtering' },
|
|
19
19
|
{ label: 'Session name', modeId: 'modal.session-name' },
|
|
20
20
|
{ label: 'Create session', modeId: 'modal.create-session' },
|
|
21
21
|
{ label: 'Rename tab', modeId: 'modal.rename-tab' },
|
|
22
|
-
{ label: 'Snippet picker', modeId: 'modal.snippet-picker' },
|
|
22
|
+
{ label: 'Snippet picker', modeId: 'modal.snippet-picker.filtering' },
|
|
23
23
|
{ label: 'Snippet picker — filter', modeId: 'modal.snippet-picker.filtering' },
|
|
24
24
|
{ label: 'Snippet editor', modeId: 'modal.snippet-editor' },
|
|
25
|
-
{ label: 'Theme picker', modeId: 'modal.theme-picker' },
|
|
25
|
+
{ label: 'Theme picker', modeId: 'modal.theme-picker.filtering' },
|
|
26
26
|
{ label: 'Split picker', modeId: 'modal.split-picker' },
|
|
27
|
-
{ label: 'Help', modeId: 'modal.help' },
|
|
27
|
+
{ label: 'Help', modeId: 'modal.help.filtering' },
|
|
28
28
|
{ label: 'Help — filter', modeId: 'modal.help.filtering' },
|
|
29
29
|
{ label: 'Update available', modeId: 'modal.update-available' },
|
|
30
30
|
]
|
|
@@ -23,21 +23,15 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
26
|
-
'help': 'modal.help',
|
|
27
|
-
'new-tab': 'modal.new-tab',
|
|
28
|
-
'session-picker': 'modal.session-picker',
|
|
29
|
-
'snippet-picker': 'modal.snippet-picker',
|
|
30
26
|
'split-picker': 'modal.split-picker',
|
|
31
|
-
'theme-picker': 'modal.theme-picker',
|
|
32
27
|
'update-available': 'modal.update-available',
|
|
33
28
|
}
|
|
34
29
|
|
|
35
30
|
export function deriveModeId(state: AppState): ModeId {
|
|
36
31
|
// Help renders as an overlay on top of git/navigation without flipping
|
|
37
|
-
// focusMode, so it needs modal-first dispatch.
|
|
38
|
-
// by editBuffer presence, not focusMode.
|
|
32
|
+
// focusMode, so it needs modal-first dispatch. Always in filter mode.
|
|
39
33
|
if (state.modal.type === 'help') {
|
|
40
|
-
return
|
|
34
|
+
return 'modal.help.filtering'
|
|
41
35
|
}
|
|
42
36
|
|
|
43
37
|
const directMode = DIRECT_FOCUS_MODE_IDS[state.focusMode]
|
|
@@ -46,6 +40,9 @@ export function deriveModeId(state: AppState): ModeId {
|
|
|
46
40
|
}
|
|
47
41
|
|
|
48
42
|
if (state.focusMode === 'command-edit') {
|
|
43
|
+
if (state.modal.type === 'new-tab' && state.modal.editingCommand !== null) {
|
|
44
|
+
return 'modal.new-tab.editing-command'
|
|
45
|
+
}
|
|
49
46
|
const modalType = state.modal.type
|
|
50
47
|
const commandEditMode = modalType ? COMMAND_EDIT_MODE_IDS[modalType] : undefined
|
|
51
48
|
if (commandEditMode) {
|
|
@@ -2,35 +2,27 @@ import type { ModeId } from './types'
|
|
|
2
2
|
|
|
3
3
|
const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
4
4
|
'git-mode': ['navigation', 'modal.git-commit'],
|
|
5
|
-
'modal.create-session': ['navigation', 'modal.session-picker'],
|
|
5
|
+
'modal.create-session': ['navigation', 'modal.session-picker.filtering'],
|
|
6
6
|
'modal.git-commit': ['git-mode'],
|
|
7
|
-
'modal.help': ['navigation'
|
|
8
|
-
'modal.
|
|
9
|
-
'modal.new-tab': ['navigation', 'modal.new-tab.command-edit'],
|
|
10
|
-
'modal.new-tab.command-edit': ['modal.new-tab'],
|
|
7
|
+
'modal.help.filtering': ['navigation'],
|
|
8
|
+
'modal.new-tab.command-edit': ['navigation', 'modal.new-tab.editing-command'],
|
|
9
|
+
'modal.new-tab.editing-command': ['navigation', 'modal.new-tab.command-edit'],
|
|
11
10
|
'modal.rename-tab': ['navigation'],
|
|
12
|
-
'modal.session-name': ['modal.session-picker', 'navigation'],
|
|
13
|
-
'modal.session-picker': [
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
'modal.session-name',
|
|
17
|
-
'modal.create-session',
|
|
18
|
-
],
|
|
19
|
-
'modal.session-picker.filtering': ['modal.session-picker'],
|
|
20
|
-
'modal.snippet-editor': ['navigation', 'modal.snippet-picker'],
|
|
21
|
-
'modal.snippet-picker': ['navigation', 'modal.snippet-picker.filtering', 'modal.snippet-editor'],
|
|
22
|
-
'modal.snippet-picker.filtering': ['modal.snippet-picker'],
|
|
11
|
+
'modal.session-name': ['modal.session-picker.filtering', 'navigation'],
|
|
12
|
+
'modal.session-picker.filtering': ['navigation', 'modal.session-name', 'modal.create-session'],
|
|
13
|
+
'modal.snippet-editor': ['navigation', 'modal.snippet-picker.filtering'],
|
|
14
|
+
'modal.snippet-picker.filtering': ['navigation', 'modal.snippet-editor'],
|
|
23
15
|
'modal.split-picker': ['navigation', 'terminal-input'],
|
|
24
|
-
'modal.theme-picker': ['navigation'
|
|
25
|
-
'modal.theme-picker.filtering': ['modal.theme-picker'],
|
|
16
|
+
'modal.theme-picker.filtering': ['navigation'],
|
|
26
17
|
'modal.update-available': ['navigation'],
|
|
27
18
|
'navigation': [
|
|
28
19
|
'terminal-input',
|
|
29
|
-
'modal.new-tab',
|
|
30
|
-
'modal.
|
|
31
|
-
'modal.
|
|
32
|
-
'modal.
|
|
33
|
-
'modal.
|
|
20
|
+
'modal.new-tab.command-edit',
|
|
21
|
+
'modal.new-tab.editing-command',
|
|
22
|
+
'modal.session-picker.filtering',
|
|
23
|
+
'modal.help.filtering',
|
|
24
|
+
'modal.snippet-picker.filtering',
|
|
25
|
+
'modal.theme-picker.filtering',
|
|
34
26
|
'modal.rename-tab',
|
|
35
27
|
'modal.update-available',
|
|
36
28
|
'git-mode',
|
package/src/input/modes/types.ts
CHANGED
|
@@ -6,19 +6,15 @@ export type ModeId =
|
|
|
6
6
|
| 'navigation'
|
|
7
7
|
| 'terminal-input'
|
|
8
8
|
| 'git-mode'
|
|
9
|
-
| 'modal.new-tab'
|
|
10
9
|
| 'modal.new-tab.command-edit'
|
|
11
|
-
| 'modal.
|
|
10
|
+
| 'modal.new-tab.editing-command'
|
|
12
11
|
| 'modal.session-picker.filtering'
|
|
13
12
|
| 'modal.session-name'
|
|
14
13
|
| 'modal.create-session'
|
|
15
14
|
| 'modal.rename-tab'
|
|
16
|
-
| 'modal.snippet-picker'
|
|
17
15
|
| 'modal.snippet-picker.filtering'
|
|
18
16
|
| 'modal.snippet-editor'
|
|
19
|
-
| 'modal.theme-picker'
|
|
20
17
|
| 'modal.theme-picker.filtering'
|
|
21
|
-
| 'modal.help'
|
|
22
18
|
| 'modal.help.filtering'
|
|
23
19
|
| 'modal.split-picker'
|
|
24
20
|
| 'modal.git-commit'
|
|
@@ -58,6 +54,7 @@ export type SideEffect =
|
|
|
58
54
|
| { type: 'git-push' }
|
|
59
55
|
| { type: 'confirm-update-selection' }
|
|
60
56
|
| { type: 'switch-session-by-index'; index: number }
|
|
57
|
+
| { type: 'toggle-transparent' }
|
|
61
58
|
|
|
62
59
|
export interface KeyResult {
|
|
63
60
|
actions: AppAction[]
|
|
@@ -14,8 +14,8 @@ import {
|
|
|
14
14
|
negotiateProtocolVersion,
|
|
15
15
|
} from './protocol'
|
|
16
16
|
|
|
17
|
-
export const MANAGER_PROTOCOL_MIN_VERSION =
|
|
18
|
-
export const MANAGER_PROTOCOL_VERSION =
|
|
17
|
+
export const MANAGER_PROTOCOL_MIN_VERSION = 2
|
|
18
|
+
export const MANAGER_PROTOCOL_VERSION = 2
|
|
19
19
|
|
|
20
20
|
export interface ManagerHelloRequest {
|
|
21
21
|
minVersion: number
|
package/src/ipc/protocol.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ScrollIntent,
|
|
3
|
+
SessionStatus,
|
|
4
|
+
TabActivity,
|
|
3
5
|
TabSession,
|
|
4
6
|
TerminalModeState,
|
|
5
7
|
TerminalSnapshot,
|
|
@@ -8,8 +10,14 @@ import type {
|
|
|
8
10
|
|
|
9
11
|
import { isWorkspaceSnapshotV1 } from '../state/validation'
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
// v4 widens sessionStatus from a single status enum to independent
|
|
14
|
+
// {working, waiting} flags so a chip can show both at once.
|
|
15
|
+
// v5 folds initial tab activities and session statuses into attachResult so
|
|
16
|
+
// the client applies them atomically with tab creation — previously they
|
|
17
|
+
// arrived as separate events and could lose to the unknown-tab no-op in
|
|
18
|
+
// the reducer.
|
|
19
|
+
export const IPC_PROTOCOL_MIN_VERSION = 6
|
|
20
|
+
export const IPC_PROTOCOL_VERSION = 6
|
|
13
21
|
|
|
14
22
|
export interface ProtocolHelloRequest {
|
|
15
23
|
minVersion: number
|
|
@@ -35,6 +43,13 @@ export interface AttachResult {
|
|
|
35
43
|
protocolVersion: number
|
|
36
44
|
tabs: TabSession[]
|
|
37
45
|
activeTabId: string | null
|
|
46
|
+
/**
|
|
47
|
+
* Snapshot of every known session's status at attach time. Applied by
|
|
48
|
+
* the client atomically with `hydrate-workspace` so chips render the
|
|
49
|
+
* right state immediately, without the per-event race where a separate
|
|
50
|
+
* `sessionStatus` event could arrive before the session was in state.
|
|
51
|
+
*/
|
|
52
|
+
initialSessionStatuses: Array<{ sessionId: string; status: SessionStatus }>
|
|
38
53
|
}
|
|
39
54
|
|
|
40
55
|
export type ClientRequest =
|
|
@@ -90,6 +105,8 @@ export type ServerEvent =
|
|
|
90
105
|
}
|
|
91
106
|
| { type: 'tabExit'; payload: { tabId: string; exitCode: number } }
|
|
92
107
|
| { type: 'tabError'; payload: { tabId: string; message: string } }
|
|
108
|
+
| { type: 'tabStatus'; payload: { sessionId: string; tabId: string; status: TabActivity } }
|
|
109
|
+
| { type: 'sessionStatus'; payload: { sessionId: string; status: SessionStatus } }
|
|
93
110
|
|
|
94
111
|
export type IpcMessage = ClientRequest | ServerResponse | ServerEvent
|
|
95
112
|
|
|
@@ -130,6 +147,18 @@ function isStringArray(value: unknown): value is string[] {
|
|
|
130
147
|
return Array.isArray(value) && value.every(isString)
|
|
131
148
|
}
|
|
132
149
|
|
|
150
|
+
function isTabActivity(value: unknown): value is TabActivity {
|
|
151
|
+
return value === 'working' || value === 'waiting-input' || value === 'idle'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isSessionStatus(value: unknown): value is SessionStatus {
|
|
155
|
+
return (
|
|
156
|
+
isObjectRecord(value) &&
|
|
157
|
+
typeof value.working === 'boolean' &&
|
|
158
|
+
typeof value.waiting === 'boolean'
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
133
162
|
function isTerminalSpan(value: unknown): boolean {
|
|
134
163
|
return (
|
|
135
164
|
isObjectRecord(value) &&
|
|
@@ -209,9 +238,11 @@ function isAttachResult(value: unknown): value is AttachResult {
|
|
|
209
238
|
(tab.status === 'starting' ||
|
|
210
239
|
tab.status === 'running' ||
|
|
211
240
|
tab.status === 'disconnected' ||
|
|
212
|
-
tab.status === 'exited' ||
|
|
213
241
|
tab.status === 'error') &&
|
|
214
|
-
(tab.activity === undefined ||
|
|
242
|
+
(tab.activity === undefined ||
|
|
243
|
+
tab.activity === 'working' ||
|
|
244
|
+
tab.activity === 'waiting-input' ||
|
|
245
|
+
tab.activity === 'idle') &&
|
|
215
246
|
isString(tab.buffer) &&
|
|
216
247
|
isTerminalModeState(tab.terminalModes) &&
|
|
217
248
|
isString(tab.command) &&
|
|
@@ -219,7 +250,11 @@ function isAttachResult(value: unknown): value is AttachResult {
|
|
|
219
250
|
(tab.errorMessage === undefined || isString(tab.errorMessage)) &&
|
|
220
251
|
(tab.exitCode === undefined || isFiniteNumber(tab.exitCode))
|
|
221
252
|
) &&
|
|
222
|
-
isNullableString(value.activeTabId)
|
|
253
|
+
isNullableString(value.activeTabId) &&
|
|
254
|
+
Array.isArray(value.initialSessionStatuses) &&
|
|
255
|
+
value.initialSessionStatuses.every(
|
|
256
|
+
(entry) => isObjectRecord(entry) && isString(entry.sessionId) && isSessionStatus(entry.status)
|
|
257
|
+
)
|
|
223
258
|
)
|
|
224
259
|
}
|
|
225
260
|
|
|
@@ -375,6 +410,15 @@ export function parseServerMessage(value: unknown): ServerResponse | ServerEvent
|
|
|
375
410
|
assert(isString(value.payload.tabId), 'tabError.tabId must be a string')
|
|
376
411
|
assert(isString(value.payload.message), 'tabError.message must be a string')
|
|
377
412
|
return value as ServerEvent
|
|
413
|
+
case 'tabStatus':
|
|
414
|
+
assert(isString(value.payload.sessionId), 'tabStatus.sessionId must be a string')
|
|
415
|
+
assert(isString(value.payload.tabId), 'tabStatus.tabId must be a string')
|
|
416
|
+
assert(isTabActivity(value.payload.status), 'tabStatus.status is invalid')
|
|
417
|
+
return value as ServerEvent
|
|
418
|
+
case 'sessionStatus':
|
|
419
|
+
assert(isString(value.payload.sessionId), 'sessionStatus.sessionId must be a string')
|
|
420
|
+
assert(isSessionStatus(value.payload.status), 'sessionStatus.status is invalid')
|
|
421
|
+
return value as ServerEvent
|
|
378
422
|
default:
|
|
379
423
|
throw new IpcProtocolError(`Unknown IPC response type: ${String(value.type)}`)
|
|
380
424
|
}
|
|
@@ -7,15 +7,18 @@ import type { DirectoryResult } from '../state/types'
|
|
|
7
7
|
|
|
8
8
|
import { logDebug } from '../debug/input-log'
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
interface DirectoryCache {
|
|
11
|
+
repoPaths: string[]
|
|
12
|
+
workspaceSet: Set<string>
|
|
13
|
+
cachedAt: number
|
|
14
|
+
}
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
const CACHE_TTL_MS = 60_000
|
|
17
|
+
let directoryCache: DirectoryCache | null = null
|
|
16
18
|
|
|
19
|
+
async function buildCache(): Promise<DirectoryCache> {
|
|
20
|
+
const home = homedir()
|
|
17
21
|
try {
|
|
18
|
-
// Step 1: Find all git repos (nothrow: find exits 1 on macOS permission errors)
|
|
19
22
|
const gitResult =
|
|
20
23
|
await $`find ${home} -maxdepth 4 -name .git -not -path '*/node_modules/*' -not -path '*/target/*' -not -path '*/dist/*' 2>/dev/null`
|
|
21
24
|
.quiet()
|
|
@@ -27,7 +30,6 @@ export async function searchProjectDirectories(query: string): Promise<Directory
|
|
|
27
30
|
.filter((line) => line.length > 0)
|
|
28
31
|
.map((p) => p.replace(/\/\.git$/, ''))
|
|
29
32
|
|
|
30
|
-
// Step 2: Find workspace parents (dirs with 2+ child repos that aren't repos themselves)
|
|
31
33
|
const repoSet = new Set(repoPaths)
|
|
32
34
|
const parentCount = new Map<string, number>()
|
|
33
35
|
for (const repo of repoPaths) {
|
|
@@ -39,10 +41,37 @@ export async function searchProjectDirectories(query: string): Promise<Directory
|
|
|
39
41
|
const workspacePaths = [...parentCount.entries()]
|
|
40
42
|
.filter(([, count]) => count >= 2)
|
|
41
43
|
.map(([p]) => p)
|
|
42
|
-
const workspaceSet = new Set(workspacePaths)
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
return { cachedAt: Date.now(), repoPaths, workspaceSet: new Set(workspacePaths) }
|
|
46
|
+
} catch (error) {
|
|
47
|
+
logDebug('platform.projectSearch.buildCache.error', {
|
|
48
|
+
error: error instanceof Error ? error.message : String(error),
|
|
49
|
+
})
|
|
50
|
+
return { cachedAt: Date.now(), repoPaths: [], workspaceSet: new Set() }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function getOrBuildCache(): Promise<DirectoryCache> {
|
|
55
|
+
if (directoryCache && Date.now() - directoryCache.cachedAt < CACHE_TTL_MS) {
|
|
56
|
+
return directoryCache
|
|
57
|
+
}
|
|
58
|
+
directoryCache = await buildCache()
|
|
59
|
+
return directoryCache
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function warmDirectoryCache(): Promise<void> {
|
|
63
|
+
await getOrBuildCache()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function searchProjectDirectories(query: string): Promise<DirectoryResult[]> {
|
|
67
|
+
if (!query.trim()) {
|
|
68
|
+
return []
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const cache = await getOrBuildCache()
|
|
73
|
+
const allPaths = [...cache.repoPaths, ...cache.workspaceSet].join('\n')
|
|
74
|
+
|
|
46
75
|
const filtered =
|
|
47
76
|
await $`printf '%s' ${allPaths} | fzf --filter=${query} --no-sort | head -50`.quiet()
|
|
48
77
|
const resultPaths = filtered
|
|
@@ -51,12 +80,16 @@ export async function searchProjectDirectories(query: string): Promise<Directory
|
|
|
51
80
|
.split('\n')
|
|
52
81
|
.filter((line) => line.length > 0)
|
|
53
82
|
|
|
54
|
-
// Step 4: Classify each result (shortest paths first, limit to 10)
|
|
55
83
|
resultPaths.sort((a, b) => a.length - b.length)
|
|
56
84
|
resultPaths.splice(10)
|
|
85
|
+
|
|
86
|
+
if (resultPaths.length === 0 && query.trim().startsWith('/')) {
|
|
87
|
+
return [{ path: query.trim(), type: 'git-repo' }]
|
|
88
|
+
}
|
|
89
|
+
|
|
57
90
|
return Promise.all(
|
|
58
91
|
resultPaths.map(async (path) => {
|
|
59
|
-
if (workspaceSet.has(path)) {
|
|
92
|
+
if (cache.workspaceSet.has(path)) {
|
|
60
93
|
return { path, type: 'workspace' as const }
|
|
61
94
|
}
|
|
62
95
|
const gitPath = join(path, '.git')
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Continuous status detection loop.
|
|
3
|
+
*
|
|
4
|
+
* Backends drive this loop to monitor every known terminal at a fixed cadence:
|
|
5
|
+
* every tick we pull each tab's current viewport, run the detector on the last
|
|
6
|
+
* ~10 non-blank lines, and report per-tab + per-session statuses to the
|
|
7
|
+
* backend's transport. The loop is the single source of truth so that idle
|
|
8
|
+
* tabs can't erase a sibling's waiting-input, and so that clients receive
|
|
9
|
+
* status for every session the backend owns, not just the attached one.
|
|
10
|
+
*
|
|
11
|
+
* Per-session status is a pair of independent booleans:
|
|
12
|
+
* - `working`: at least one tab is working.
|
|
13
|
+
* - `waiting`: at least one tab is waiting for user input.
|
|
14
|
+
* Both can be true at the same time — the chip renders one glyph per flag.
|
|
15
|
+
*/
|
|
16
|
+
import type { AssistantId, SessionStatus, TabActivity, TerminalSnapshot } from '../state/types'
|
|
17
|
+
|
|
18
|
+
import { logDebug } from '../debug/input-log'
|
|
19
|
+
import { getLineText } from '../input/terminal-text-extraction'
|
|
20
|
+
import { AssistantStatusDetector } from './assistant-status-detector'
|
|
21
|
+
|
|
22
|
+
function tailPreview(viewport: TerminalSnapshot | undefined): string {
|
|
23
|
+
if (!viewport) return '<no-viewport>'
|
|
24
|
+
const lines = viewport.lines
|
|
25
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
26
|
+
const line = lines[i]
|
|
27
|
+
if (!line) continue
|
|
28
|
+
const text = getLineText(line).trim()
|
|
29
|
+
if (text.length > 0) return text.slice(0, 80)
|
|
30
|
+
}
|
|
31
|
+
return '<blank>'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Default polling interval. Cheap — detector is a handful of substring checks. */
|
|
35
|
+
const DEFAULT_TICK_MS = 500
|
|
36
|
+
|
|
37
|
+
export interface LoopTabView {
|
|
38
|
+
id: string
|
|
39
|
+
assistant: AssistantId
|
|
40
|
+
command: string
|
|
41
|
+
viewport?: TerminalSnapshot
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StatusDetectionLoopOptions {
|
|
45
|
+
listSessions: () => string[]
|
|
46
|
+
listTabs: (sessionId: string) => LoopTabView[]
|
|
47
|
+
/** Emitted when a tab's status changes. */
|
|
48
|
+
onTabStatus: (tabId: string, status: TabActivity, sessionId: string) => void
|
|
49
|
+
/** Emitted when either flag on a session changes. */
|
|
50
|
+
onSessionStatus: (sessionId: string, status: SessionStatus) => void
|
|
51
|
+
tickMs?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface StatusDetectionLoopHandle {
|
|
55
|
+
stop: () => void
|
|
56
|
+
/** Last classified status for a tab, for on-attach replay. */
|
|
57
|
+
getTabStatus: (tabId: string) => TabActivity | undefined
|
|
58
|
+
/** Last session flags, for on-attach replay. */
|
|
59
|
+
getSessionStatus: (sessionId: string) => SessionStatus | undefined
|
|
60
|
+
/** Snapshot of every known session's flags. */
|
|
61
|
+
snapshotSessions: () => Array<{ sessionId: string; status: SessionStatus }>
|
|
62
|
+
/** Snapshot of every known tab's status plus its session. */
|
|
63
|
+
snapshotTabs: () => Array<{ tabId: string; sessionId: string; status: TabActivity }>
|
|
64
|
+
/**
|
|
65
|
+
* Synchronously run classification for a single session. Used on client
|
|
66
|
+
* attach so the replay snapshot is populated *before* the client reads it,
|
|
67
|
+
* rather than relying on the next scheduled tick.
|
|
68
|
+
*/
|
|
69
|
+
classifyNow: (sessionId: string, tabs: LoopTabView[]) => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function runStatusDetectionLoop(
|
|
73
|
+
options: StatusDetectionLoopOptions
|
|
74
|
+
): StatusDetectionLoopHandle {
|
|
75
|
+
const tickMs = options.tickMs ?? DEFAULT_TICK_MS
|
|
76
|
+
const detector = new AssistantStatusDetector()
|
|
77
|
+
const lastTabStatus = new Map<string, { status: TabActivity; sessionId: string }>()
|
|
78
|
+
const lastSessionStatus = new Map<string, SessionStatus>()
|
|
79
|
+
|
|
80
|
+
const timer = setInterval(() => {
|
|
81
|
+
try {
|
|
82
|
+
tick()
|
|
83
|
+
} catch (error) {
|
|
84
|
+
logDebug('statusLoop.tickError', {
|
|
85
|
+
error: error instanceof Error ? error.message : String(error),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}, tickMs)
|
|
89
|
+
timer.unref?.()
|
|
90
|
+
|
|
91
|
+
function classifySession(
|
|
92
|
+
sessionId: string,
|
|
93
|
+
tabs: LoopTabView[],
|
|
94
|
+
now: number,
|
|
95
|
+
source: 'tick' | 'classifyNow',
|
|
96
|
+
seenTabs?: Set<string>
|
|
97
|
+
): void {
|
|
98
|
+
let working = false
|
|
99
|
+
let waiting = false
|
|
100
|
+
for (const tab of tabs) {
|
|
101
|
+
seenTabs?.add(tab.id)
|
|
102
|
+
const status = detector.classify({
|
|
103
|
+
assistant: tab.assistant,
|
|
104
|
+
command: tab.command,
|
|
105
|
+
now,
|
|
106
|
+
tabId: tab.id,
|
|
107
|
+
viewport: tab.viewport,
|
|
108
|
+
})
|
|
109
|
+
if (status === 'working') working = true
|
|
110
|
+
if (status === 'waiting-input') waiting = true
|
|
111
|
+
const prev = lastTabStatus.get(tab.id)
|
|
112
|
+
const changed = !prev || prev.status !== status || prev.sessionId !== sessionId
|
|
113
|
+
logDebug('statusLoop.classify', {
|
|
114
|
+
assistant: tab.assistant,
|
|
115
|
+
changed,
|
|
116
|
+
prevSessionId: prev?.sessionId,
|
|
117
|
+
prevStatus: prev?.status,
|
|
118
|
+
sessionId,
|
|
119
|
+
source,
|
|
120
|
+
status,
|
|
121
|
+
tabId: tab.id,
|
|
122
|
+
tailPreview: tailPreview(tab.viewport),
|
|
123
|
+
})
|
|
124
|
+
if (changed) {
|
|
125
|
+
lastTabStatus.set(tab.id, { sessionId, status })
|
|
126
|
+
options.onTabStatus(tab.id, status, sessionId)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const next: SessionStatus = { waiting, working }
|
|
130
|
+
const prevSession = lastSessionStatus.get(sessionId)
|
|
131
|
+
const sessionChanged =
|
|
132
|
+
!prevSession || prevSession.working !== working || prevSession.waiting !== waiting
|
|
133
|
+
logDebug('statusLoop.classifySession', {
|
|
134
|
+
prev: prevSession,
|
|
135
|
+
sessionChanged,
|
|
136
|
+
sessionId,
|
|
137
|
+
source,
|
|
138
|
+
status: next,
|
|
139
|
+
tabCount: tabs.length,
|
|
140
|
+
})
|
|
141
|
+
if (sessionChanged) {
|
|
142
|
+
lastSessionStatus.set(sessionId, next)
|
|
143
|
+
options.onSessionStatus(sessionId, next)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function tick(): void {
|
|
148
|
+
const now = Date.now()
|
|
149
|
+
const sessionIds = options.listSessions()
|
|
150
|
+
const seenSessions = new Set<string>()
|
|
151
|
+
const seenTabs = new Set<string>()
|
|
152
|
+
|
|
153
|
+
for (const sessionId of sessionIds) {
|
|
154
|
+
seenSessions.add(sessionId)
|
|
155
|
+
classifySession(sessionId, options.listTabs(sessionId), now, 'tick', seenTabs)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const tabId of lastTabStatus.keys()) {
|
|
159
|
+
if (!seenTabs.has(tabId)) {
|
|
160
|
+
detector.forget(tabId)
|
|
161
|
+
lastTabStatus.delete(tabId)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const sessionId of lastSessionStatus.keys()) {
|
|
165
|
+
if (!seenSessions.has(sessionId)) {
|
|
166
|
+
lastSessionStatus.delete(sessionId)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
classifyNow: (sessionId, tabs) => {
|
|
173
|
+
classifySession(sessionId, tabs, Date.now(), 'classifyNow')
|
|
174
|
+
},
|
|
175
|
+
getSessionStatus: (sessionId) => lastSessionStatus.get(sessionId),
|
|
176
|
+
getTabStatus: (tabId) => lastTabStatus.get(tabId)?.status,
|
|
177
|
+
snapshotSessions: () =>
|
|
178
|
+
[...lastSessionStatus.entries()].map(([sessionId, status]) => ({ sessionId, status })),
|
|
179
|
+
snapshotTabs: () =>
|
|
180
|
+
[...lastTabStatus.entries()].map(([tabId, entry]) => ({
|
|
181
|
+
sessionId: entry.sessionId,
|
|
182
|
+
status: entry.status,
|
|
183
|
+
tabId,
|
|
184
|
+
})),
|
|
185
|
+
stop: () => {
|
|
186
|
+
clearInterval(timer)
|
|
187
|
+
detector.clear()
|
|
188
|
+
lastTabStatus.clear()
|
|
189
|
+
lastSessionStatus.clear()
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
}
|