@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
@@ -64,8 +64,8 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
64
64
  const filteredNew = filterSessions(newSessions, state.modal.editBuffer)
65
65
  const maxIndex = filteredNew.length
66
66
  const clampedIndex = Math.min(state.modal.selectedIndex, maxIndex)
67
- const nextBusy = { ...state.sessionsBusy }
68
- delete nextBusy[action.sessionId]
67
+ const nextStatuses = { ...state.sessionStatuses }
68
+ delete nextStatuses[action.sessionId]
69
69
  return {
70
70
  ...state,
71
71
  activeTabId: action.sessionId === state.currentSessionId ? null : state.activeTabId,
@@ -79,7 +79,7 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
79
79
  type: 'session-picker',
80
80
  },
81
81
  sessions: newSessions,
82
- sessionsBusy: nextBusy,
82
+ sessionStatuses: nextStatuses,
83
83
  tabs: action.sessionId === state.currentSessionId ? [] : state.tabs,
84
84
  }
85
85
  }
@@ -101,11 +101,18 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
101
101
  }
102
102
  return { ...state, sessions: ordered }
103
103
  }
104
- case 'set-session-busy': {
105
- if ((state.sessionsBusy[action.sessionId] ?? false) === action.busy) return state
104
+ case 'set-session-status': {
105
+ const prev = state.sessionStatuses[action.sessionId]
106
+ if (
107
+ prev !== undefined &&
108
+ prev.working === action.status.working &&
109
+ prev.waiting === action.status.waiting
110
+ ) {
111
+ return state
112
+ }
106
113
  return {
107
114
  ...state,
108
- sessionsBusy: { ...state.sessionsBusy, [action.sessionId]: action.busy },
115
+ sessionStatuses: { ...state.sessionStatuses, [action.sessionId]: action.status },
109
116
  }
110
117
  }
111
118
  default:
@@ -432,16 +432,6 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
432
432
  ...state,
433
433
  tabs: updateTab(state.tabs, action.tabId, (tab) => ({ ...tab, activity: action.activity })),
434
434
  }
435
- case 'set-tab-status':
436
- return {
437
- ...state,
438
- tabs: updateTab(state.tabs, action.tabId, (tab) => ({
439
- ...tab,
440
- activity: action.status === 'running' ? tab.activity : undefined,
441
- exitCode: action.exitCode,
442
- status: action.status,
443
- })),
444
- }
445
435
  case 'set-tab-error':
446
436
  return {
447
437
  ...state,
@@ -1,5 +1,17 @@
1
+ import type { AssistantOption } from '../pty/command-registry'
1
2
  import type { SessionRecord, SnippetRecord } from './types'
2
3
 
4
+ export function filterAssistants(
5
+ options: AssistantOption[],
6
+ filter: string | null
7
+ ): AssistantOption[] {
8
+ if (!filter) return options
9
+ const lower = filter.toLowerCase()
10
+ return options.filter(
11
+ (o) => o.label.toLowerCase().includes(lower) || o.description.toLowerCase().includes(lower)
12
+ )
13
+ }
14
+
3
15
  export function filterSessions(sessions: SessionRecord[], filter: string | null): SessionRecord[] {
4
16
  if (!filter) {
5
17
  return sessions
@@ -20,7 +20,7 @@ export function createEmptyWorkspaceSnapshot(): WorkspaceSnapshotV1 {
20
20
  }
21
21
  }
22
22
 
23
- function getDisconnectedStatus(status: WorkspaceSnapshotV1['tabs'][number]['status']): TabStatus {
23
+ function getDisconnectedStatus(status: TabStatus): TabStatus {
24
24
  if (status === 'running' || status === 'starting') {
25
25
  return 'disconnected'
26
26
  }
@@ -61,20 +61,25 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
61
61
  return []
62
62
  }
63
63
 
64
- return snapshot.tabs.map((tab) => ({
65
- activity: 'idle',
66
- assistant: tab.assistant,
67
- buffer: tab.buffer,
68
- command: tab.command,
69
- errorMessage: tab.errorMessage,
70
- exitCode: tab.exitCode,
71
- id: tab.id,
72
- scrollIntent: tab.scrollIntent ?? DEFAULT_SCROLL_INTENT,
73
- status: getDisconnectedStatus(tab.status),
74
- terminalModes: tab.terminalModes,
75
- title: tab.title,
76
- viewport: tab.viewport,
77
- }))
64
+ return snapshot.tabs
65
+ .filter(
66
+ (tab): tab is typeof tab & { status: Exclude<typeof tab.status, 'exited'> } =>
67
+ tab.status !== 'exited'
68
+ )
69
+ .map((tab) => ({
70
+ activity: 'idle',
71
+ assistant: tab.assistant,
72
+ buffer: tab.buffer,
73
+ command: tab.command,
74
+ errorMessage: tab.errorMessage,
75
+ exitCode: tab.exitCode,
76
+ id: tab.id,
77
+ scrollIntent: tab.scrollIntent ?? DEFAULT_SCROLL_INTENT,
78
+ status: getDisconnectedStatus(tab.status),
79
+ terminalModes: tab.terminalModes,
80
+ title: tab.title,
81
+ viewport: tab.viewport,
82
+ }))
78
83
  }
79
84
 
80
85
  export function restoreLayoutTrees(
@@ -38,7 +38,9 @@ const DEFAULT_GIT_PANE: GitPaneState = {
38
38
  mode: 'embedded',
39
39
  path: { enabled: true },
40
40
  position: 'bottom',
41
+ prefetchRadius: 5,
41
42
  ratio: 0.5,
43
+ treeCompaction: true,
42
44
  visible: true,
43
45
  }
44
46
 
@@ -65,7 +67,7 @@ export function createInitialState(
65
67
  activeTabId: null,
66
68
  currentSessionId: null,
67
69
  customCommands,
68
- focusMode: showSessionPicker ? 'modal' : 'navigation',
70
+ focusMode: showSessionPicker ? 'command-edit' : 'navigation',
69
71
  gitMode: { ...emptyGitMode(), ...overrides.gitMode },
70
72
  gitPane: {
71
73
  ...DEFAULT_GIT_PANE,
@@ -80,7 +82,13 @@ export function createInitialState(
80
82
  },
81
83
  layoutTrees: {},
82
84
  modal: showSessionPicker
83
- ? { editBuffer: null, selectedIndex: 0, sessionTargetId: null, type: 'session-picker' }
85
+ ? {
86
+ cursorPos: 0,
87
+ editBuffer: '',
88
+ selectedIndex: 0,
89
+ sessionTargetId: null,
90
+ type: 'session-picker',
91
+ }
84
92
  : emptyModal(),
85
93
  pendingChords: null,
86
94
  sessionBar: {
@@ -88,7 +96,7 @@ export function createInitialState(
88
96
  visible: overrides.sessionBarVisible ?? true,
89
97
  },
90
98
  sessions,
91
- sessionsBusy: {},
99
+ sessionStatuses: {},
92
100
  sidebar: {
93
101
  maxWidth: DEFAULT_SIDEBAR_MAX_WIDTH,
94
102
  minWidth: DEFAULT_SIDEBAR_MIN_WIDTH,
@@ -123,6 +131,8 @@ export function appReducer(state: AppState, action: AppAction): AppState {
123
131
  switch (action.type) {
124
132
  case 'set-snippets':
125
133
  return { ...state, snippets: action.snippets }
134
+ case 'set-custom-commands':
135
+ return { ...state, customCommands: action.customCommands }
126
136
  case 'delete-snippet': {
127
137
  const newSnippets = state.snippets.filter((s) => s.id !== action.snippetId)
128
138
  const filteredNew = filterSnippets(newSnippets, state.modal.editBuffer)
@@ -1,12 +1,33 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
+ import type { ThemedToken } from 'shiki'
3
+
4
+ import type { FileDiffMetadata } from '../diff-parser'
2
5
 
3
6
  export type BuiltinAssistantId = 'claude' | 'codex' | 'opencode' | 'terminal'
4
7
 
5
8
  export type AssistantId = BuiltinAssistantId | (string & {})
6
9
 
7
- export type TabStatus = 'starting' | 'running' | 'disconnected' | 'exited' | 'error'
10
+ export type TabStatus = 'starting' | 'running' | 'disconnected' | 'error'
11
+
12
+ /**
13
+ * Status values that may appear in legacy on-disk workspace snapshots but are
14
+ * not produced by the running app anymore. Filtered at restore time.
15
+ */
16
+ export type LegacyPersistedTabStatus = TabStatus | 'exited'
17
+
18
+ export type TabActivity = 'working' | 'waiting-input' | 'idle'
19
+
20
+ /**
21
+ * Per-session status flags. Both can be true at once (e.g. one tab working,
22
+ * another waiting for user input) so we keep them as independent booleans
23
+ * rather than a single priority enum.
24
+ */
25
+ export interface SessionStatus {
26
+ working: boolean
27
+ waiting: boolean
28
+ }
8
29
 
9
- export type TabActivity = 'busy' | 'idle'
30
+ export const IDLE_SESSION_STATUS: SessionStatus = { waiting: false, working: false }
10
31
 
11
32
  export type FocusMode = 'navigation' | 'terminal-input' | 'modal' | 'command-edit' | 'git'
12
33
 
@@ -69,7 +90,7 @@ export interface PersistedTabSnapshot {
69
90
  assistant: AssistantId
70
91
  title: string
71
92
  command: string
72
- status: Exclude<TabStatus, 'disconnected'>
93
+ status: Exclude<LegacyPersistedTabStatus, 'disconnected'>
73
94
  buffer: string
74
95
  viewport?: TerminalSnapshot
75
96
  terminalModes: TerminalModeState
@@ -148,8 +169,11 @@ export interface GitPaneState {
148
169
  ratio: number
149
170
  diffModeRatio: number
150
171
  fileListMode: GitFileListMode
172
+ treeCompaction: boolean
151
173
  path: GitPanePathConfig
152
174
  diffCount: GitPaneDiffCountConfig
175
+ /** Prefetch this many neighbours around the selection. 0 disables prefetch. */
176
+ prefetchRadius: number
153
177
  }
154
178
 
155
179
  export type GitFileStatus = 'M' | 'A' | 'D' | 'R' | 'C' | 'U' | '?'
@@ -195,10 +219,45 @@ export interface FoldState {
195
219
  bottom: number
196
220
  }
197
221
 
222
+ export interface ParsedDiffEntry {
223
+ hash: string
224
+ // Stored as unknown at the state boundary; concrete type is FileDiffMetadata
225
+ // | null, narrowed at read sites via getParsedFile().
226
+ file: unknown
227
+ }
228
+
229
+ export interface HighlightsEntry {
230
+ hash: string
231
+ themeId: string
232
+ // See ParsedDiffEntry note — narrowed via getHighlights().
233
+ add: unknown
234
+ del: unknown
235
+ }
236
+
237
+ export function getParsedFile(entry: ParsedDiffEntry | undefined): FileDiffMetadata | null {
238
+ if (!entry) return null
239
+ return entry.file as FileDiffMetadata | null
240
+ }
241
+
242
+ export function getHighlightsTokens(entry: HighlightsEntry | undefined): {
243
+ add: ThemedToken[][]
244
+ del: ThemedToken[][]
245
+ } | null {
246
+ if (!entry) return null
247
+ return {
248
+ add: entry.add as ThemedToken[][],
249
+ del: entry.del as ThemedToken[][],
250
+ }
251
+ }
252
+
198
253
  export interface GitModeState {
199
254
  selectedEntryKey: string | null
200
255
  collapsedFolders: Record<string, true>
201
256
  diffs: Record<string, DiffData>
257
+ /** Parsed diff keyed by fileKey. Invalidated on file change or head offset shift. */
258
+ parsedFiles: Record<string, ParsedDiffEntry>
259
+ /** Tokenised highlights keyed by `${fileKey}|${themeId}`. */
260
+ highlights: Record<string, HighlightsEntry>
202
261
  loading: Record<string, boolean>
203
262
  pendingDeletePath: string | null
204
263
  actionMessage: string | null
@@ -223,6 +282,7 @@ export interface ModalClosed extends ModalBase {
223
282
 
224
283
  export interface ModalNewTab extends ModalBase {
225
284
  type: 'new-tab'
285
+ editingCommand: AssistantId | null
226
286
  }
227
287
 
228
288
  export interface ModalSessionPicker extends ModalBase {
@@ -269,6 +329,7 @@ export interface ModalCreateSession extends ModalBase {
269
329
  pendingProjectPath: string | null
270
330
  activeField: 'directory' | 'name'
271
331
  nameBuffer: string
332
+ returnToSessionPicker: boolean
272
333
  }
273
334
 
274
335
  export interface ModalSnippetEditor extends ModalBase {
@@ -323,7 +384,7 @@ export interface AppState {
323
384
  tabGroupMap: Record<string, string>
324
385
  sessions: SessionRecord[]
325
386
  currentSessionId: string | null
326
- sessionsBusy: Record<string, boolean>
387
+ sessionStatuses: Record<string, SessionStatus>
327
388
  sessionBar: SessionBarState
328
389
  snippets: SnippetRecord[]
329
390
  focusMode: FocusMode
@@ -342,31 +403,27 @@ export interface AppState {
342
403
  export type ModalAction =
343
404
  | { type: 'move-modal-cursor'; delta?: number; to?: 'home' | 'end' }
344
405
  | { type: 'open-new-tab-modal' }
406
+ | { type: 'open-edit-custom-command'; assistantId: AssistantId }
345
407
  | { type: 'open-help-modal'; scope?: ModeId }
346
408
  | { type: 'open-split-picker'; direction: import('./layout-tree').SplitDirection }
347
409
  | { type: 'open-session-picker' }
348
410
  | { type: 'open-session-name-modal'; sessionTargetId?: string; initialName?: string }
349
411
  | { type: 'close-modal' }
350
412
  | { type: 'move-modal-selection'; delta: number }
351
- | { type: 'begin-command-edit' }
352
413
  | { type: 'update-command-edit'; char: string }
353
- | { type: 'commit-command-edit' }
354
414
  | { type: 'cancel-command-edit' }
355
- | { type: 'open-create-session-modal' }
415
+ | { type: 'open-create-session-modal'; returnToSessionPicker: boolean }
356
416
  | { type: 'set-directory-results'; results: DirectoryResult[] }
357
417
  | { type: 'switch-create-session-field' }
358
418
  | { type: 'select-directory' }
359
- | { type: 'begin-session-filter' }
360
419
  | { type: 'open-rename-tab-modal' }
361
420
  | { type: 'open-snippet-picker' }
362
421
  | { type: 'open-snippet-editor'; snippetId?: string }
363
- | { type: 'begin-snippet-filter' }
364
- | { type: 'begin-help-filter' }
365
- | { type: 'begin-theme-filter' }
366
422
  | { type: 'set-help-entry-count'; count: number }
367
423
  | { type: 'set-theme-entry-count'; count: number }
368
424
  | { type: 'open-theme-picker' }
369
425
  | { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
426
+ | { type: 'set-modal-selection-index'; index: number }
370
427
 
371
428
  // -- Session actions --
372
429
  export type SessionAction =
@@ -376,7 +433,7 @@ export type SessionAction =
376
433
  | { type: 'rename-session-record'; sessionId: string; name: string }
377
434
  | { type: 'delete-session-record'; sessionId: string }
378
435
  | { type: 'reorder-sessions'; orderedIds: string[] }
379
- | { type: 'set-session-busy'; sessionId: string; busy: boolean }
436
+ | { type: 'set-session-status'; sessionId: string; status: SessionStatus }
380
437
 
381
438
  // -- Tab actions --
382
439
  export type TabAction =
@@ -406,7 +463,6 @@ export type TabAction =
406
463
  }
407
464
  | { type: 'set-scroll-intent'; tabId: string; intent: ScrollIntent }
408
465
  | { type: 'set-tab-activity'; tabId: string; activity?: TabActivity }
409
- | { type: 'set-tab-status'; tabId: string; status: TabStatus; exitCode?: number }
410
466
  | { type: 'set-tab-error'; tabId: string; message: string }
411
467
 
412
468
  // -- Layout actions --
@@ -473,7 +529,23 @@ export type GitModeAction =
473
529
  | { type: 'git-mode-collapse-selection' }
474
530
  | { type: 'git-mode-expand-selection' }
475
531
  | { type: 'git-mode-toggle-file-list-mode' }
476
- | { type: 'git-mode-set-diff'; key: string; diff: DiffData }
532
+ | { type: 'git-mode-toggle-tree-compaction' }
533
+ | { type: 'git-mode-set-diff'; key: string; diff: DiffData; hash: string }
534
+ | {
535
+ type: 'git-mode-set-parsed'
536
+ key: string
537
+ hash: string
538
+ file: unknown
539
+ }
540
+ | {
541
+ type: 'git-mode-set-highlights'
542
+ key: string
543
+ hash: string
544
+ themeId: string
545
+ add: unknown
546
+ del: unknown
547
+ }
548
+ | { type: 'git-mode-invalidate-diffs'; paths: string[] }
477
549
  | { type: 'git-mode-set-loading'; key: string; loading: boolean }
478
550
  | { type: 'git-mode-set-pending-delete'; path: string | null }
479
551
  | { type: 'git-mode-clear-diff-cache'; path: string }
@@ -501,6 +573,7 @@ export type GitModeAction =
501
573
  export type DataAction =
502
574
  | { type: 'set-snippets'; snippets: SnippetRecord[] }
503
575
  | { type: 'delete-snippet'; snippetId: string }
576
+ | { type: 'set-custom-commands'; customCommands: Record<AssistantId, string> }
504
577
 
505
578
  export type AppAction =
506
579
  | ModalAction
@@ -0,0 +1,31 @@
1
+ import { useKeyboard } from '@opentui/react'
2
+
3
+ import { ListItem } from './components/list-item'
4
+ import { ModalShell } from './components/modal-shell'
5
+ import { uiTokens } from './ui-tokens'
6
+
7
+ interface BreakingUpdateScreenProps {
8
+ onConfirm: () => void
9
+ }
10
+
11
+ export function BreakingUpdateScreen({ onConfirm }: BreakingUpdateScreenProps) {
12
+ useKeyboard((key) => {
13
+ if (key.name === 'return') {
14
+ key.preventDefault()
15
+ onConfirm()
16
+ }
17
+ })
18
+
19
+ return (
20
+ <ModalShell title="Breaking update detected" width={uiTokens.modalWidth.md}>
21
+ <box flexDirection="column" gap={1}>
22
+ <box flexDirection="column" gap={0}>
23
+ <text>{"We're sorry, but this update contains breaking protocol changes."}</text>
24
+ <text>The terminal manager must be restarted.</text>
25
+ <text>You will lose your current terminal states.</text>
26
+ </box>
27
+ <ListItem active direction="row" title={<text>OK — Restart terminal manager</text>} />
28
+ </box>
29
+ </ModalShell>
30
+ )
31
+ }
@@ -0,0 +1,44 @@
1
+ import { useTheme } from '../theme'
2
+
3
+ interface BareInputProps {
4
+ value: string
5
+ cursorPos?: number
6
+ placeholder?: string
7
+ }
8
+
9
+ export function BareInput({ cursorPos, placeholder = '', value }: BareInputProps) {
10
+ const theme = useTheme()
11
+ const fg = theme.colors['editor.foreground']
12
+ const bg = theme.colors['editor.background']
13
+ const placeholderFg = theme.colors['editorLineNumber.foreground']
14
+
15
+ if (!value) {
16
+ const firstChar = placeholder.charAt(0) || ' '
17
+ const rest = placeholder.slice(1)
18
+ return (
19
+ <text>
20
+ <span bg={fg} fg={bg}>
21
+ {firstChar}
22
+ </span>
23
+ <span fg={placeholderFg}>{rest}</span>
24
+ </text>
25
+ )
26
+ }
27
+
28
+ const safePos =
29
+ cursorPos === undefined ? value.length : Math.max(0, Math.min(value.length, cursorPos))
30
+ const before = value.slice(0, safePos)
31
+ const cursorOnEnd = safePos >= value.length
32
+ const cursorChar = cursorOnEnd ? ' ' : value.charAt(safePos)
33
+ const after = cursorOnEnd ? '' : value.slice(safePos + 1)
34
+
35
+ return (
36
+ <text fg={fg}>
37
+ {before}
38
+ <span bg={fg} fg={bg}>
39
+ {cursorChar}
40
+ </span>
41
+ {after}
42
+ </text>
43
+ )
44
+ }
@@ -7,6 +7,8 @@ import { InputField } from './input-field'
7
7
  import { ListItem } from './list-item'
8
8
  import { ModalShell } from './modal-shell'
9
9
 
10
+ const VISIBLE_ROWS = 8
11
+
10
12
  function getDirectoryResultIcon(result: DirectoryResult): string {
11
13
  if (result.type === 'worktree') {
12
14
  return '\u{e728}'
@@ -52,6 +54,9 @@ export function CreateSessionModal({
52
54
  const dirActive = activeField === 'directory'
53
55
  const nameActive = activeField === 'name'
54
56
 
57
+ const scrollOffset = Math.max(0, selectedIndex - VISIBLE_ROWS + 1)
58
+ const visibleResults = results.slice(scrollOffset, scrollOffset + VISIBLE_ROWS)
59
+
55
60
  return (
56
61
  <ModalShell
57
62
  title="Create session"
@@ -66,19 +71,21 @@ export function CreateSessionModal({
66
71
  </text>
67
72
  <InputField
68
73
  active={dirActive}
74
+ placeholder="Type a project name..."
69
75
  value={
70
76
  pendingProjectPath && !dirActive ? abbreviatePath(pendingProjectPath) : directoryQuery
71
77
  }
72
78
  />
73
79
  </box>
74
80
 
75
- {dirActive && results.length === 0 && directoryQuery.length > 0 ? (
76
- <text fg={theme.colors['descriptionForeground']}>No matches</text>
77
- ) : null}
78
-
79
- {dirActive
80
- ? results.map((result, index) => {
81
- const active = index === selectedIndex
81
+ <box flexDirection="column" height={VISIBLE_ROWS}>
82
+ {results.length === 0 ? (
83
+ <text fg={theme.colors['descriptionForeground']}>
84
+ {directoryQuery.length > 0 ? 'No matches' : 'Type a project name to search...'}
85
+ </text>
86
+ ) : (
87
+ visibleResults.map((result, index) => {
88
+ const active = dirActive && scrollOffset + index === selectedIndex
82
89
  return (
83
90
  <ListItem
84
91
  key={result.path}
@@ -100,7 +107,8 @@ export function CreateSessionModal({
100
107
  />
101
108
  )
102
109
  })
103
- : null}
110
+ )}
111
+ </box>
104
112
 
105
113
  <box flexDirection="column">
106
114
  <text
@@ -1,4 +1,4 @@
1
- import { useTheme } from '../../theme'
1
+ import { useBg, useTheme } from '../../theme'
2
2
  import { FOLD_STEP, type FoldInfo } from './build-rows'
3
3
  import { type FoldDispatch } from './pierre-diff'
4
4
 
@@ -9,13 +9,9 @@ interface Props {
9
9
 
10
10
  function Button({ label, onPress }: { label: string; onPress: () => void }) {
11
11
  const theme = useTheme()
12
+ const bg = useBg('sideBar.background')
12
13
  return (
13
- <box
14
- paddingLeft={1}
15
- paddingRight={1}
16
- backgroundColor={theme.colors['sideBar.background']}
17
- onMouseDown={onPress}
18
- >
14
+ <box paddingLeft={1} paddingRight={1} backgroundColor={bg} onMouseDown={onPress}>
19
15
  <text fg={theme.colors['textLink.foreground']}>{label}</text>
20
16
  </box>
21
17
  )
@@ -27,6 +23,7 @@ function Spacer() {
27
23
 
28
24
  export function FoldStrip({ dispatch, fold }: Props) {
29
25
  const theme = useTheme()
26
+ const headerBg = useBg('sideBarSectionHeader.background')
30
27
  const { bottomExpanded, foldId, hidden, topExpanded, total } = fold
31
28
  const stepUp = Math.min(FOLD_STEP, hidden)
32
29
  const stepDown = Math.min(FOLD_STEP, hidden)
@@ -73,12 +70,7 @@ export function FoldStrip({ dispatch, fold }: Props) {
73
70
  }
74
71
 
75
72
  return (
76
- <box
77
- flexDirection="row"
78
- backgroundColor={theme.colors['sideBarSectionHeader.background']}
79
- paddingLeft={1}
80
- paddingRight={1}
81
- >
73
+ <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
82
74
  <text fg={theme.colors['descriptionForeground']}>{`⋯ ${hidden} hidden `}</text>
83
75
  {controls}
84
76
  </box>
@@ -1,20 +1,18 @@
1
1
  import type { ScrollBoxRenderable } from '@opentui/core'
2
2
  import type { ThemedToken } from 'shiki'
3
3
 
4
- import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
4
+ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
5
5
 
6
- import type { FoldState } from '../../../state/types'
7
6
  import type { ThemeId } from '../../themes'
8
7
 
9
- import { parsePatchFiles } from '../../../diff-parser'
10
8
  import { useAppStore } from '../../../state/app-store'
11
9
  import { dispatchGlobal } from '../../../state/dispatch-ref'
10
+ import { type FoldState } from '../../../state/types'
12
11
  import { useTheme } from '../../theme'
13
12
  import { buildSplitRows, buildUnifiedRows, firstChangeRowOffset, gutterWidth } from './build-rows'
14
- import { filetypeFromPath } from './filetype'
15
- import { tokenizeSide } from './highlight'
16
13
  import { SplitView, type SplitViewHandle } from './split-view'
17
14
  import { StackedView, type StackedViewHandle } from './stacked-view'
15
+ import { useDiffPreparation } from './use-diff-preparation'
18
16
 
19
17
  export type DiffView = 'split' | 'stacked'
20
18
 
@@ -41,7 +39,6 @@ interface Props {
41
39
  view: DiffView
42
40
  }
43
41
 
44
- const EMPTY_HIGHLIGHTS: DiffHighlights = { add: [], del: [] }
45
42
  const EMPTY_FOLDS: Record<string, FoldState> = {}
46
43
 
47
44
  export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDiff(
@@ -49,12 +46,9 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
49
46
  ref
50
47
  ) {
51
48
  const theme = useTheme()
52
- const file = useMemo(() => {
53
- const patches = parsePatchFiles(diff)
54
- return patches[0]?.files[0]
55
- }, [diff])
56
-
57
- const filetype = useMemo(() => filetypeFromPath(path), [path])
49
+ const preparation = useDiffPreparation(cacheKey, diff, path, themeId)
50
+ const file = preparation.file ?? undefined
51
+ const highlights: DiffHighlights = preparation.highlights
58
52
 
59
53
  const terminalCols = useAppStore((s) => s.layout.terminalCols)
60
54
  const sidebarWidth = useAppStore((s) => s.sidebar.width)
@@ -78,24 +72,6 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
78
72
  [cacheKey]
79
73
  )
80
74
 
81
- const [highlights, setHighlights] = useState<DiffHighlights>(EMPTY_HIGHLIGHTS)
82
-
83
- useEffect(() => {
84
- setHighlights(EMPTY_HIGHLIGHTS)
85
- if (!file || !filetype) return
86
- let cancelled = false
87
- void Promise.all([
88
- tokenizeSide(file.additionLines, filetype, themeId),
89
- tokenizeSide(file.deletionLines, filetype, themeId),
90
- ]).then(([add, del]) => {
91
- if (cancelled) return
92
- setHighlights({ add, del })
93
- })
94
- return () => {
95
- cancelled = true
96
- }
97
- }, [file, filetype, themeId])
98
-
99
75
  const splitRef = useRef<SplitViewHandle | null>(null)
100
76
  const stackedRef = useRef<StackedViewHandle | null>(null)
101
77
 
@@ -141,7 +117,9 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
141
117
  if (!file) {
142
118
  return (
143
119
  <box flexGrow={1} padding={1}>
144
- <text fg={theme.colors['descriptionForeground']}>(could not parse diff)</text>
120
+ <text fg={theme.colors['descriptionForeground']}>
121
+ {preparation.preparing ? 'Preparing diff…' : '(could not parse diff)'}
122
+ </text>
145
123
  </box>
146
124
  )
147
125
  }