@brimveyn/aimux 1.5.4 → 1.6.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.
Files changed (67) hide show
  1. package/README.md +14 -2
  2. package/package.json +5 -3
  3. package/src/app-runtime/side-effects.ts +41 -6
  4. package/src/app.tsx +17 -5
  5. package/src/config.ts +29 -4
  6. package/src/diff-parser/clean-last-newline.ts +5 -0
  7. package/src/diff-parser/constants.ts +13 -0
  8. package/src/diff-parser/index.ts +12 -0
  9. package/src/diff-parser/parse-line-type.ts +29 -0
  10. package/src/diff-parser/parse-patch-files.ts +369 -0
  11. package/src/diff-parser/types.ts +75 -0
  12. package/src/git/git-diff.ts +24 -12
  13. package/src/git/git-poller.ts +11 -3
  14. package/src/git/git-status.ts +98 -17
  15. package/src/input/modes/bridge.ts +8 -0
  16. package/src/input/modes/transitions.ts +2 -1
  17. package/src/input/modes/types.ts +4 -1
  18. package/src/pty/terminal-snapshot.ts +5 -5
  19. package/src/state/git-tree.ts +220 -0
  20. package/src/state/reducers/git-mode-state.ts +276 -36
  21. package/src/state/reducers/git-panel-state.ts +35 -4
  22. package/src/state/reducers/modal-state.ts +43 -10
  23. package/src/state/store.ts +5 -1
  24. package/src/state/types.ts +46 -6
  25. package/src/state/workspace-save.ts +2 -0
  26. package/src/ui/components/create-session-modal.tsx +25 -8
  27. package/src/ui/components/diff-renderer/build-rows.ts +349 -0
  28. package/src/ui/components/diff-renderer/filetype.ts +29 -0
  29. package/src/ui/components/diff-renderer/fold-strip.tsx +86 -0
  30. package/src/ui/components/diff-renderer/highlight.ts +48 -0
  31. package/src/ui/components/diff-renderer/index.ts +1 -0
  32. package/src/ui/components/diff-renderer/pierre-diff.tsx +171 -0
  33. package/src/ui/components/diff-renderer/split-view.tsx +211 -0
  34. package/src/ui/components/diff-renderer/stacked-view.tsx +168 -0
  35. package/src/ui/components/git-commit-modal.tsx +16 -3
  36. package/src/ui/components/git-pane-widget.tsx +6 -1
  37. package/src/ui/components/git-panel.tsx +215 -86
  38. package/src/ui/components/git-view.tsx +103 -90
  39. package/src/ui/components/help-modal.tsx +45 -12
  40. package/src/ui/components/input-field.tsx +4 -3
  41. package/src/ui/components/list-item.tsx +11 -2
  42. package/src/ui/components/modal-filter-bar.tsx +3 -2
  43. package/src/ui/components/modal-keybinds-overlay.tsx +4 -3
  44. package/src/ui/components/modal-shell.tsx +5 -4
  45. package/src/ui/components/new-tab-modal.tsx +17 -4
  46. package/src/ui/components/pending-chord-overlay.tsx +5 -4
  47. package/src/ui/components/session-bar.tsx +13 -6
  48. package/src/ui/components/session-picker-modal.tsx +28 -10
  49. package/src/ui/components/sidebar.tsx +26 -11
  50. package/src/ui/components/snippet-editor-modal.tsx +18 -3
  51. package/src/ui/components/snippet-picker-modal.tsx +13 -4
  52. package/src/ui/components/split-layout.tsx +3 -2
  53. package/src/ui/components/status-bar.tsx +15 -10
  54. package/src/ui/components/surface.tsx +6 -6
  55. package/src/ui/components/tab-item.tsx +28 -17
  56. package/src/ui/components/terminal-pane.tsx +28 -16
  57. package/src/ui/components/theme-picker-modal.tsx +113 -17
  58. package/src/ui/components/update-available-modal.tsx +13 -2
  59. package/src/ui/filter-themes.ts +15 -0
  60. package/src/ui/keymap-context.ts +10 -2
  61. package/src/ui/root.tsx +29 -7
  62. package/src/ui/shiki.ts +49 -0
  63. package/src/ui/status-bar-model.ts +32 -4
  64. package/src/ui/theme-store.ts +36 -0
  65. package/src/ui/theme.ts +4 -17
  66. package/src/ui/themes.ts +23 -277
  67. package/src/ui/syntax.ts +0 -102
@@ -16,6 +16,7 @@ interface NumstatRow {
16
16
  export type GitCollectResult =
17
17
  | { kind: 'ok'; payload: GitRefreshPayload }
18
18
  | { kind: 'error'; error: GitPanelError }
19
+ | { kind: 'out-of-range'; maxOffset: number }
19
20
 
20
21
  const STATUS_CODES = new Set(['M', 'A', 'D', 'R', 'C', 'U', '?'])
21
22
 
@@ -189,26 +190,106 @@ async function annotateUntrackedCounts(cwd: string, files: GitFileEntry[]): Prom
189
190
  }
190
191
  }
191
192
 
192
- export async function collectGitStatus(cwd: string): Promise<GitCollectResult> {
193
- try {
194
- const [statusResult, unstagedDiff, stagedDiff] = await Promise.all([
195
- $`git -C ${cwd} status --porcelain=v2 -b -z --untracked-files=all`.quiet().nothrow(),
196
- $`git -C ${cwd} -c core.quotePath=false diff --numstat`.quiet().nothrow(),
197
- $`git -C ${cwd} -c core.quotePath=false diff --cached --numstat`.quiet().nothrow(),
198
- ])
199
-
200
- if (statusResult.exitCode !== 0) {
201
- return { error: 'not-a-repo', kind: 'error' }
193
+ interface CollectOptions {
194
+ headOffset?: number
195
+ }
196
+
197
+ function parseNameStatus(
198
+ output: string
199
+ ): { path: string; status: GitFileStatus; renamedFrom?: string }[] {
200
+ const rows: { path: string; status: GitFileStatus; renamedFrom?: string }[] = []
201
+ for (const raw of output.split('\n')) {
202
+ if (!raw) continue
203
+ const parts = raw.split('\t')
204
+ const code = parts[0] ?? ''
205
+ const letter = code[0] ?? ''
206
+ const status = toStatus(letter)
207
+ if (!status) continue
208
+ if (letter === 'R' || letter === 'C') {
209
+ const from = parts[1]
210
+ const to = parts[2]
211
+ if (!from || !to) continue
212
+ rows.push({ path: to, renamedFrom: from, status })
213
+ } else {
214
+ const path = parts.slice(1).join('\t')
215
+ if (!path) continue
216
+ rows.push({ path, status })
202
217
  }
218
+ }
219
+ return rows
220
+ }
221
+
222
+ async function collectAgainstHead(cwd: string): Promise<GitCollectResult> {
223
+ const [statusResult, unstagedDiff, stagedDiff] = await Promise.all([
224
+ $`git -C ${cwd} status --porcelain=v2 -b -z --untracked-files=all`.quiet().nothrow(),
225
+ $`git -C ${cwd} -c core.quotePath=false diff --numstat`.quiet().nothrow(),
226
+ $`git -C ${cwd} -c core.quotePath=false diff --cached --numstat`.quiet().nothrow(),
227
+ ])
203
228
 
204
- const statusText = statusResult.text()
205
- const { ahead, behind, branch } = parseBranchLines(statusText)
206
- const unstagedNumstat = parseNumstat(unstagedDiff.text())
207
- const stagedNumstat = parseNumstat(stagedDiff.text())
208
- const files = parsePorcelainEntries(statusText, stagedNumstat, unstagedNumstat)
209
- await annotateUntrackedCounts(cwd, files)
229
+ if (statusResult.exitCode !== 0) {
230
+ return { error: 'not-a-repo', kind: 'error' }
231
+ }
232
+
233
+ const statusText = statusResult.text()
234
+ const { ahead, behind, branch } = parseBranchLines(statusText)
235
+ const unstagedNumstat = parseNumstat(unstagedDiff.text())
236
+ const stagedNumstat = parseNumstat(stagedDiff.text())
237
+ const files = parsePorcelainEntries(statusText, stagedNumstat, unstagedNumstat)
238
+ await annotateUntrackedCounts(cwd, files)
210
239
 
211
- return { kind: 'ok', payload: { ahead, behind, branch, files } }
240
+ return { kind: 'ok', payload: { ahead, behind, branch, files } }
241
+ }
242
+
243
+ async function collectAgainstHistorical(
244
+ cwd: string,
245
+ headOffset: number
246
+ ): Promise<GitCollectResult> {
247
+ const ref = `HEAD~${headOffset}`
248
+ const revParse = await $`git -C ${cwd} rev-parse ${ref}`.quiet().nothrow()
249
+ if (revParse.exitCode !== 0) {
250
+ const countResult = await $`git -C ${cwd} rev-list --count HEAD`.quiet().nothrow()
251
+ const count = countResult.exitCode === 0 ? Number.parseInt(countResult.text().trim(), 10) : NaN
252
+ const maxOffset = Number.isFinite(count) && count > 0 ? count - 1 : 0
253
+ return { kind: 'out-of-range', maxOffset }
254
+ }
255
+
256
+ const [statusResult, nameStatus, numstat, untrackedStatus] = await Promise.all([
257
+ $`git -C ${cwd} status --porcelain=v2 -b -z --untracked-files=all`.quiet().nothrow(),
258
+ $`git -C ${cwd} -c core.quotePath=false diff ${ref} --name-status`.quiet().nothrow(),
259
+ $`git -C ${cwd} -c core.quotePath=false diff ${ref} --numstat`.quiet().nothrow(),
260
+ $`git -C ${cwd} status --porcelain=v2 -z --untracked-files=all`.quiet().nothrow(),
261
+ ])
262
+
263
+ if (statusResult.exitCode !== 0) {
264
+ return { error: 'not-a-repo', kind: 'error' }
265
+ }
266
+
267
+ const { ahead, behind, branch } = parseBranchLines(statusResult.text())
268
+ const stats = parseNumstat(numstat.text())
269
+ const rows = parseNameStatus(nameStatus.text())
270
+ const files: GitFileEntry[] = rows.map((row) =>
271
+ buildEntry('historical', row.status, row.path, stats, row.renamedFrom)
272
+ )
273
+
274
+ // Untracked files live outside any commit — still surface them so `?` files
275
+ // don't vanish when the user walks history.
276
+ const porcelain = parsePorcelainEntries(untrackedStatus.text(), new Map(), new Map())
277
+ const untracked = porcelain.filter((f) => f.section === 'untracked')
278
+ await annotateUntrackedCounts(cwd, untracked)
279
+ files.push(...untracked)
280
+
281
+ return { kind: 'ok', payload: { ahead, behind, branch, files } }
282
+ }
283
+
284
+ export async function collectGitStatus(
285
+ cwd: string,
286
+ options: CollectOptions = {}
287
+ ): Promise<GitCollectResult> {
288
+ const headOffset = options.headOffset ?? 0
289
+ try {
290
+ return headOffset > 0
291
+ ? await collectAgainstHistorical(cwd, headOffset)
292
+ : await collectAgainstHead(cwd)
212
293
  } catch {
213
294
  return { error: 'unknown', kind: 'error' }
214
295
  }
@@ -19,6 +19,7 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
19
19
  'session-picker': 'modal.session-picker.filtering',
20
20
  'snippet-editor': 'modal.snippet-editor',
21
21
  'snippet-picker': 'modal.snippet-picker.filtering',
22
+ 'theme-picker': 'modal.theme-picker.filtering',
22
23
  }
23
24
 
24
25
  const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
@@ -32,6 +33,13 @@ const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
32
33
  }
33
34
 
34
35
  export function deriveModeId(state: AppState): ModeId {
36
+ // Help renders as an overlay on top of git/navigation without flipping
37
+ // focusMode, so it needs modal-first dispatch. Filter sub-mode is tracked
38
+ // by editBuffer presence, not focusMode.
39
+ if (state.modal.type === 'help') {
40
+ return state.modal.editBuffer !== null ? 'modal.help.filtering' : 'modal.help'
41
+ }
42
+
35
43
  const directMode = DIRECT_FOCUS_MODE_IDS[state.focusMode]
36
44
  if (directMode) {
37
45
  return directMode
@@ -21,7 +21,8 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
21
21
  'modal.snippet-picker': ['navigation', 'modal.snippet-picker.filtering', 'modal.snippet-editor'],
22
22
  'modal.snippet-picker.filtering': ['modal.snippet-picker'],
23
23
  'modal.split-picker': ['navigation', 'terminal-input'],
24
- 'modal.theme-picker': ['navigation'],
24
+ 'modal.theme-picker': ['navigation', 'modal.theme-picker.filtering'],
25
+ 'modal.theme-picker.filtering': ['modal.theme-picker'],
25
26
  'modal.update-available': ['navigation'],
26
27
  'navigation': [
27
28
  'terminal-input',
@@ -1,6 +1,6 @@
1
1
  import type { KeyEvent } from '@opentui/core'
2
2
 
3
- import type { AppAction, AppState, TabSession } from '../../state/types'
3
+ import type { AppAction, AppState, GitFileListMode, TabSession } from '../../state/types'
4
4
 
5
5
  export type ModeId =
6
6
  | 'navigation'
@@ -17,6 +17,7 @@ export type ModeId =
17
17
  | 'modal.snippet-picker.filtering'
18
18
  | 'modal.snippet-editor'
19
19
  | 'modal.theme-picker'
20
+ | 'modal.theme-picker.filtering'
20
21
  | 'modal.help'
21
22
  | 'modal.help.filtering'
22
23
  | 'modal.split-picker'
@@ -46,6 +47,8 @@ export type SideEffect =
46
47
  | { type: 'split-pane'; direction: import('../../state/layout-tree').SplitDirection }
47
48
  | { type: 'confirm-split' }
48
49
  | { type: 'scroll-git-diff'; delta: number }
50
+ | { type: 'persist-git-diff-mode-ratio'; ratio: number }
51
+ | { type: 'persist-git-file-list-mode'; mode: GitFileListMode }
49
52
  | { type: 'git-stage'; path: string }
50
53
  | { type: 'git-unstage'; path: string }
51
54
  | { type: 'git-restore'; path: string }
@@ -2,7 +2,7 @@ import type { Terminal } from '@xterm/headless'
2
2
 
3
3
  import type { TerminalLine, TerminalSnapshot, TerminalSpan } from '../state/types'
4
4
 
5
- import { theme } from '../ui/theme'
5
+ import { getCurrentTheme } from '../ui/theme'
6
6
 
7
7
  const ANSI_PALETTE = [
8
8
  '#000000',
@@ -113,15 +113,15 @@ function buildLine(
113
113
  let bg = getColorHex(current.getBgColor(), bgMode)
114
114
 
115
115
  if (current.isInverse()) {
116
- const resolvedFg = fg ?? theme.text
117
- const resolvedBg = bg ?? theme.background
116
+ const resolvedFg = fg ?? getCurrentTheme().colors['editor.foreground']
117
+ const resolvedBg = bg ?? getCurrentTheme().colors['editor.background']
118
118
  ;[fg, bg] = [resolvedBg, resolvedFg]
119
119
  }
120
120
 
121
121
  const isCursorCell = cursorVisible && cursorColumn === column
122
122
  if (isCursorCell) {
123
- const resolvedFg = fg ?? theme.text
124
- const resolvedBg = bg ?? theme.background
123
+ const resolvedFg = fg ?? getCurrentTheme().colors['editor.foreground']
124
+ const resolvedBg = bg ?? getCurrentTheme().colors['editor.background']
125
125
  ;[fg, bg] = [resolvedBg, resolvedFg]
126
126
  }
127
127
 
@@ -0,0 +1,220 @@
1
+ import type { GitFileEntry, GitFileListMode, GitFileSection } from './types'
2
+
3
+ const SECTION_ORDER: GitFileSection[] = ['historical', 'staged', 'unstaged', 'untracked']
4
+
5
+ interface GitTreeNode {
6
+ files: GitFileEntry[]
7
+ folders: Map<string, GitTreeNode>
8
+ path: string
9
+ section: GitFileSection
10
+ }
11
+
12
+ export interface GitTreeFolderRow {
13
+ kind: 'folder'
14
+ key: string
15
+ section: GitFileSection
16
+ depth: number
17
+ name: string
18
+ folderPath: string
19
+ parentKey?: string
20
+ isCollapsed: boolean
21
+ }
22
+
23
+ export interface GitTreeFileRow {
24
+ kind: 'file'
25
+ key: string
26
+ section: GitFileSection
27
+ depth: number
28
+ file: GitFileEntry
29
+ parentKey?: string
30
+ }
31
+
32
+ export type GitTreeRow = GitTreeFolderRow | GitTreeFileRow
33
+
34
+ export interface GitTreeSectionRows {
35
+ section: GitFileSection
36
+ files: GitFileEntry[]
37
+ rows: GitTreeRow[]
38
+ }
39
+
40
+ export interface GitTreeRows {
41
+ sections: GitTreeSectionRows[]
42
+ visibleRows: GitTreeRow[]
43
+ }
44
+
45
+ export function gitFileKey(file: Pick<GitFileEntry, 'path' | 'section'>): string {
46
+ return `${file.section}:${file.path}`
47
+ }
48
+
49
+ export function gitFolderKey(section: GitFileSection, folderPath: string): string {
50
+ return `${section}:dir:${folderPath}`
51
+ }
52
+
53
+ export function buildGitTreeRows(
54
+ files: GitFileEntry[],
55
+ collapsedFolders: Record<string, true>,
56
+ fileListMode: GitFileListMode = 'tree'
57
+ ): GitTreeRows {
58
+ const sections = SECTION_ORDER.map((section) => {
59
+ const sectionFiles = files.filter((file) => file.section === section)
60
+ return {
61
+ files: sectionFiles,
62
+ rows:
63
+ fileListMode === 'flat'
64
+ ? sectionFiles.map((file) => ({
65
+ depth: 0,
66
+ file,
67
+ key: gitFileKey(file),
68
+ kind: 'file' as const,
69
+ section,
70
+ }))
71
+ : flattenSectionRows(sectionFiles, collapsedFolders),
72
+ section,
73
+ }
74
+ })
75
+ return { sections, visibleRows: sections.flatMap((section) => section.rows) }
76
+ }
77
+
78
+ export function getSelectedGitRow(
79
+ files: GitFileEntry[],
80
+ options: {
81
+ collapsedFolders: Record<string, true>
82
+ fileListMode: GitFileListMode
83
+ selectedEntryKey: string | null
84
+ }
85
+ ): GitTreeRow | null {
86
+ if (!options.selectedEntryKey) return null
87
+ const { visibleRows } = buildGitTreeRows(files, options.collapsedFolders, options.fileListMode)
88
+ return visibleRows.find((row) => row.key === options.selectedEntryKey) ?? null
89
+ }
90
+
91
+ export function getSelectedGitFile(
92
+ files: GitFileEntry[],
93
+ options: {
94
+ collapsedFolders: Record<string, true>
95
+ fileListMode: GitFileListMode
96
+ selectedEntryKey: string | null
97
+ }
98
+ ): GitFileEntry | null {
99
+ const row = getSelectedGitRow(files, options)
100
+ return row?.kind === 'file' ? row.file : null
101
+ }
102
+
103
+ export function reconcileSelectedGitEntryKey(
104
+ files: GitFileEntry[],
105
+ collapsedFolders: Record<string, true>,
106
+ fileListMode: GitFileListMode,
107
+ selectedEntryKey: string | null | undefined,
108
+ preferredKeys: string[] = []
109
+ ): string | null {
110
+ const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode)
111
+ if (visibleRows.length === 0) return null
112
+ const candidates = [...preferredKeys, selectedEntryKey ?? '']
113
+ for (const key of candidates) {
114
+ if (!key) continue
115
+ if (visibleRows.some((row) => row.key === key)) return key
116
+ }
117
+ return visibleRows[0]?.key ?? null
118
+ }
119
+
120
+ export function moveGitSelection(
121
+ files: GitFileEntry[],
122
+ collapsedFolders: Record<string, true>,
123
+ fileListMode: GitFileListMode,
124
+ selectedEntryKey: string | null,
125
+ delta: -1 | 1
126
+ ): string | null {
127
+ const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode)
128
+ const total = visibleRows.length
129
+ if (total === 0) return null
130
+ const current = visibleRows.findIndex((row) => row.key === selectedEntryKey)
131
+ const base = current >= 0 ? current : 0
132
+ return visibleRows[(base + delta + total) % total]?.key ?? null
133
+ }
134
+
135
+ export function moveGitFileSelection(
136
+ files: GitFileEntry[],
137
+ collapsedFolders: Record<string, true>,
138
+ fileListMode: GitFileListMode,
139
+ selectedEntryKey: string | null,
140
+ delta: -1 | 1
141
+ ): string | null {
142
+ const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode)
143
+ const fileRows = visibleRows.filter((row) => row.kind === 'file')
144
+ const total = fileRows.length
145
+ if (total === 0) return null
146
+ const current = fileRows.findIndex((row) => row.key === selectedEntryKey)
147
+ let base = current
148
+ if (base < 0) {
149
+ base = delta > 0 ? -1 : 0
150
+ }
151
+ return fileRows[(base + delta + total) % total]?.key ?? null
152
+ }
153
+
154
+ function flattenSectionRows(
155
+ files: GitFileEntry[],
156
+ collapsedFolders: Record<string, true>
157
+ ): GitTreeRow[] {
158
+ if (files.length === 0) return []
159
+ const section = files[0]?.section
160
+ if (!section) return []
161
+ const root = buildSectionTree(files, section)
162
+ return flattenTreeNode(root, collapsedFolders, 0)
163
+ }
164
+
165
+ function buildSectionTree(files: GitFileEntry[], section: GitFileSection): GitTreeNode {
166
+ const root: GitTreeNode = { files: [], folders: new Map(), path: '', section }
167
+ for (const file of files) {
168
+ const parts = file.path.split('/')
169
+ let cursor = root
170
+ for (let i = 0; i < parts.length - 1; i++) {
171
+ const name = parts[i]
172
+ if (!name) continue
173
+ const nextPath = cursor.path ? `${cursor.path}/${name}` : name
174
+ let child = cursor.folders.get(name)
175
+ if (!child) {
176
+ child = { files: [], folders: new Map(), path: nextPath, section }
177
+ cursor.folders.set(name, child)
178
+ }
179
+ cursor = child
180
+ }
181
+ cursor.files.push(file)
182
+ }
183
+ return root
184
+ }
185
+
186
+ function flattenTreeNode(
187
+ node: GitTreeNode,
188
+ collapsedFolders: Record<string, true>,
189
+ depth: number,
190
+ parentKey?: string
191
+ ): GitTreeRow[] {
192
+ const rows: GitTreeRow[] = []
193
+ for (const child of node.folders.values()) {
194
+ const name = child.path.split('/').pop() ?? child.path
195
+ const key = gitFolderKey(node.section, child.path)
196
+ const isCollapsed = key in collapsedFolders
197
+ rows.push({
198
+ depth,
199
+ folderPath: child.path,
200
+ isCollapsed,
201
+ key,
202
+ kind: 'folder',
203
+ name,
204
+ parentKey,
205
+ section: node.section,
206
+ })
207
+ if (!isCollapsed) rows.push(...flattenTreeNode(child, collapsedFolders, depth + 1, key))
208
+ }
209
+ for (const file of node.files) {
210
+ rows.push({
211
+ depth,
212
+ file,
213
+ key: gitFileKey(file),
214
+ kind: 'file',
215
+ parentKey,
216
+ section: file.section,
217
+ })
218
+ }
219
+ return rows
220
+ }