@brimveyn/aimux 1.21.0 → 1.22.2

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 (220) hide show
  1. package/README.md +27 -25
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +22 -27
  4. package/src/app-runtime/backend-attach-runtime.ts +43 -36
  5. package/src/app-runtime/backend-runtime-events.ts +107 -88
  6. package/src/app-runtime/editor-actions.ts +201 -0
  7. package/src/app-runtime/git-actions.ts +197 -0
  8. package/src/app-runtime/measure-ref.ts +19 -0
  9. package/src/app-runtime/navigation-actions.ts +213 -0
  10. package/src/app-runtime/project-actions.ts +188 -0
  11. package/src/app-runtime/prompt-injection.ts +123 -0
  12. package/src/app-runtime/selection.ts +29 -0
  13. package/src/app-runtime/settings-actions.ts +156 -0
  14. package/src/app-runtime/setup-actions.ts +237 -0
  15. package/src/app-runtime/side-effect-context.ts +33 -0
  16. package/src/app-runtime/side-effects.ts +302 -1541
  17. package/src/app-runtime/snippet-actions.ts +3 -2
  18. package/src/app-runtime/tab-actions.ts +218 -0
  19. package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
  20. package/src/app-runtime/use-auto-commit-driver.ts +14 -13
  21. package/src/app-runtime/use-backend-runtime.ts +12 -11
  22. package/src/app-runtime/use-directory-search.ts +5 -4
  23. package/src/app-runtime/use-mouse-handlers.ts +2 -1
  24. package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
  25. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  26. package/src/app-runtime/use-setup-runner.ts +96 -0
  27. package/src/app-runtime/use-terminal-resize.ts +18 -14
  28. package/src/app-runtime/workspace-actions.ts +366 -0
  29. package/src/app-runtime/workspace-activity.ts +138 -0
  30. package/src/app-runtime/workspace-naming.ts +90 -0
  31. package/src/app.tsx +93 -48
  32. package/src/assets/sounds/bell.wav +0 -0
  33. package/src/assets/sounds/ding.wav +0 -0
  34. package/src/assets/sounds/plane.wav +0 -0
  35. package/src/cli/client/daemon-client.ts +2 -2
  36. package/src/cli/client/project-resolver.ts +112 -0
  37. package/src/cli/commands/project/close.ts +32 -0
  38. package/src/cli/commands/project/create.ts +93 -0
  39. package/src/cli/commands/project/list.ts +25 -0
  40. package/src/cli/commands/project/show.ts +32 -0
  41. package/src/cli/commands/{workspace → project}/switch.ts +19 -19
  42. package/src/cli/commands/tab/await.ts +2 -2
  43. package/src/cli/commands/tab/close.ts +3 -3
  44. package/src/cli/commands/tab/create.ts +74 -73
  45. package/src/cli/commands/tab/focus.ts +3 -3
  46. package/src/cli/commands/tab/list.ts +6 -6
  47. package/src/cli/commands/tab/run.ts +2 -2
  48. package/src/cli/commands/tab/send.ts +2 -2
  49. package/src/cli/commands/tab/snapshot.ts +2 -2
  50. package/src/cli/commands/tab/tail.ts +2 -2
  51. package/src/cli/commands/tab/wait.ts +2 -2
  52. package/src/cli/commands/worker/await.ts +3 -3
  53. package/src/cli/commands/worker/doctor.ts +30 -30
  54. package/src/cli/commands/worker/list.ts +15 -15
  55. package/src/cli/commands/worker/prompt.ts +3 -3
  56. package/src/cli/commands/worker/run.ts +23 -23
  57. package/src/cli/commands/worker/shared.ts +53 -59
  58. package/src/cli/commands/worker/stop.ts +29 -29
  59. package/src/cli/commands/worker/submit.ts +3 -3
  60. package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
  61. package/src/cli/commands/workspace/create.ts +30 -68
  62. package/src/cli/commands/workspace/list.ts +52 -9
  63. package/src/cli/commands/workspace/remove.ts +81 -0
  64. package/src/cli/completion/plan.ts +2 -2
  65. package/src/cli/completion/sources.ts +18 -18
  66. package/src/cli/context.ts +13 -13
  67. package/src/cli/flags.ts +30 -10
  68. package/src/cli/index.ts +26 -16
  69. package/src/cli/output.ts +1 -1
  70. package/src/cli/registry.ts +12 -12
  71. package/src/config/loader.ts +16 -5
  72. package/src/config.ts +70 -112
  73. package/src/daemon/catalog-writer.ts +67 -67
  74. package/src/daemon/daemon.ts +205 -201
  75. package/src/daemon/session-manager.ts +39 -39
  76. package/src/daemon/session-registry.ts +14 -14
  77. package/src/git/divergence.ts +33 -2
  78. package/src/git/git-poller.ts +1 -1
  79. package/src/git/git-status.ts +2 -2
  80. package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
  81. package/src/git/pr-status.ts +1 -1
  82. package/src/git/repo-discovery.ts +1 -1
  83. package/src/git/use-repo-discovery.ts +1 -1
  84. package/src/git/workspace-branch-poller.ts +54 -0
  85. package/src/git/workspace-divergence-poller.ts +65 -0
  86. package/src/git/worktree.ts +29 -0
  87. package/src/index.tsx +4 -4
  88. package/src/input/keymap/help-entries.ts +7 -5
  89. package/src/input/modes/bridge.ts +13 -12
  90. package/src/input/modes/handlers/shared.ts +1 -1
  91. package/src/input/modes/transitions.ts +34 -21
  92. package/src/input/modes/types.ts +48 -31
  93. package/src/ipc/manager-protocol.ts +44 -44
  94. package/src/ipc/protocol.ts +192 -166
  95. package/src/platform/play-sound.ts +184 -0
  96. package/src/platform/project-search.ts +8 -8
  97. package/src/platform/worktree-paths.ts +8 -6
  98. package/src/pty/assistant-question-extractor.ts +1 -1
  99. package/src/pty/assistant-status-detection-loop.ts +79 -55
  100. package/src/pty/assistant-status-detector.ts +40 -15
  101. package/src/pty/command-registry.ts +81 -1
  102. package/src/restart-terminal-manager.ts +1 -1
  103. package/src/services/ai-usage/provider.ts +9 -2
  104. package/src/services/ai-usage/spawn.ts +19 -6
  105. package/src/session-backend/bootstrap.ts +4 -4
  106. package/src/session-backend/local-session-backend.ts +78 -75
  107. package/src/session-backend/remote-session-backend.ts +64 -52
  108. package/src/session-backend/types.ts +43 -32
  109. package/src/settings/live.ts +90 -0
  110. package/src/settings/search.ts +43 -0
  111. package/src/settings/sections/about.ts +49 -0
  112. package/src/settings/sections/appearance.ts +54 -0
  113. package/src/settings/sections/automation.ts +105 -0
  114. package/src/settings/sections/commands.ts +72 -0
  115. package/src/settings/sections/editor.ts +56 -0
  116. package/src/settings/sections/experimental.ts +57 -0
  117. package/src/settings/sections/git.ts +112 -0
  118. package/src/settings/sections/index.ts +95 -0
  119. package/src/settings/sections/integrations.ts +23 -0
  120. package/src/settings/sections/layout.ts +72 -0
  121. package/src/settings/sections/notifications.ts +99 -0
  122. package/src/settings/sections/setup.ts +54 -0
  123. package/src/settings/sections/status-bar.ts +63 -0
  124. package/src/settings/settings-store.ts +207 -0
  125. package/src/settings/types.ts +108 -0
  126. package/src/snippets/run-shell-var.ts +48 -22
  127. package/src/state/actions.ts +342 -0
  128. package/src/state/app-store.ts +2 -1
  129. package/src/state/bars.ts +1 -1
  130. package/src/state/dispatch-ref.ts +1 -1
  131. package/src/state/layout-resize.ts +2 -2
  132. package/src/state/project-catalog.ts +275 -0
  133. package/src/state/project-data.ts +143 -0
  134. package/src/state/{session-persistence.ts → project-persistence.ts} +74 -68
  135. package/src/state/project-save.ts +46 -0
  136. package/src/state/project-workspaces.ts +392 -0
  137. package/src/state/reducers/auto-commit-state.ts +11 -10
  138. package/src/state/reducers/git-commit-modal-state.ts +122 -0
  139. package/src/state/reducers/git-mode-state.ts +1 -1
  140. package/src/state/reducers/git-panel-state.ts +10 -6
  141. package/src/state/reducers/modal-state.ts +290 -681
  142. package/src/state/reducers/multi-repo-state.ts +3 -6
  143. package/src/state/reducers/project-state.ts +312 -0
  144. package/src/state/reducers/settings-state.ts +80 -0
  145. package/src/state/reducers/tab-state.ts +107 -60
  146. package/src/state/reducers/ui-state.ts +4 -3
  147. package/src/state/selectors.ts +49 -37
  148. package/src/state/store.ts +57 -24
  149. package/src/state/types.ts +244 -405
  150. package/src/state/validation.ts +14 -12
  151. package/src/terminal-manager/manager-client.ts +32 -32
  152. package/src/terminal-manager/terminal-manager.ts +24 -24
  153. package/src/ui/components/git/git-panel.tsx +2 -2
  154. package/src/ui/components/git/git-view.tsx +12 -12
  155. package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
  156. package/src/ui/components/git/pane/git-pane-widget.tsx +7 -7
  157. package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
  158. package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
  159. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
  160. package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
  161. package/src/ui/components/layout/status-bar.tsx +6 -2
  162. package/src/ui/components/layout/terminal-pane.tsx +34 -29
  163. package/src/ui/components/layout/top-tab-bar.tsx +46 -36
  164. package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
  165. package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
  166. package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
  167. package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
  168. package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
  169. package/src/ui/components/modals/shared/form.tsx +34 -22
  170. package/src/ui/components/modals/shared/picker.tsx +10 -10
  171. package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
  172. package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
  173. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
  174. package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
  175. package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
  176. package/src/ui/components/primitives/input-field.tsx +7 -3
  177. package/src/ui/components/primitives/surface.tsx +3 -0
  178. package/src/ui/components/settings/row-value.tsx +74 -0
  179. package/src/ui/components/settings/settings-row.tsx +78 -0
  180. package/src/ui/components/settings/settings-view.tsx +169 -0
  181. package/src/ui/components/setup/setup-widget.tsx +152 -0
  182. package/src/ui/flash/build-labels.ts +27 -27
  183. package/src/ui/hooks/use-activity-sprite.ts +73 -0
  184. package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
  185. package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
  186. package/src/ui/root.tsx +139 -121
  187. package/src/ui/status-bar-model.ts +48 -34
  188. package/src/ui/terminal-graphics/kitty.ts +28 -2
  189. package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
  190. package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
  191. package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
  192. package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
  193. package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
  194. package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
  195. package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
  196. package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
  197. package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
  198. package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
  199. package/src/ui/terminal-graphics/sprites.ts +246 -0
  200. package/src/ui/truncate.ts +7 -0
  201. package/src/ui/widgets/registry.tsx +6 -3
  202. package/src/ui/widgets/widget-context-menu.ts +26 -10
  203. package/src/update.ts +2 -2
  204. package/src/app-runtime/session-actions.ts +0 -187
  205. package/src/cli/client/workspace-resolver.ts +0 -110
  206. package/src/cli/commands/workspace/close.ts +0 -32
  207. package/src/cli/commands/workspace/show.ts +0 -32
  208. package/src/cli/commands/worktree/create.ts +0 -55
  209. package/src/cli/commands/worktree/list.ts +0 -68
  210. package/src/cli/commands/worktree/remove.ts +0 -81
  211. package/src/git/worktree-branch-poller.ts +0 -54
  212. package/src/git/worktree-divergence-poller.ts +0 -59
  213. package/src/state/reducers/session-state.ts +0 -262
  214. package/src/state/session-catalog.ts +0 -143
  215. package/src/state/session-worktrees.ts +0 -260
  216. package/src/state/workspace-save.ts +0 -43
  217. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
  218. package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
  219. package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
  220. /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
@@ -6,15 +6,19 @@ interface InputFieldProps {
6
6
  value: string
7
7
  cursorPos?: number
8
8
  placeholder?: string
9
+ /** Rows the box occupies even when empty, so a long answer looks expected. */
10
+ minLines?: number
9
11
  }
10
12
 
11
- export function InputField({ active, cursorPos, placeholder, value }: InputFieldProps) {
13
+ export function InputField({ active, cursorPos, minLines, placeholder, value }: InputFieldProps) {
12
14
  const t = useTheme()
13
15
  const fg = active ? t.text : t.textMuted
16
+ // The surface pads one row top and bottom.
17
+ const minHeight = minLines != null ? minLines + 2 : undefined
14
18
  if (!active) {
15
19
  const showPlaceholder = !value && !!(placeholder != null && placeholder !== '')
16
20
  return (
17
- <Surface tone="input" padding={1}>
21
+ <Surface tone="input" padding={1} minHeight={minHeight}>
18
22
  <text fg={showPlaceholder ? t.textMuted : fg}>{showPlaceholder ? placeholder : value}</text>
19
23
  </Surface>
20
24
  )
@@ -29,7 +33,7 @@ export function InputField({ active, cursorPos, placeholder, value }: InputField
29
33
  const trailing = cursorOnLineEnd ? value.slice(safePos) : value.slice(safePos + 1)
30
34
 
31
35
  return (
32
- <Surface tone="inputActive" padding={1}>
36
+ <Surface tone="inputActive" padding={1} minHeight={minHeight}>
33
37
  <text fg={fg}>
34
38
  {before}
35
39
  <span bg={t.text} fg={t.background}>
@@ -26,6 +26,7 @@ interface SurfaceProps {
26
26
  children: ReactNode
27
27
  flexDirection?: 'row' | 'column'
28
28
  gap?: number
29
+ minHeight?: number
29
30
  padding?: number
30
31
  paddingLeft?: number
31
32
  paddingRight?: number
@@ -39,6 +40,7 @@ export function Surface({
39
40
  children,
40
41
  flexDirection = 'column',
41
42
  gap = 0,
43
+ minHeight,
42
44
  padding,
43
45
  paddingBottom,
44
46
  paddingLeft,
@@ -53,6 +55,7 @@ export function Surface({
53
55
  backgroundColor={t[toneToToken(tone)]}
54
56
  flexDirection={flexDirection}
55
57
  gap={gap}
58
+ minHeight={minHeight}
56
59
  padding={padding}
57
60
  paddingBottom={paddingBottom}
58
61
  paddingLeft={paddingLeft}
@@ -0,0 +1,74 @@
1
+ import { memo } from 'react'
2
+
3
+ import type { SettingRow, SettingValue } from '../../../settings/types'
4
+
5
+ import { readRow, useSettingsStore } from '../../../settings/settings-store'
6
+ import { useAppStore } from '../../../state/app-store'
7
+ import { type ResolvedTuiTheme, useTheme } from '../../theme'
8
+
9
+ /** Filled means on. Both are one cell wide in a Latin-width terminal. */
10
+ const ON = '●'
11
+ const OFF = '○'
12
+
13
+ /** What a row shows on its right edge, per kind. */
14
+ export function formatValue(row: SettingRow, value: SettingValue): string {
15
+ switch (row.kind) {
16
+ case 'toggle':
17
+ return value === true ? ON : OFF
18
+ case 'select': {
19
+ const option = row.options.find((entry) => entry.value === value)
20
+ return `${option?.label ?? String(value)} ›`
21
+ }
22
+ case 'number':
23
+ return String(value)
24
+ case 'text': {
25
+ const text = String(value)
26
+ return text === '' ? (row.placeholder ?? 'not set') : text
27
+ }
28
+ case 'info':
29
+ case 'action':
30
+ return String(value)
31
+ default:
32
+ row satisfies never
33
+ return ''
34
+ }
35
+ }
36
+
37
+ /**
38
+ * The same value in prose, for the line under the label. The column wants a glyph
39
+ * you can scan; a sentence wants a word — `default: ●` is not a sentence.
40
+ */
41
+ export function describeValue(row: SettingRow, value: SettingValue): string {
42
+ switch (row.kind) {
43
+ case 'toggle':
44
+ return value === true ? 'on' : 'off'
45
+ case 'select':
46
+ return row.options.find((entry) => entry.value === value)?.label ?? String(value)
47
+ case 'text':
48
+ return String(value) === '' ? 'empty' : String(value)
49
+ default:
50
+ return String(value)
51
+ }
52
+ }
53
+
54
+ function valueColor(row: SettingRow, value: SettingValue, t: ResolvedTuiTheme): string {
55
+ // A toggle is read at a glance or not at all, so its state is the colour as much
56
+ // as the glyph: lit when on, as quiet as the rest of the row when off.
57
+ if (row.kind === 'toggle') return value === true ? t.success : t.textMuted
58
+ return row.kind === 'info' ? t.textMuted : t.primary
59
+ }
60
+
61
+ /**
62
+ * A row's current value, read by the row itself.
63
+ *
64
+ * Per row rather than per list on purpose: a value is a primitive, so a PTY frame
65
+ * that rewrites `tabs` re-runs this selector, gets the same value back, and
66
+ * renders nothing. A parent subscribing to the whole store for every row in the
67
+ * list would repaint it at the rate the terminals print.
68
+ */
69
+ export const RowValue = memo(function RowValue({ row }: { row: SettingRow }) {
70
+ const t = useTheme()
71
+ const values = useSettingsStore((s) => s.values)
72
+ const value = useAppStore((s) => readRow(row, { state: s, values }))
73
+ return <text fg={valueColor(row, value, t)}>{formatValue(row, value)}</text>
74
+ })
@@ -0,0 +1,78 @@
1
+ import { memo } from 'react'
2
+
3
+ import type { SettingRow } from '../../../settings/types'
4
+
5
+ import { storedRow, useSettingsStore } from '../../../settings/settings-store'
6
+ import { useTheme } from '../../theme'
7
+ import { ListItem } from '../primitives/list-item'
8
+ import { describeValue, RowValue } from './row-value'
9
+
10
+ /** Where the value comes from, on the left, out of the value column's way. */
11
+ const CONFIG_FILE_MARK = '*'
12
+ const TOUCHED_MARK = '~'
13
+
14
+ interface SettingsRowProps {
15
+ row: SettingRow
16
+ active: boolean
17
+ index: number
18
+ onSelect: (index: number) => void
19
+ }
20
+
21
+ export const SettingsRow = memo(function SettingsRow({
22
+ active,
23
+ index,
24
+ onSelect,
25
+ row,
26
+ }: SettingsRowProps) {
27
+ const t = useTheme()
28
+ const fromConfigFile = useSettingsStore((s) => s.fromConfigFile.has(row.id))
29
+ const touched = useSettingsStore((s) => s.touched.has(row.id))
30
+ const defaultValue = useSettingsStore((s) => s.defaults[row.id])
31
+ const editable = row.kind !== 'info'
32
+ // A blank when neither applies, never nothing: a marker that comes and goes
33
+ // would shift the label of every row it is missing from.
34
+ let mark = ' '
35
+ if (fromConfigFile) mark = CONFIG_FILE_MARK
36
+ else if (touched) mark = TOUCHED_MARK
37
+
38
+ // Always, never only on the focused row: a row that grows a line when you land
39
+ // on it shifts everything under it, so the list moves under the cursor the
40
+ // whole way down. A taller list that holds still reads better than a compact
41
+ // one that does not.
42
+ const notes: string[] = []
43
+ if (row.description != null && row.description !== '') notes.push(row.description)
44
+ if (fromConfigFile) notes.push('set in aimux.config.ts — comes back on restart')
45
+ else if (storedRow(row)?.restart === true) notes.push('applies on restart')
46
+ // Right-aligned on the same line rather than tacked onto the end of the
47
+ // description: it is about the row, not about what the setting does, and the
48
+ // value column above it is on that edge too. Only once the row has been
49
+ // changed — `r` is otherwise a key you press to find out what it does.
50
+ const resetHint =
51
+ touched && defaultValue !== undefined ? `r resets to ${describeValue(row, defaultValue)}` : null
52
+
53
+ return (
54
+ <ListItem
55
+ id={`setting-row-${row.id}`}
56
+ active={active}
57
+ index={index}
58
+ onClickIndex={onSelect}
59
+ leading={<text fg={fromConfigFile ? t.warning : t.secondary}>{mark}</text>}
60
+ title={<text fg={editable ? t.text : t.textMuted}>{row.label}</text>}
61
+ subtitle={
62
+ notes.length > 0 || resetHint != null ? (
63
+ <box flexDirection="row">
64
+ <box flexGrow={1} flexShrink={1}>
65
+ <text fg={t.textMuted}>{notes.join(' · ')}</text>
66
+ </box>
67
+ {resetHint != null ? (
68
+ <text fg={t.textMuted} wrapMode="none">
69
+ {resetHint}
70
+ </text>
71
+ ) : null}
72
+ </box>
73
+ ) : undefined
74
+ }
75
+ trailing={<RowValue row={row} />}
76
+ />
77
+ )
78
+ })
@@ -0,0 +1,169 @@
1
+ import type { ScrollBoxRenderable } from '@opentui/core'
2
+
3
+ import { useTerminalDimensions } from '@opentui/react'
4
+ import { memo, useCallback, useMemo, useRef } from 'react'
5
+
6
+ import { getSectionRows, SETTING_SECTIONS } from '../../../settings/sections'
7
+ import { useSettingsStore } from '../../../settings/settings-store'
8
+ import { useAppStore } from '../../../state/app-store'
9
+ import { clampBarWidth } from '../../../state/bars'
10
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
11
+ import { useScrollActiveIntoView } from '../../hooks/use-scroll-active-into-view'
12
+ import { useTheme } from '../../theme'
13
+ import { ListItem } from '../primitives/list-item'
14
+ import { SettingsRow } from './settings-row'
15
+
16
+ const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
17
+ /**
18
+ * Share of the window the settings themselves are allowed. Without a cap the
19
+ * value sits on the far edge of a wide terminal, a hundred columns from the label
20
+ * it belongs to, and the pair stops reading as a pair.
21
+ *
22
+ * A share of the window rather than a fixed column count, so it holds whatever
23
+ * the window is; the floor is there for the narrow ones, where the pane is
24
+ * smaller than the cap anyway and this stops binding.
25
+ */
26
+ const CONTENT_WIDTH_RATIO = 0.45
27
+ const CONTENT_MIN_WIDTH = 56
28
+
29
+ export const SettingsView = memo(function SettingsView() {
30
+ const t = useTheme()
31
+ // The cursor is all this component needs from the app state. Rows read their
32
+ // own values, so nothing here re-renders at the rate the terminals print.
33
+ const settings = useAppStore((s) => s.settings)
34
+ // The section list stands exactly where the left bar stands, so opening the
35
+ // screen does not shift the column under the cursor. Its configured width, not
36
+ // `getBarWidth`: a hidden bar still says how wide the sidebar is.
37
+ const navWidth = useAppStore((s) => clampBarWidth(s.bars.left.width))
38
+ // Which rows exist can depend on the projects — Setup has one per project — and
39
+ // building a Setup row reads its script off disk. So the list is rebuilt when
40
+ // the section changes, when the projects change, and after any write; not on
41
+ // every render, which at the rate this store changes would mean reading files
42
+ // at the rate the terminals print.
43
+ const projects = useAppStore((s) => s.projects)
44
+ const revision = useSettingsStore((s) => s.revision)
45
+ const rows = useMemo(
46
+ () => getSectionRows(settings.sectionId, projects),
47
+ // `revision` is an invalidation key, not an input: it says a value the builder
48
+ // read from disk may have changed under it.
49
+ // eslint-disable-next-line react-hooks/exhaustive-deps
50
+ [settings.sectionId, projects, revision]
51
+ )
52
+ const dimensions = useTerminalDimensions()
53
+ const contentMaxWidth = Math.max(
54
+ CONTENT_MIN_WIDTH,
55
+ Math.round(dimensions.width * CONTENT_WIDTH_RATIO)
56
+ )
57
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null)
58
+
59
+ useScrollActiveIntoView({
60
+ activeId: settings.pane === 'rows' ? (rows[settings.rowIndex]?.id ?? null) : null,
61
+ idPrefix: 'setting-row-',
62
+ scrollRef,
63
+ visible: true,
64
+ })
65
+
66
+ const handleSectionClick = useCallback((index: number) => {
67
+ const sectionId = SETTING_SECTIONS[index]?.id
68
+ if (sectionId == null) return
69
+ dispatchGlobal({ sectionId, type: 'settings-select-section' })
70
+ }, [])
71
+
72
+ // One click selects the row and changes it — a checkbox you have to select
73
+ // first and then click again is not a checkbox.
74
+ const handleRowClick = useCallback((rowIndex: number) => {
75
+ dispatchGlobal({ rowIndex, type: 'settings-select-row' })
76
+ runSideEffectGlobal({ type: 'activate-settings-row' })
77
+ }, [])
78
+
79
+ const handleClose = useCallback(() => {
80
+ dispatchGlobal({ type: 'exit-settings' })
81
+ }, [])
82
+
83
+ const section = SETTING_SECTIONS.find((s) => s.id === settings.sectionId)
84
+ const sectionLabel = section?.label ?? 'Settings'
85
+ const sectionNote = section?.description
86
+ // Same 1-cell seam the bar draws between itself and the terminal, so the two
87
+ // views line up to the column.
88
+ const navContentWidth = Math.max(1, navWidth - 1)
89
+
90
+ return (
91
+ <box flexDirection="row" flexGrow={1} overflow="hidden">
92
+ <box width={navWidth} flexDirection="row" overflow="hidden" backgroundColor={t.background}>
93
+ <box width={navContentWidth} flexDirection="column" overflow="hidden">
94
+ <box paddingLeft={1} paddingRight={1}>
95
+ <text fg={t.textMuted}>Settings</text>
96
+ </box>
97
+ <box flexGrow={1} flexShrink={1} flexDirection="column" overflow="hidden">
98
+ {SETTING_SECTIONS.map((section, index) => (
99
+ <ListItem
100
+ key={section.id}
101
+ active={section.id === settings.sectionId}
102
+ index={index}
103
+ onClickIndex={handleSectionClick}
104
+ title={
105
+ <text fg={section.id === settings.sectionId ? t.text : t.textMuted}>
106
+ {section.label}
107
+ </text>
108
+ }
109
+ trailing={
110
+ section.id === settings.sectionId && settings.pane === 'nav' ? (
111
+ <text fg={t.primary}>›</text>
112
+ ) : undefined
113
+ }
114
+ />
115
+ ))}
116
+ </box>
117
+ <box flexDirection="row" flexShrink={0} paddingLeft={1} paddingRight={1}>
118
+ <text fg={t.textMuted} onMouseDown={handleClose}>
119
+ ‹ Close
120
+ </text>
121
+ </box>
122
+ </box>
123
+ <box width={1} flexShrink={0} backgroundColor={t.border} />
124
+ </box>
125
+ {/* The pane the terminal would be in, with the same border — the content
126
+ keeps the inset it had, instead of jumping to the edge of the screen. */}
127
+ <box
128
+ border
129
+ borderColor={settings.pane === 'rows' ? t.borderActive : t.border}
130
+ title={sectionLabel}
131
+ padding={0}
132
+ flexDirection="column"
133
+ flexGrow={1}
134
+ backgroundColor={t.background}
135
+ >
136
+ <box flexDirection="column" flexGrow={1} flexShrink={1} maxWidth={contentMaxWidth}>
137
+ {sectionNote != null && sectionNote !== '' ? (
138
+ <box flexShrink={0} paddingLeft={1} paddingRight={1}>
139
+ <text fg={t.textMuted}>{sectionNote}</text>
140
+ </box>
141
+ ) : null}
142
+ <scrollbox
143
+ ref={scrollRef}
144
+ scrollY
145
+ flexGrow={1}
146
+ flexShrink={1}
147
+ contentOptions={COLUMN_CONTENT_OPTIONS}
148
+ >
149
+ {rows.length === 0 ? (
150
+ <box paddingLeft={1}>
151
+ <text fg={t.textMuted}>Nothing to configure here yet.</text>
152
+ </box>
153
+ ) : (
154
+ rows.map((row, index) => (
155
+ <SettingsRow
156
+ key={row.id}
157
+ row={row}
158
+ index={index}
159
+ active={settings.pane === 'rows' && index === settings.rowIndex}
160
+ onSelect={handleRowClick}
161
+ />
162
+ ))
163
+ )}
164
+ </scrollbox>
165
+ </box>
166
+ </box>
167
+ </box>
168
+ )
169
+ })
@@ -0,0 +1,152 @@
1
+ import { memo, useCallback, useEffect, useState } from 'react'
2
+
3
+ import type { SideEffect } from '../../../input/modes/types'
4
+
5
+ import { measureGlobal } from '../../../app-runtime/measure-ref'
6
+ import { findSetupTab } from '../../../app-runtime/setup-actions'
7
+ import { usePaneSizeReport } from '../../../app-runtime/use-pane-size-report'
8
+ import { useAppStore } from '../../../state/app-store'
9
+ import { runSideEffectGlobal } from '../../../state/dispatch-ref'
10
+ import { hasSetupScript } from '../../../state/project-data'
11
+ import { getActiveWorkspace, getCurrentProject } from '../../../state/project-workspaces'
12
+ import { useTheme, useTransparent } from '../../theme'
13
+ import { TerminalViewport } from '../layout/terminal-pane'
14
+
15
+ /**
16
+ * How often the widget re-checks whether a setup script exists. Needed because
17
+ * the file can appear with no state change behind it — an agent writing it, or
18
+ * the user saving in their editor. Cheap `existsSync`; checking on every render
19
+ * would fire a syscall per PTY frame.
20
+ */
21
+ const SCRIPT_POLL_MS = 2_000
22
+
23
+ // Hoisted so they are stable prop identities across renders.
24
+ const RUN: SideEffect = { type: 'run-setup' }
25
+ const STOP: SideEffect = { type: 'stop-setup' }
26
+ const CONFIGURE: SideEffect = { type: 'configure-setup-script' }
27
+ const ASK_AGENT: SideEffect = { type: 'ask-agent-for-setup-script' }
28
+ const PROMOTE: SideEffect = { type: 'promote-setup-tab' }
29
+
30
+ function Button({ effect, label }: { effect: SideEffect; label: string }) {
31
+ const t = useTheme()
32
+ const transparent = useTransparent()
33
+ const onMouseDown = useCallback(() => runSideEffectGlobal(effect), [effect])
34
+
35
+ return (
36
+ <box
37
+ paddingLeft={1}
38
+ paddingRight={1}
39
+ flexShrink={0}
40
+ backgroundColor={transparent ? undefined : t.backgroundElement}
41
+ onMouseDown={onMouseDown}
42
+ >
43
+ <text selectable={false} fg={t.text} wrapMode="none">
44
+ {label}
45
+ </text>
46
+ </box>
47
+ )
48
+ }
49
+
50
+ export const SetupWidget = memo(function SetupWidget() {
51
+ const t = useTheme()
52
+ const currentProjectId = useAppStore((s) => s.currentProjectId)
53
+ const projects = useAppStore((s) => s.projects)
54
+
55
+ const project = getCurrentProject({ currentProjectId, projects })
56
+ const workspace = getActiveWorkspace(project)
57
+
58
+ // Selecting the tab rather than the whole `tabs` array: that array gets a new
59
+ // identity on every viewport frame of every terminal, so subscribing to it
60
+ // re-renders this widget at the rate the other PTYs print. The found tab keeps
61
+ // its identity until it changes, which is exactly when a redraw is due.
62
+ const setupTab = useAppStore((s) => findSetupTab(s.tabs, workspace?.id))
63
+
64
+ const [scriptExists, setScriptExists] = useState(false)
65
+ useEffect(() => {
66
+ if (project === undefined) {
67
+ setScriptExists(false)
68
+ return
69
+ }
70
+ const projectId = project.id
71
+ const check = () => setScriptExists(hasSetupScript(projectId))
72
+ check()
73
+ const timer = setInterval(check, SCRIPT_POLL_MS)
74
+ return () => clearInterval(timer)
75
+ }, [project])
76
+
77
+ // The PTY is sized from this box, not from the main terminal area — the resize
78
+ // cascade skips hidden tabs precisely so this is the only authority.
79
+ const setContentBox = usePaneSizeReport(setupTab?.id, setupTab !== undefined, measureGlobal)
80
+
81
+ if (!project || !workspace) {
82
+ return (
83
+ <box flexDirection="column" flexGrow={1} flexShrink={1} flexBasis={0} overflow="hidden">
84
+ <text selectable={false} fg={t.textMuted} wrapMode="none">
85
+ No workspace
86
+ </text>
87
+ </box>
88
+ )
89
+ }
90
+
91
+ const ranAt = workspace.setupRanAt
92
+ const exitCode = workspace.setupExitCode
93
+ const finished = ranAt != null && ranAt !== ''
94
+ const running = setupTab !== undefined && !finished
95
+
96
+ let statusText = 'not run here'
97
+ let statusFg = t.textMuted
98
+ if (!scriptExists) {
99
+ statusText = 'no setup script'
100
+ } else if (running) {
101
+ statusText = 'running…'
102
+ statusFg = t.primary
103
+ } else if (finished) {
104
+ statusText = exitCode === 0 ? '✓ setup ok' : `✗ exit ${exitCode ?? '?'}`
105
+ statusFg = exitCode === 0 ? t.success : t.error
106
+ }
107
+
108
+ let runLabel = 'Run'
109
+ if (running) runLabel = 'Stop'
110
+ else if (finished) runLabel = 'Re-run'
111
+
112
+ return (
113
+ <box flexDirection="column" flexGrow={1} flexShrink={1} flexBasis={0} overflow="hidden">
114
+ <box flexDirection="row" flexShrink={0} justifyContent="space-between" gap={1}>
115
+ <text selectable={false} fg={statusFg} wrapMode="none">
116
+ {statusText}
117
+ </text>
118
+ <text selectable={false} fg={t.textMuted} wrapMode="none">
119
+ {workspace.name}
120
+ </text>
121
+ </box>
122
+
123
+ {/* Only Run depends on a script existing. Edit and Agent must not vanish
124
+ the moment one does, or writing a stub and closing the editor leaves no
125
+ way to ask an agent to fill it in — and no way out. */}
126
+ <box flexDirection="row" flexShrink={0} gap={1} paddingTop={1} paddingBottom={1}>
127
+ {scriptExists ? <Button effect={running ? STOP : RUN} label={runLabel} /> : null}
128
+ <Button effect={CONFIGURE} label={scriptExists ? 'Edit' : 'Create'} />
129
+ <Button effect={ASK_AGENT} label="Agent" />
130
+ {/* A failing setup means reading a stack trace, which a bar this narrow
131
+ cannot do. This is the way out. */}
132
+ {setupTab ? <Button effect={PROMOTE} label="↗" /> : null}
133
+ </box>
134
+
135
+ {setupTab ? (
136
+ <box ref={setContentBox} flexDirection="column" flexGrow={1} width="100%" overflow="hidden">
137
+ <TerminalViewport
138
+ buffer={setupTab.buffer}
139
+ softCursor={false}
140
+ viewport={setupTab.viewport}
141
+ />
142
+ </box>
143
+ ) : (
144
+ <text selectable={false} fg={t.textMuted} wrapMode="word">
145
+ {scriptExists
146
+ ? 'Runs automatically in each new workspace.'
147
+ : 'A setup script installs what a fresh worktree lacks: dependencies, .env, caches.'}
148
+ </text>
149
+ )}
150
+ </box>
151
+ )
152
+ })
@@ -1,7 +1,7 @@
1
1
  import type { AppState, FlashJumpTarget, FlashLabel } from '../../state/types'
2
2
 
3
- import { filterTabsForActiveWorktree } from '../../state/session-worktrees'
4
- import { orderSessionsForDisplay } from '../session-ordering'
3
+ import { filterTabsForActiveWorkspace, getPrimaryWorkspace } from '../../state/project-workspaces'
4
+ import { orderProjectsForDisplay } from '../project-ordering'
5
5
  import { assignFlashLabels, type FlashTarget } from './assign-labels'
6
6
 
7
7
  interface PendingTarget {
@@ -12,50 +12,50 @@ interface PendingTarget {
12
12
 
13
13
  /**
14
14
  * Collect every visible flash-jump target in the same order the UI renders:
15
- * workspace rows first (each followed by its non-primary worktrees), then the
16
- * tabs of the active worktree. Stable ordering keeps the assigned labels
15
+ * project rows first (each followed by its non-primary workspaces), then the
16
+ * tabs of the active workspace. Stable ordering keeps the assigned labels
17
17
  * predictable across re-opens when nothing changed.
18
18
  */
19
19
  function collectTargets(state: AppState): PendingTarget[] {
20
20
  const out: PendingTarget[] = []
21
- const ordered = orderSessionsForDisplay(state.sessions)
22
- for (const [index, session] of ordered.entries()) {
23
- const sessionIndex = index + 1
24
- const worktrees = session.worktrees ?? []
25
- const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
21
+ const ordered = orderProjectsForDisplay(state.projects)
22
+ for (const [index, project] of ordered.entries()) {
23
+ const projectIndex = index + 1
24
+ const workspaces = project.workspaces ?? []
25
+ const primary = getPrimaryWorkspace(workspaces)
26
26
  out.push({
27
- key: `ws:${session.id}`,
28
- name: session.name,
27
+ key: `ws:${project.id}`,
28
+ name: project.name,
29
29
  target: {
30
- kind: 'workspace',
31
- sessionId: session.id,
32
- sessionIndex,
33
- worktreeId: primary?.id,
30
+ kind: 'project',
31
+ projectId: project.id,
32
+ projectIndex,
33
+ workspaceId: primary?.id,
34
34
  },
35
35
  })
36
- for (const worktree of worktrees) {
37
- if (worktree.id === primary?.id) continue
36
+ for (const workspace of workspaces) {
37
+ if (workspace.id === primary?.id) continue
38
38
  out.push({
39
- key: `wt:${worktree.id}`,
40
- name: worktree.name,
39
+ key: `wt:${workspace.id}`,
40
+ name: workspace.name,
41
41
  target: {
42
- kind: 'worktree',
43
- sessionId: session.id,
44
- sessionIndex,
45
- worktreeId: worktree.id,
42
+ kind: 'workspace',
43
+ projectId: project.id,
44
+ projectIndex,
45
+ workspaceId: workspace.id,
46
46
  },
47
47
  })
48
48
  }
49
49
  }
50
50
 
51
- const currentSession = state.sessions.find((s) => s.id === state.currentSessionId)
52
- if (currentSession) {
53
- const tabs = filterTabsForActiveWorktree(state.tabs, currentSession)
51
+ const currentProject = state.projects.find((s) => s.id === state.currentProjectId)
52
+ if (currentProject) {
53
+ const tabs = filterTabsForActiveWorkspace(state.tabs, currentProject)
54
54
  for (const tab of tabs) {
55
55
  out.push({
56
56
  key: `tab:${tab.id}`,
57
57
  name: tab.title,
58
- target: { kind: 'tab', sessionId: currentSession.id, sessionIndex: 0, tabId: tab.id },
58
+ target: { kind: 'tab', projectId: currentProject.id, projectIndex: 0, tabId: tab.id },
59
59
  })
60
60
  }
61
61
  }