@brimveyn/aimux 1.6.1 → 1.7.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/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 +43 -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 +6 -1
- package/src/config.ts +28 -1
- 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/git/diff-hash.ts +10 -0
- 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 +3 -5
- 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 +4 -1
- 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/git-tree.ts +42 -16
- package/src/state/reducers/diff-cache.ts +64 -0
- package/src/state/reducers/git-mode-state.ts +91 -33
- package/src/state/reducers/git-panel-state.ts +33 -2
- 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 +13 -3
- package/src/state/types.ts +87 -14
- 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/pierre-diff.tsx +9 -31
- package/src/ui/components/diff-renderer/prepare-diff.ts +66 -0
- package/src/ui/components/diff-renderer/split-view.tsx +35 -16
- package/src/ui/components/diff-renderer/stacked-view.tsx +30 -12
- package/src/ui/components/diff-renderer/use-diff-prefetch.ts +191 -0
- package/src/ui/components/diff-renderer/use-diff-preparation.ts +106 -0
- package/src/ui/components/git-pane-widget.tsx +2 -0
- package/src/ui/components/git-panel.tsx +27 -10
- package/src/ui/components/git-view.tsx +23 -9
- 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/daemon/daemon.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { connect, createServer, type Socket } from 'node:net'
|
|
2
2
|
|
|
3
|
+
import type { AssistantId, TerminalSnapshot } from '../state/types'
|
|
4
|
+
|
|
3
5
|
import { logDebug } from '../debug/input-log'
|
|
4
6
|
import {
|
|
5
7
|
type ClientRequest,
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
type ServerResponse,
|
|
15
17
|
} from '../ipc/protocol'
|
|
16
18
|
import { findSocketProcessPid, spawnDetachedTerminalManager } from '../platform/daemon-control'
|
|
19
|
+
import { type LoopTabView, runStatusDetectionLoop } from '../pty/assistant-status-detection-loop'
|
|
17
20
|
import { TerminalManagerClient } from '../terminal-manager/manager-client'
|
|
18
21
|
import {
|
|
19
22
|
getIpcDaemonSocketPath,
|
|
@@ -24,6 +27,53 @@ import {
|
|
|
24
27
|
tightenSocketPermissions,
|
|
25
28
|
} from './runtime-paths'
|
|
26
29
|
|
|
30
|
+
export interface DaemonTabEntry {
|
|
31
|
+
sessionId: string
|
|
32
|
+
assistant: AssistantId
|
|
33
|
+
command: string
|
|
34
|
+
viewport: TerminalSnapshot | undefined
|
|
35
|
+
/**
|
|
36
|
+
* Monotonic viewport sequence. Incremented on every render-event write so
|
|
37
|
+
* stale writers (e.g. `attach` returning a pre-await snapshot) can refuse
|
|
38
|
+
* to clobber a fresher viewport that arrived via `render` while the
|
|
39
|
+
* `attachSession` call was still in flight.
|
|
40
|
+
*/
|
|
41
|
+
viewportSeq: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Merges an attach-time tab view into the registry without clobbering a
|
|
46
|
+
* viewport that arrived via a render event during the `attachSession` await.
|
|
47
|
+
*
|
|
48
|
+
* Returns the updated entry (also written into `registry`). `seq` is a
|
|
49
|
+
* monotonic counter scoped to the registry; pass the same counter into every
|
|
50
|
+
* call so the new entry slot gets a stable ordinal when we first observe it.
|
|
51
|
+
*
|
|
52
|
+
* Exported for unit tests — the attach path in `runDaemon` uses the same
|
|
53
|
+
* helper through a closure that threads the counter.
|
|
54
|
+
*/
|
|
55
|
+
export function mergeTabRegistryEntry(
|
|
56
|
+
registry: Map<string, DaemonTabEntry>,
|
|
57
|
+
sessionId: string,
|
|
58
|
+
tabId: string,
|
|
59
|
+
assistant: AssistantId,
|
|
60
|
+
command: string,
|
|
61
|
+
initialViewport: TerminalSnapshot | undefined,
|
|
62
|
+
allocateSeq: () => number
|
|
63
|
+
): DaemonTabEntry {
|
|
64
|
+
const existing = registry.get(tabId)
|
|
65
|
+
const viewport = existing?.viewport ?? initialViewport
|
|
66
|
+
const entry: DaemonTabEntry = {
|
|
67
|
+
assistant,
|
|
68
|
+
command,
|
|
69
|
+
sessionId,
|
|
70
|
+
viewport,
|
|
71
|
+
viewportSeq: existing?.viewportSeq ?? (viewport ? allocateSeq() : 0),
|
|
72
|
+
}
|
|
73
|
+
registry.set(tabId, entry)
|
|
74
|
+
return entry
|
|
75
|
+
}
|
|
76
|
+
|
|
27
77
|
function send(socket: Socket, message: ServerResponse | ServerEvent): void {
|
|
28
78
|
socket.write(encodeMessage(message))
|
|
29
79
|
}
|
|
@@ -115,37 +165,121 @@ export async function runDaemon(): Promise<void> {
|
|
|
115
165
|
const attachedSessions = new Map<Socket, string>()
|
|
116
166
|
const negotiatedVersions = new Map<Socket, number>()
|
|
117
167
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
168
|
+
// Per-tab registry so the status-detection loop can poll every terminal
|
|
169
|
+
// continuously, not just the one the UI is currently attached to.
|
|
170
|
+
const tabRegistry = new Map<string, DaemonTabEntry>()
|
|
171
|
+
let nextViewportSeq = 1
|
|
172
|
+
|
|
173
|
+
const allocateSeq = (): number => nextViewportSeq++
|
|
174
|
+
const rememberTab = (
|
|
175
|
+
sessionId: string,
|
|
176
|
+
tabId: string,
|
|
177
|
+
assistant: AssistantId,
|
|
178
|
+
command: string,
|
|
179
|
+
initialViewport?: TerminalSnapshot
|
|
180
|
+
): void => {
|
|
181
|
+
const before = tabRegistry.get(tabId)
|
|
182
|
+
const entry = mergeTabRegistryEntry(
|
|
183
|
+
tabRegistry,
|
|
184
|
+
sessionId,
|
|
185
|
+
tabId,
|
|
186
|
+
assistant,
|
|
187
|
+
command,
|
|
188
|
+
initialViewport,
|
|
189
|
+
allocateSeq
|
|
190
|
+
)
|
|
191
|
+
logDebug('daemon.rememberTab', {
|
|
192
|
+
hadExistingEntry: before !== undefined,
|
|
193
|
+
hadExistingViewport: before?.viewport !== undefined,
|
|
194
|
+
initialViewportProvided: initialViewport !== undefined,
|
|
195
|
+
preservedExistingViewport:
|
|
196
|
+
before?.viewport !== undefined && entry.viewport === before.viewport,
|
|
197
|
+
resultSeq: entry.viewportSeq,
|
|
121
198
|
sessionId,
|
|
122
199
|
tabId,
|
|
123
|
-
viewportLines: viewport.lines.length,
|
|
124
200
|
})
|
|
125
|
-
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const broadcastAll = (event: ServerEvent): void => {
|
|
204
|
+
for (const socket of sockets) {
|
|
205
|
+
send(socket, event)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const broadcastForSession = (sessionId: string, event: ServerEvent): void => {
|
|
126
210
|
for (const socket of sockets) {
|
|
127
211
|
if (attachedSessions.get(socket) === sessionId) {
|
|
128
212
|
send(socket, event)
|
|
129
213
|
}
|
|
130
214
|
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
manager.on('render', (sessionId, tabId, viewport, terminalModes) => {
|
|
218
|
+
const existing = tabRegistry.get(tabId)
|
|
219
|
+
let newSeq: number | null = null
|
|
220
|
+
if (existing) {
|
|
221
|
+
existing.viewport = viewport
|
|
222
|
+
existing.viewportSeq = nextViewportSeq++
|
|
223
|
+
existing.sessionId = sessionId
|
|
224
|
+
newSeq = existing.viewportSeq
|
|
225
|
+
}
|
|
226
|
+
logDebug('daemon.manager.render', {
|
|
227
|
+
attachedSocketCount: sockets.size,
|
|
228
|
+
hadRegistryEntry: existing !== undefined,
|
|
229
|
+
newSeq,
|
|
230
|
+
sessionId,
|
|
231
|
+
tabId,
|
|
232
|
+
viewportLines: viewport.lines.length,
|
|
233
|
+
})
|
|
234
|
+
const event: ServerEvent = { payload: { tabId, terminalModes, viewport }, type: 'tabRender' }
|
|
235
|
+
broadcastForSession(sessionId, event)
|
|
131
236
|
})
|
|
132
237
|
manager.on('exit', (sessionId, tabId, exitCode) => {
|
|
133
238
|
logDebug('daemon.manager.exit', { exitCode, sessionId, tabId })
|
|
239
|
+
tabRegistry.delete(tabId)
|
|
134
240
|
const event: ServerEvent = { payload: { exitCode, tabId }, type: 'tabExit' }
|
|
135
|
-
|
|
136
|
-
if (attachedSessions.get(socket) === sessionId) {
|
|
137
|
-
send(socket, event)
|
|
138
|
-
}
|
|
139
|
-
}
|
|
241
|
+
broadcastForSession(sessionId, event)
|
|
140
242
|
})
|
|
141
243
|
manager.on('error', (sessionId, tabId, message) => {
|
|
142
244
|
logDebug('daemon.manager.error', { message, sessionId, tabId })
|
|
143
245
|
const event: ServerEvent = { payload: { message, tabId }, type: 'tabError' }
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
246
|
+
broadcastForSession(sessionId, event)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const listTabsForSession = (sessionId: string): LoopTabView[] => {
|
|
250
|
+
const result: LoopTabView[] = []
|
|
251
|
+
for (const [tabId, entry] of tabRegistry) {
|
|
252
|
+
if (entry.sessionId !== sessionId) continue
|
|
253
|
+
result.push({
|
|
254
|
+
assistant: entry.assistant,
|
|
255
|
+
command: entry.command,
|
|
256
|
+
id: tabId,
|
|
257
|
+
viewport: entry.viewport,
|
|
258
|
+
})
|
|
148
259
|
}
|
|
260
|
+
return result
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const statusLoop = runStatusDetectionLoop({
|
|
264
|
+
listSessions: () => {
|
|
265
|
+
const seen = new Set<string>()
|
|
266
|
+
for (const entry of tabRegistry.values()) seen.add(entry.sessionId)
|
|
267
|
+
return [...seen]
|
|
268
|
+
},
|
|
269
|
+
listTabs: listTabsForSession,
|
|
270
|
+
onSessionStatus: (sessionId, status) => {
|
|
271
|
+
logDebug('daemon.status.session', { sessionId, status })
|
|
272
|
+
broadcastAll({ payload: { sessionId, status }, type: 'sessionStatus' })
|
|
273
|
+
},
|
|
274
|
+
onTabStatus: (tabId, status, sessionId) => {
|
|
275
|
+
logDebug('daemon.status.tab', { sessionId, status, tabId })
|
|
276
|
+
// Broadcast to every client. Clients silently ignore events for tabIds
|
|
277
|
+
// they don't know about, so there's no UI impact — this costs one
|
|
278
|
+
// extra socket write per tab per change, which is trivial, and
|
|
279
|
+
// removes a race where in-flight tab events could be dropped when a
|
|
280
|
+
// client tears down its socket to switch sessions.
|
|
281
|
+
broadcastAll({ payload: { sessionId, status, tabId }, type: 'tabStatus' })
|
|
282
|
+
},
|
|
149
283
|
})
|
|
150
284
|
|
|
151
285
|
const server = createServer((socket) => {
|
|
@@ -213,12 +347,49 @@ export async function runDaemon(): Promise<void> {
|
|
|
213
347
|
sessionId: message.payload.sessionId,
|
|
214
348
|
workspaceSnapshot: message.payload.workspaceSnapshot,
|
|
215
349
|
})
|
|
350
|
+
for (const tab of attachResult.tabs) {
|
|
351
|
+
rememberTab(
|
|
352
|
+
message.payload.sessionId,
|
|
353
|
+
tab.id,
|
|
354
|
+
tab.assistant,
|
|
355
|
+
tab.command,
|
|
356
|
+
tab.viewport
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
// Classify synchronously BEFORE sending attachResult so
|
|
360
|
+
// each tab's activity and the full session-status snapshot
|
|
361
|
+
// are available to embed in the reply. This makes the
|
|
362
|
+
// client apply them atomically with `hydrate-workspace`
|
|
363
|
+
// and closes the race where separate `tabStatus` events
|
|
364
|
+
// landed before the tab existed in client state.
|
|
365
|
+
const tabsForLoop = listTabsForSession(message.payload.sessionId)
|
|
366
|
+
logDebug('daemon.attach.preClassify', {
|
|
367
|
+
sessionId: message.payload.sessionId,
|
|
368
|
+
tabs: tabsForLoop.map((t) => ({
|
|
369
|
+
assistant: t.assistant,
|
|
370
|
+
hasViewport: t.viewport !== undefined,
|
|
371
|
+
id: t.id,
|
|
372
|
+
seq: tabRegistry.get(t.id)?.viewportSeq ?? null,
|
|
373
|
+
})),
|
|
374
|
+
})
|
|
375
|
+
statusLoop.classifyNow(message.payload.sessionId, tabsForLoop)
|
|
376
|
+
const tabsWithActivity = attachResult.tabs.map((tab) => ({
|
|
377
|
+
...tab,
|
|
378
|
+
activity: statusLoop.getTabStatus(tab.id) ?? tab.activity,
|
|
379
|
+
}))
|
|
380
|
+
const initialSessionStatuses = statusLoop.snapshotSessions()
|
|
381
|
+
logDebug('daemon.attach.replay', {
|
|
382
|
+
sessionId: message.payload.sessionId,
|
|
383
|
+
sessions: initialSessionStatuses,
|
|
384
|
+
tabs: tabsWithActivity.map((t) => ({ activity: t.activity, id: t.id })),
|
|
385
|
+
})
|
|
216
386
|
send(socket, {
|
|
217
387
|
id: message.id,
|
|
218
388
|
payload: {
|
|
219
389
|
activeTabId: attachResult.activeTabId,
|
|
390
|
+
initialSessionStatuses,
|
|
220
391
|
protocolVersion: negotiatedVersion,
|
|
221
|
-
tabs:
|
|
392
|
+
tabs: tabsWithActivity,
|
|
222
393
|
},
|
|
223
394
|
type: 'attachResult',
|
|
224
395
|
})
|
|
@@ -238,6 +409,12 @@ export async function runDaemon(): Promise<void> {
|
|
|
238
409
|
tabId: message.payload.tabId,
|
|
239
410
|
title: message.payload.title,
|
|
240
411
|
})
|
|
412
|
+
rememberTab(
|
|
413
|
+
sessionId,
|
|
414
|
+
message.payload.tabId,
|
|
415
|
+
message.payload.assistant,
|
|
416
|
+
[message.payload.command, ...(message.payload.args ?? [])].join(' ')
|
|
417
|
+
)
|
|
241
418
|
await manager.createTab({ ...message.payload, sessionId })
|
|
242
419
|
sendOk(socket, message.id)
|
|
243
420
|
logDebug('daemon.request.createTab.success', {
|
|
@@ -313,6 +490,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
313
490
|
case 'closeTab': {
|
|
314
491
|
const sessionId = requireSession(socket, attachedSessions)
|
|
315
492
|
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
493
|
+
tabRegistry.delete(message.payload.tabId)
|
|
316
494
|
await manager.closeTab(sessionId, message.payload.tabId)
|
|
317
495
|
sendOk(socket, message.id)
|
|
318
496
|
break
|
|
@@ -321,6 +499,9 @@ export async function runDaemon(): Promise<void> {
|
|
|
321
499
|
const sessionId = attachedSessions.get(socket)
|
|
322
500
|
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
323
501
|
if (sessionId) {
|
|
502
|
+
for (const [tabId, entry] of tabRegistry) {
|
|
503
|
+
if (entry.sessionId === sessionId) tabRegistry.delete(tabId)
|
|
504
|
+
}
|
|
324
505
|
await manager.disposeSession(sessionId)
|
|
325
506
|
}
|
|
326
507
|
sendOk(socket, message.id)
|
|
@@ -371,6 +552,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
371
552
|
|
|
372
553
|
const gracefulShutdown = (signal: string) => {
|
|
373
554
|
logDebug(`daemon.${signal}`)
|
|
555
|
+
statusLoop.stop()
|
|
374
556
|
manager.destroy()
|
|
375
557
|
server.close()
|
|
376
558
|
process.exit(0)
|
|
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events'
|
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
4
|
ScrollIntent,
|
|
5
|
+
TabSession,
|
|
5
6
|
TerminalModeState,
|
|
6
7
|
TerminalSnapshot,
|
|
7
8
|
WorkspaceSnapshotV1,
|
|
@@ -128,4 +129,12 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
|
|
|
128
129
|
}
|
|
129
130
|
this.registries.clear()
|
|
130
131
|
}
|
|
132
|
+
|
|
133
|
+
listSessionIds(): string[] {
|
|
134
|
+
return [...this.registries.keys()]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
listTabs(sessionId: string): TabSession[] {
|
|
138
|
+
return this.registries.get(sessionId)?.listTabs() ?? []
|
|
139
|
+
}
|
|
131
140
|
}
|
|
@@ -44,13 +44,13 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
|
|
|
44
44
|
this.emit('render', tabId, viewport, terminalModes)
|
|
45
45
|
})
|
|
46
46
|
this.ptyManager.on('exit', (tabId, exitCode) => {
|
|
47
|
-
|
|
48
|
-
if (!tab) {
|
|
47
|
+
if (!this.tabs.has(tabId)) {
|
|
49
48
|
return
|
|
50
49
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
this.tabs.delete(tabId)
|
|
51
|
+
if (this.activeTabId === tabId) {
|
|
52
|
+
this.activeTabId = this.listTabs()[0]?.id ?? null
|
|
53
|
+
}
|
|
54
54
|
logDebug('daemon.registry.exit', { exitCode, tabId })
|
|
55
55
|
this.emit('exit', tabId, exitCode)
|
|
56
56
|
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// FNV-1a 32-bit hash. Fast, non-crypto, used to detect stale cached diff
|
|
2
|
+
// artefacts (parsed file, syntax highlights) when the raw diff text changes.
|
|
3
|
+
export function diffHash(input: string): string {
|
|
4
|
+
let h = 0x811c9dc5
|
|
5
|
+
for (let i = 0; i < input.length; i++) {
|
|
6
|
+
h ^= input.charCodeAt(i)
|
|
7
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0
|
|
8
|
+
}
|
|
9
|
+
return h.toString(16)
|
|
10
|
+
}
|
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'
|
|
@@ -49,6 +45,7 @@ export type SideEffect =
|
|
|
49
45
|
| { type: 'scroll-git-diff'; delta: number }
|
|
50
46
|
| { type: 'persist-git-diff-mode-ratio'; ratio: number }
|
|
51
47
|
| { type: 'persist-git-file-list-mode'; mode: GitFileListMode }
|
|
48
|
+
| { type: 'persist-git-tree-compaction'; enabled: boolean }
|
|
52
49
|
| { type: 'git-stage'; path: string }
|
|
53
50
|
| { type: 'git-unstage'; path: string }
|
|
54
51
|
| { type: 'git-restore'; path: string }
|
|
@@ -57,6 +54,7 @@ export type SideEffect =
|
|
|
57
54
|
| { type: 'git-push' }
|
|
58
55
|
| { type: 'confirm-update-selection' }
|
|
59
56
|
| { type: 'switch-session-by-index'; index: number }
|
|
57
|
+
| { type: 'toggle-transparent' }
|
|
60
58
|
|
|
61
59
|
export interface KeyResult {
|
|
62
60
|
actions: AppAction[]
|
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 = 5
|
|
20
|
+
export const IPC_PROTOCOL_VERSION = 5
|
|
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
|
}
|