@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.
Files changed (71) hide show
  1. package/README.md +3 -0
  2. package/package.json +2 -2
  3. package/src/app-runtime/backend-attach-runtime.ts +6 -0
  4. package/src/app-runtime/backend-runtime-events.ts +21 -21
  5. package/src/app-runtime/side-effects.ts +43 -12
  6. package/src/app-runtime/use-backend-runtime.ts +2 -20
  7. package/src/app-runtime/use-directory-search.ts +6 -1
  8. package/src/app.tsx +6 -1
  9. package/src/config.ts +28 -1
  10. package/src/daemon/daemon.ts +197 -15
  11. package/src/daemon/session-manager.ts +9 -0
  12. package/src/daemon/session-registry.ts +5 -5
  13. package/src/git/diff-hash.ts +10 -0
  14. package/src/index.tsx +10 -2
  15. package/src/input/keymap/help-entries.ts +5 -5
  16. package/src/input/modes/bridge.ts +5 -8
  17. package/src/input/modes/transitions.ts +15 -23
  18. package/src/input/modes/types.ts +3 -5
  19. package/src/ipc/protocol.ts +49 -5
  20. package/src/platform/project-search.ts +45 -12
  21. package/src/pty/assistant-status-detection-loop.ts +192 -0
  22. package/src/pty/assistant-status-detector.ts +226 -0
  23. package/src/pty/pty-manager.ts +3 -37
  24. package/src/session-backend/bootstrap.ts +4 -1
  25. package/src/session-backend/local-session-backend.ts +43 -56
  26. package/src/session-backend/remote-session-backend.ts +15 -0
  27. package/src/session-backend/types.ts +10 -1
  28. package/src/state/git-tree.ts +42 -16
  29. package/src/state/reducers/diff-cache.ts +64 -0
  30. package/src/state/reducers/git-mode-state.ts +91 -33
  31. package/src/state/reducers/git-panel-state.ts +33 -2
  32. package/src/state/reducers/modal-state.ts +91 -134
  33. package/src/state/reducers/session-state.ts +13 -6
  34. package/src/state/reducers/tab-state.ts +0 -10
  35. package/src/state/selectors.ts +12 -0
  36. package/src/state/session-persistence.ts +20 -15
  37. package/src/state/store.ts +13 -3
  38. package/src/state/types.ts +87 -14
  39. package/src/ui/breaking-update-screen.tsx +31 -0
  40. package/src/ui/components/bare-input.tsx +44 -0
  41. package/src/ui/components/create-session-modal.tsx +16 -8
  42. package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
  43. package/src/ui/components/diff-renderer/pierre-diff.tsx +9 -31
  44. package/src/ui/components/diff-renderer/prepare-diff.ts +66 -0
  45. package/src/ui/components/diff-renderer/split-view.tsx +35 -16
  46. package/src/ui/components/diff-renderer/stacked-view.tsx +30 -12
  47. package/src/ui/components/diff-renderer/use-diff-prefetch.ts +191 -0
  48. package/src/ui/components/diff-renderer/use-diff-preparation.ts +106 -0
  49. package/src/ui/components/git-pane-widget.tsx +2 -0
  50. package/src/ui/components/git-panel.tsx +27 -10
  51. package/src/ui/components/git-view.tsx +23 -9
  52. package/src/ui/components/help-modal.tsx +45 -160
  53. package/src/ui/components/input-field.tsx +6 -2
  54. package/src/ui/components/list-item.tsx +36 -28
  55. package/src/ui/components/modal-shell.tsx +51 -13
  56. package/src/ui/components/new-tab-modal.tsx +78 -45
  57. package/src/ui/components/picker.tsx +179 -0
  58. package/src/ui/components/session-bar.tsx +47 -21
  59. package/src/ui/components/session-picker-modal.tsx +58 -56
  60. package/src/ui/components/sidebar.tsx +49 -22
  61. package/src/ui/components/snippet-picker-modal.tsx +43 -34
  62. package/src/ui/components/status-bar.tsx +3 -2
  63. package/src/ui/components/surface.tsx +11 -8
  64. package/src/ui/components/tab-item.tsx +38 -22
  65. package/src/ui/components/terminal-pane.tsx +8 -8
  66. package/src/ui/components/theme-picker-modal.tsx +51 -91
  67. package/src/ui/root.tsx +14 -23
  68. package/src/ui/status-bar-model.ts +5 -5
  69. package/src/ui/theme-store.ts +26 -2
  70. package/src/ui/theme.ts +9 -1
  71. package/src/ui/components/modal-filter-bar.tsx +0 -19
@@ -5,6 +5,7 @@ import type { SessionBackend, SessionBackendEvents } from './types'
5
5
 
6
6
  import { SessionManager } from '../daemon/session-manager'
7
7
  import { logDebug } from '../debug/input-log'
8
+ import { runStatusDetectionLoop } from '../pty/assistant-status-detection-loop'
8
9
  import {
9
10
  createTerminalBounds,
10
11
  forEachSplitPaneRect,
@@ -12,16 +13,13 @@ import {
12
13
  toTerminalContentSize,
13
14
  } from '../state/layout-resize'
14
15
 
15
- const SESSION_IDLE_TIMEOUT_MS = 2_000
16
-
17
16
  export class LocalSessionBackend
18
17
  extends EventEmitter<SessionBackendEvents>
19
18
  implements SessionBackend
20
19
  {
21
20
  private readonly sessionManager = new SessionManager()
22
21
  private currentSessionId: string | null = null
23
- private readonly sessionIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
24
- private readonly sessionBusy = new Map<string, boolean>()
22
+ private readonly statusLoop: ReturnType<typeof runStatusDetectionLoop>
25
23
 
26
24
  constructor() {
27
25
  super()
@@ -29,7 +27,6 @@ export class LocalSessionBackend
29
27
  if (sessionId === this.currentSessionId) {
30
28
  this.emit('render', tabId, viewport, terminalModes)
31
29
  }
32
- this.markSessionBusy(sessionId)
33
30
  })
34
31
  this.sessionManager.on('exit', (sessionId, tabId, exitCode) => {
35
32
  if (sessionId === this.currentSessionId) {
@@ -41,23 +38,18 @@ export class LocalSessionBackend
41
38
  this.emit('error', tabId, message)
42
39
  }
43
40
  })
44
- }
45
-
46
- private markSessionBusy(sessionId: string): void {
47
- if (this.sessionBusy.get(sessionId) !== true) {
48
- this.sessionBusy.set(sessionId, true)
49
- this.emit('sessionActivity', sessionId, true)
50
- }
51
- const existing = this.sessionIdleTimers.get(sessionId)
52
- if (existing) clearTimeout(existing)
53
- const timer = setTimeout(() => {
54
- this.sessionIdleTimers.delete(sessionId)
55
- if (this.sessionBusy.get(sessionId) === true) {
56
- this.sessionBusy.set(sessionId, false)
57
- this.emit('sessionActivity', sessionId, false)
58
- }
59
- }, SESSION_IDLE_TIMEOUT_MS)
60
- this.sessionIdleTimers.set(sessionId, timer)
41
+ this.statusLoop = runStatusDetectionLoop({
42
+ listSessions: () => this.sessionManager.listSessionIds(),
43
+ listTabs: (sessionId) => this.sessionManager.listTabs(sessionId),
44
+ onSessionStatus: (sessionId, status) => {
45
+ this.emit('sessionActivity', sessionId, status)
46
+ },
47
+ onTabStatus: (tabId, status, sessionId) => {
48
+ if (sessionId === this.currentSessionId) {
49
+ this.emit('tabActivity', tabId, status)
50
+ }
51
+ },
52
+ })
61
53
  }
62
54
 
63
55
  async attach(options: {
@@ -84,7 +76,24 @@ export class LocalSessionBackend
84
76
  } else {
85
77
  this.sessionManager.resize(options.sessionId, options.cols, options.rows)
86
78
  }
87
- return this.sessionManager.attachSession(options.sessionId, options.workspaceSnapshot)
79
+ const attachResult = this.sessionManager.attachSession(
80
+ options.sessionId,
81
+ options.workspaceSnapshot
82
+ )
83
+ // Run a synchronous classification pass so every tab's activity and the
84
+ // session-status snapshot are available to embed in the reply — mirrors
85
+ // the remote backend's behavior and keeps hydrate dispatches atomic on
86
+ // the client side.
87
+ this.statusLoop.classifyNow(options.sessionId, this.sessionManager.listTabs(options.sessionId))
88
+ const tabsWithActivity = attachResult.tabs.map((tab) => ({
89
+ ...tab,
90
+ activity: this.statusLoop.getTabStatus(tab.id) ?? tab.activity,
91
+ }))
92
+ return {
93
+ activeTabId: attachResult.activeTabId,
94
+ initialSessionStatuses: this.statusLoop.snapshotSessions(),
95
+ tabs: tabsWithActivity,
96
+ }
88
97
  }
89
98
 
90
99
  createSession(options: {
@@ -123,30 +132,22 @@ export class LocalSessionBackend
123
132
  }
124
133
 
125
134
  scrollViewport(tabId: string, deltaLines: number): void {
126
- if (!this.currentSessionId) {
127
- return
128
- }
135
+ if (!this.currentSessionId) return
129
136
  this.sessionManager.scroll(this.currentSessionId, tabId, deltaLines)
130
137
  }
131
138
 
132
139
  scrollViewportToBottom(tabId: string): void {
133
- if (!this.currentSessionId) {
134
- return
135
- }
140
+ if (!this.currentSessionId) return
136
141
  this.sessionManager.scrollToBottom(this.currentSessionId, tabId)
137
142
  }
138
143
 
139
144
  reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
140
- if (!this.currentSessionId) {
141
- return
142
- }
145
+ if (!this.currentSessionId) return
143
146
  this.sessionManager.reapplyScrollIntent(this.currentSessionId, tabId, intent)
144
147
  }
145
148
 
146
149
  setActiveTab(tabId: string | null): void {
147
- if (!this.currentSessionId) {
148
- return
149
- }
150
+ if (!this.currentSessionId) return
150
151
  logDebug('backend.local.setActiveTab', { sessionId: this.currentSessionId, tabId })
151
152
  this.sessionManager.setActiveTab(this.currentSessionId, tabId)
152
153
  }
@@ -157,9 +158,7 @@ export class LocalSessionBackend
157
158
  intents?: Map<string, ScrollIntent>,
158
159
  options?: { sync?: boolean }
159
160
  ): void {
160
- if (!this.currentSessionId) {
161
- return
162
- }
161
+ if (!this.currentSessionId) return
163
162
  this.sessionManager.resize(this.currentSessionId, cols, rows, intents, options)
164
163
  }
165
164
 
@@ -170,40 +169,28 @@ export class LocalSessionBackend
170
169
  intent?: ScrollIntent,
171
170
  options?: { sync?: boolean }
172
171
  ): void {
173
- if (!this.currentSessionId) {
174
- return
175
- }
172
+ if (!this.currentSessionId) return
176
173
  this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent, options)
177
174
  }
178
175
 
179
176
  disposeSession(tabId: string): void {
180
- if (!this.currentSessionId) {
181
- return
182
- }
177
+ if (!this.currentSessionId) return
183
178
  logDebug('backend.local.disposeSession', { sessionId: this.currentSessionId, tabId })
184
179
  this.sessionManager.closeTab(this.currentSessionId, tabId)
185
180
  }
186
181
 
187
182
  disposeAll(): void {
188
- if (!this.currentSessionId) {
189
- return
190
- }
183
+ if (!this.currentSessionId) return
191
184
  logDebug('backend.local.disposeAll', { sessionId: this.currentSessionId })
192
185
  this.sessionManager.disposeSession(this.currentSessionId)
193
186
  }
194
187
 
195
188
  destroy(keepSessions = true): void {
196
189
  logDebug('backend.local.destroy', { keepSessions, sessionId: this.currentSessionId })
197
- if (!keepSessions) {
198
- if (this.currentSessionId) {
199
- this.sessionManager.disposeSession(this.currentSessionId)
200
- }
201
- }
202
- for (const timer of this.sessionIdleTimers.values()) {
203
- clearTimeout(timer)
190
+ if (!keepSessions && this.currentSessionId) {
191
+ this.sessionManager.disposeSession(this.currentSessionId)
204
192
  }
205
- this.sessionIdleTimers.clear()
206
- this.sessionBusy.clear()
193
+ this.statusLoop.stop()
207
194
  this.currentSessionId = null
208
195
  }
209
196
  }
@@ -161,6 +161,21 @@ export class RemoteSessionBackend
161
161
  case 'tabError':
162
162
  this.emit('error', message.payload.tabId, message.payload.message)
163
163
  break
164
+ case 'tabStatus':
165
+ logDebug('backend.remote.tabStatus', {
166
+ sessionId: message.payload.sessionId,
167
+ status: message.payload.status,
168
+ tabId: message.payload.tabId,
169
+ })
170
+ this.emit('tabActivity', message.payload.tabId, message.payload.status)
171
+ break
172
+ case 'sessionStatus':
173
+ logDebug('backend.remote.sessionStatus', {
174
+ sessionId: message.payload.sessionId,
175
+ status: message.payload.status,
176
+ })
177
+ this.emit('sessionActivity', message.payload.sessionId, message.payload.status)
178
+ break
164
179
  }
165
180
  }
166
181
 
@@ -2,6 +2,8 @@ import type { EventEmitter } from 'node:events'
2
2
 
3
3
  import type {
4
4
  ScrollIntent,
5
+ SessionStatus,
6
+ TabActivity,
5
7
  TabSession,
6
8
  TerminalModeState,
7
9
  TerminalSnapshot,
@@ -12,12 +14,19 @@ export type SessionBackendEvents = {
12
14
  render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
13
15
  exit: [tabId: string, exitCode: number]
14
16
  error: [tabId: string, message: string]
15
- sessionActivity: [sessionId: string, busy: boolean]
17
+ sessionActivity: [sessionId: string, status: SessionStatus]
18
+ tabActivity: [tabId: string, activity: TabActivity]
16
19
  }
17
20
 
18
21
  export interface BackendAttachResult {
19
22
  tabs: TabSession[]
20
23
  activeTabId: string | null
24
+ /**
25
+ * Per-session status snapshot taken at attach time and applied
26
+ * atomically with tab hydration to prevent an unknown-tab race on
27
+ * separate `sessionActivity` events.
28
+ */
29
+ initialSessionStatuses: Array<{ sessionId: string; status: SessionStatus }>
21
30
  }
22
31
 
23
32
  export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
@@ -53,7 +53,8 @@ export function gitFolderKey(section: GitFileSection, folderPath: string): strin
53
53
  export function buildGitTreeRows(
54
54
  files: GitFileEntry[],
55
55
  collapsedFolders: Record<string, true>,
56
- fileListMode: GitFileListMode = 'tree'
56
+ fileListMode: GitFileListMode = 'tree',
57
+ compact: boolean = false
57
58
  ): GitTreeRows {
58
59
  const sections = SECTION_ORDER.map((section) => {
59
60
  const sectionFiles = files.filter((file) => file.section === section)
@@ -68,7 +69,7 @@ export function buildGitTreeRows(
68
69
  kind: 'file' as const,
69
70
  section,
70
71
  }))
71
- : flattenSectionRows(sectionFiles, collapsedFolders),
72
+ : flattenSectionRows(sectionFiles, collapsedFolders, compact),
72
73
  section,
73
74
  }
74
75
  })
@@ -81,10 +82,16 @@ export function getSelectedGitRow(
81
82
  collapsedFolders: Record<string, true>
82
83
  fileListMode: GitFileListMode
83
84
  selectedEntryKey: string | null
85
+ compact?: boolean
84
86
  }
85
87
  ): GitTreeRow | null {
86
88
  if (!options.selectedEntryKey) return null
87
- const { visibleRows } = buildGitTreeRows(files, options.collapsedFolders, options.fileListMode)
89
+ const { visibleRows } = buildGitTreeRows(
90
+ files,
91
+ options.collapsedFolders,
92
+ options.fileListMode,
93
+ options.compact ?? false
94
+ )
88
95
  return visibleRows.find((row) => row.key === options.selectedEntryKey) ?? null
89
96
  }
90
97
 
@@ -94,6 +101,7 @@ export function getSelectedGitFile(
94
101
  collapsedFolders: Record<string, true>
95
102
  fileListMode: GitFileListMode
96
103
  selectedEntryKey: string | null
104
+ compact?: boolean
97
105
  }
98
106
  ): GitFileEntry | null {
99
107
  const row = getSelectedGitRow(files, options)
@@ -105,9 +113,10 @@ export function reconcileSelectedGitEntryKey(
105
113
  collapsedFolders: Record<string, true>,
106
114
  fileListMode: GitFileListMode,
107
115
  selectedEntryKey: string | null | undefined,
108
- preferredKeys: string[] = []
116
+ preferredKeys: string[] = [],
117
+ compact: boolean = false
109
118
  ): string | null {
110
- const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode)
119
+ const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode, compact)
111
120
  if (visibleRows.length === 0) return null
112
121
  const candidates = [...preferredKeys, selectedEntryKey ?? '']
113
122
  for (const key of candidates) {
@@ -122,9 +131,10 @@ export function moveGitSelection(
122
131
  collapsedFolders: Record<string, true>,
123
132
  fileListMode: GitFileListMode,
124
133
  selectedEntryKey: string | null,
125
- delta: -1 | 1
134
+ delta: -1 | 1,
135
+ compact: boolean = false
126
136
  ): string | null {
127
- const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode)
137
+ const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode, compact)
128
138
  const total = visibleRows.length
129
139
  if (total === 0) return null
130
140
  const current = visibleRows.findIndex((row) => row.key === selectedEntryKey)
@@ -137,9 +147,10 @@ export function moveGitFileSelection(
137
147
  collapsedFolders: Record<string, true>,
138
148
  fileListMode: GitFileListMode,
139
149
  selectedEntryKey: string | null,
140
- delta: -1 | 1
150
+ delta: -1 | 1,
151
+ compact: boolean = false
141
152
  ): string | null {
142
- const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode)
153
+ const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode, compact)
143
154
  const fileRows = visibleRows.filter((row) => row.kind === 'file')
144
155
  const total = fileRows.length
145
156
  if (total === 0) return null
@@ -153,13 +164,14 @@ export function moveGitFileSelection(
153
164
 
154
165
  function flattenSectionRows(
155
166
  files: GitFileEntry[],
156
- collapsedFolders: Record<string, true>
167
+ collapsedFolders: Record<string, true>,
168
+ compact: boolean
157
169
  ): GitTreeRow[] {
158
170
  if (files.length === 0) return []
159
171
  const section = files[0]?.section
160
172
  if (!section) return []
161
173
  const root = buildSectionTree(files, section)
162
- return flattenTreeNode(root, collapsedFolders, 0)
174
+ return flattenTreeNode(root, collapsedFolders, 0, undefined, compact)
163
175
  }
164
176
 
165
177
  function buildSectionTree(files: GitFileEntry[], section: GitFileSection): GitTreeNode {
@@ -187,16 +199,28 @@ function flattenTreeNode(
187
199
  node: GitTreeNode,
188
200
  collapsedFolders: Record<string, true>,
189
201
  depth: number,
190
- parentKey?: string
202
+ parentKey: string | undefined,
203
+ compact: boolean
191
204
  ): GitTreeRow[] {
192
205
  const rows: GitTreeRow[] = []
193
206
  for (const child of node.folders.values()) {
194
- const name = child.path.split('/').pop() ?? child.path
195
- const key = gitFolderKey(node.section, child.path)
207
+ // Compaction: while a folder has exactly one child folder and no direct
208
+ // files, fold the chain into a single row ("src/keymap/keymap.ts" instead
209
+ // of three nested rows).
210
+ let current = child
211
+ const segments: string[] = [current.path.split('/').pop() ?? current.path]
212
+ while (compact && current.files.length === 0 && current.folders.size === 1) {
213
+ const next = current.folders.values().next().value
214
+ if (!next) break
215
+ segments.push(next.path.split('/').pop() ?? next.path)
216
+ current = next
217
+ }
218
+ const name = segments.join('/')
219
+ const key = gitFolderKey(node.section, current.path)
196
220
  const isCollapsed = key in collapsedFolders
197
221
  rows.push({
198
222
  depth,
199
- folderPath: child.path,
223
+ folderPath: current.path,
200
224
  isCollapsed,
201
225
  key,
202
226
  kind: 'folder',
@@ -204,7 +228,9 @@ function flattenTreeNode(
204
228
  parentKey,
205
229
  section: node.section,
206
230
  })
207
- if (!isCollapsed) rows.push(...flattenTreeNode(child, collapsedFolders, depth + 1, key))
231
+ if (!isCollapsed) {
232
+ rows.push(...flattenTreeNode(current, collapsedFolders, depth + 1, key, compact))
233
+ }
208
234
  }
209
235
  for (const file of node.files) {
210
236
  rows.push({
@@ -0,0 +1,64 @@
1
+ import type { AppState } from '../types'
2
+
3
+ export function clearDiffCacheForPath(state: AppState, path: string): AppState {
4
+ return clearDiffCacheForPaths(state, new Set([path]))
5
+ }
6
+
7
+ // Drops every cache slice derived from the given file paths: diffs, folds,
8
+ // loading flags, parsed patches, and per-theme highlight tokens. Called when
9
+ // a file's on-disk content shifts or a file disappears from git status.
10
+ export function clearDiffCacheForPaths(state: AppState, paths: Set<string>): AppState {
11
+ if (paths.size === 0) return state
12
+ const nextDiffs = { ...state.gitMode.diffs }
13
+ const nextFolds = { ...state.gitMode.folds }
14
+ const nextLoading = { ...state.gitMode.loading }
15
+ const nextParsed = { ...state.gitMode.parsedFiles }
16
+ const nextHighlights = { ...state.gitMode.highlights }
17
+ let changed = false
18
+ const keyMatches = (key: string): boolean => {
19
+ for (const path of paths) {
20
+ if (key.endsWith(`:${path}`) || key.startsWith(`${path}|`) || key.includes(`:${path}|`)) {
21
+ return true
22
+ }
23
+ }
24
+ return false
25
+ }
26
+ for (const key of Object.keys(nextDiffs)) {
27
+ const entry = nextDiffs[key]
28
+ if (!entry || !paths.has(entry.path)) continue
29
+ delete nextDiffs[key]
30
+ changed = true
31
+ }
32
+ for (const key of Object.keys(nextFolds)) {
33
+ if (!keyMatches(key)) continue
34
+ delete nextFolds[key]
35
+ changed = true
36
+ }
37
+ for (const key of Object.keys(nextLoading)) {
38
+ if (!keyMatches(key)) continue
39
+ delete nextLoading[key]
40
+ changed = true
41
+ }
42
+ for (const key of Object.keys(nextParsed)) {
43
+ if (!keyMatches(key)) continue
44
+ delete nextParsed[key]
45
+ changed = true
46
+ }
47
+ for (const key of Object.keys(nextHighlights)) {
48
+ if (!keyMatches(key)) continue
49
+ delete nextHighlights[key]
50
+ changed = true
51
+ }
52
+ if (!changed) return state
53
+ return {
54
+ ...state,
55
+ gitMode: {
56
+ ...state.gitMode,
57
+ diffs: nextDiffs,
58
+ folds: nextFolds,
59
+ highlights: nextHighlights,
60
+ loading: nextLoading,
61
+ parsedFiles: nextParsed,
62
+ },
63
+ }
64
+ }