@brimveyn/aimux 1.1.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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/package.json +79 -0
  4. package/src/app-runtime/backend-attach-runtime.ts +135 -0
  5. package/src/app-runtime/backend-runtime-events.ts +78 -0
  6. package/src/app-runtime/click-selection-resolver.ts +130 -0
  7. package/src/app-runtime/pty-write.ts +32 -0
  8. package/src/app-runtime/render-invalidation.ts +20 -0
  9. package/src/app-runtime/selection-clipboard.ts +69 -0
  10. package/src/app-runtime/selection-scroll.ts +143 -0
  11. package/src/app-runtime/session-actions.ts +170 -0
  12. package/src/app-runtime/side-effects.ts +434 -0
  13. package/src/app-runtime/snippet-actions.ts +90 -0
  14. package/src/app-runtime/split-drag-controller.ts +15 -0
  15. package/src/app-runtime/tab-runtime-timeouts.ts +86 -0
  16. package/src/app-runtime/terminal-mouse-adapter.ts +31 -0
  17. package/src/app-runtime/use-backend-runtime.ts +99 -0
  18. package/src/app-runtime/use-directory-search.ts +52 -0
  19. package/src/app-runtime/use-mouse-handlers.ts +183 -0
  20. package/src/app-runtime/use-renderer-bindings.ts +163 -0
  21. package/src/app-runtime/use-terminal-resize.ts +141 -0
  22. package/src/app-runtime/use-workspace-autosave.ts +39 -0
  23. package/src/app.tsx +247 -0
  24. package/src/config/loader.ts +36 -0
  25. package/src/config.ts +146 -0
  26. package/src/daemon/daemon.ts +236 -0
  27. package/src/daemon/runtime-paths.ts +69 -0
  28. package/src/daemon/session-manager.ts +109 -0
  29. package/src/daemon/session-registry.ts +207 -0
  30. package/src/debug/input-log.ts +29 -0
  31. package/src/doctor.ts +106 -0
  32. package/src/git/git-poller.ts +49 -0
  33. package/src/git/git-status.ts +183 -0
  34. package/src/index.tsx +56 -0
  35. package/src/input/keymap/build-handlers.ts +34 -0
  36. package/src/input/keymap/key-chord.ts +201 -0
  37. package/src/input/keymap/keymap-mode-handler.ts +88 -0
  38. package/src/input/keymap/sequence-resolver.ts +117 -0
  39. package/src/input/keymap/trie.ts +103 -0
  40. package/src/input/modes/bridge.ts +58 -0
  41. package/src/input/modes/handlers/index.ts +18 -0
  42. package/src/input/modes/handlers/shared.ts +70 -0
  43. package/src/input/modes/registry.ts +34 -0
  44. package/src/input/modes/transitions.ts +37 -0
  45. package/src/input/modes/types.ts +63 -0
  46. package/src/input/mouse-forwarding.ts +98 -0
  47. package/src/input/multi-click-detector.ts +55 -0
  48. package/src/input/paste.ts +12 -0
  49. package/src/input/raw-input-handler.ts +191 -0
  50. package/src/input/terminal-text-extraction.ts +78 -0
  51. package/src/ipc/protocol.ts +341 -0
  52. package/src/platform/clipboard.ts +13 -0
  53. package/src/platform/daemon-control.ts +62 -0
  54. package/src/platform/id.ts +7 -0
  55. package/src/platform/project-search.ts +75 -0
  56. package/src/pty/command-registry.ts +69 -0
  57. package/src/pty/pty-manager.ts +268 -0
  58. package/src/pty/terminal-snapshot.ts +210 -0
  59. package/src/restart-daemon.ts +28 -0
  60. package/src/session-backend/bootstrap.ts +107 -0
  61. package/src/session-backend/local-session-backend.ts +164 -0
  62. package/src/session-backend/remote-session-backend.ts +373 -0
  63. package/src/session-backend/types.ts +47 -0
  64. package/src/state/app-store.ts +19 -0
  65. package/src/state/layout-resize.ts +56 -0
  66. package/src/state/layout-tree.ts +323 -0
  67. package/src/state/reducers/git-panel-state.ts +102 -0
  68. package/src/state/reducers/modal-state.ts +358 -0
  69. package/src/state/reducers/session-state.ts +86 -0
  70. package/src/state/reducers/tab-state.ts +531 -0
  71. package/src/state/reducers/ui-state.ts +29 -0
  72. package/src/state/selectors.ts +30 -0
  73. package/src/state/session-catalog.ts +96 -0
  74. package/src/state/session-persistence.ts +210 -0
  75. package/src/state/snippet-catalog.ts +90 -0
  76. package/src/state/store.ts +93 -0
  77. package/src/state/terminal-modes.ts +11 -0
  78. package/src/state/types.ts +367 -0
  79. package/src/state/validation.ts +154 -0
  80. package/src/state/workspace-save.ts +33 -0
  81. package/src/ui/components/create-session-modal.tsx +100 -0
  82. package/src/ui/components/git-panel.tsx +200 -0
  83. package/src/ui/components/help-modal.tsx +79 -0
  84. package/src/ui/components/input-field.tsx +18 -0
  85. package/src/ui/components/list-item.tsx +30 -0
  86. package/src/ui/components/modal-filter-bar.tsx +18 -0
  87. package/src/ui/components/modal-shell.tsx +47 -0
  88. package/src/ui/components/new-tab-modal.tsx +52 -0
  89. package/src/ui/components/pending-chord-indicator.tsx +41 -0
  90. package/src/ui/components/session-name-modal.tsx +15 -0
  91. package/src/ui/components/session-picker-modal.tsx +93 -0
  92. package/src/ui/components/sidebar-group-metadata.ts +44 -0
  93. package/src/ui/components/sidebar-scroll.ts +33 -0
  94. package/src/ui/components/sidebar.tsx +225 -0
  95. package/src/ui/components/snippet-editor-modal.tsx +39 -0
  96. package/src/ui/components/snippet-picker-modal.tsx +56 -0
  97. package/src/ui/components/split-layout.tsx +201 -0
  98. package/src/ui/components/status-bar.tsx +60 -0
  99. package/src/ui/components/surface.tsx +63 -0
  100. package/src/ui/components/tab-item.tsx +118 -0
  101. package/src/ui/components/terminal-pane.tsx +215 -0
  102. package/src/ui/components/theme-picker-modal.tsx +35 -0
  103. package/src/ui/components/use-sidebar-auto-scroll.ts +63 -0
  104. package/src/ui/components/use-sidebar-branch.ts +36 -0
  105. package/src/ui/directory-search.ts +1 -0
  106. package/src/ui/git-branch.ts +11 -0
  107. package/src/ui/path-format.ts +6 -0
  108. package/src/ui/root.tsx +261 -0
  109. package/src/ui/status-bar-model.ts +87 -0
  110. package/src/ui/theme.ts +9 -0
  111. package/src/ui/themes.ts +255 -0
  112. package/src/ui/ui-tokens.ts +11 -0
  113. package/src/update.ts +62 -0
@@ -0,0 +1,118 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ import type { TabSession } from '../../state/types'
4
+
5
+ import { theme } from '../theme'
6
+
7
+ interface TabItemProps {
8
+ id?: string
9
+ tab: TabSession
10
+ active: boolean
11
+ focused: boolean
12
+ isFocusedInput: boolean
13
+ inLayout?: boolean
14
+ }
15
+
16
+ function getStatusColor(status: TabSession['status']): string {
17
+ switch (status) {
18
+ case 'running':
19
+ return theme.success
20
+ case 'disconnected':
21
+ return theme.warning
22
+ case 'error':
23
+ return theme.danger
24
+ case 'exited':
25
+ return theme.warning
26
+ default:
27
+ return theme.textMuted
28
+ }
29
+ }
30
+
31
+ const BUSY_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
32
+ const BUSY_FRAME_INTERVAL_MS = 80
33
+
34
+ function getIndicator(active: boolean, focused: boolean, inLayout: boolean): string {
35
+ if (active) {
36
+ return focused ? '›' : '•'
37
+ }
38
+
39
+ return inLayout ? '·' : ' '
40
+ }
41
+
42
+ function getIndicatorColor(active: boolean, focused: boolean, inLayout: boolean): string {
43
+ if (active) {
44
+ return focused ? theme.accent : theme.accentAlt
45
+ }
46
+
47
+ return inLayout ? theme.textMuted : theme.dim
48
+ }
49
+
50
+ function BusyIndicator() {
51
+ const [frame, setFrame] = useState(0)
52
+
53
+ useEffect(() => {
54
+ const interval = setInterval(() => {
55
+ setFrame((prev) => (prev + 1) % BUSY_FRAMES.length)
56
+ }, BUSY_FRAME_INTERVAL_MS)
57
+ return () => clearInterval(interval)
58
+ }, [])
59
+
60
+ return <text fg={theme.accent}>{BUSY_FRAMES[frame]} busy</text>
61
+ }
62
+
63
+ function ActivityIndicator({ isFocusedInput, tab }: { tab: TabSession; isFocusedInput: boolean }) {
64
+ if (tab.status === 'error') {
65
+ return <text fg={theme.danger}>✗ error</text>
66
+ }
67
+
68
+ if (tab.status === 'disconnected') {
69
+ return <text fg={theme.warning}>⏸ restore</text>
70
+ }
71
+
72
+ if (tab.status === 'exited') {
73
+ return <text fg={theme.warning}>⏹ exited</text>
74
+ }
75
+
76
+ if (isFocusedInput) {
77
+ return <text fg={theme.borderActive}>▸ focused</text>
78
+ }
79
+
80
+ if (tab.activity === 'busy') {
81
+ return <BusyIndicator />
82
+ }
83
+
84
+ if (tab.activity === 'idle') {
85
+ return <text fg={theme.success}>● idle</text>
86
+ }
87
+
88
+ return <text fg={getStatusColor(tab.status)}>{tab.status}</text>
89
+ }
90
+
91
+ export function TabItem({ active, focused, id, inLayout, isFocusedInput, tab }: TabItemProps) {
92
+ const label = tab.command.split(' ')[0]
93
+ const isInLayout = inLayout ?? false
94
+ const indicator = getIndicator(active, focused, isInLayout)
95
+ const indicatorColor = getIndicatorColor(active, focused, isInLayout)
96
+
97
+ return (
98
+ <box
99
+ id={id}
100
+ paddingLeft={1}
101
+ paddingRight={1}
102
+ paddingTop={0}
103
+ paddingBottom={0}
104
+ backgroundColor={active ? theme.panelHighlight : undefined}
105
+ flexDirection="column"
106
+ gap={0}
107
+ >
108
+ <box flexDirection="row">
109
+ <text fg={indicatorColor}>{indicator} </text>
110
+ <text fg={active ? theme.text : theme.textMuted}>{tab.title}</text>
111
+ </box>
112
+ <box flexDirection="row">
113
+ <text fg={theme.textMuted}> {label} </text>
114
+ <ActivityIndicator tab={tab} isFocusedInput={isFocusedInput} />
115
+ </box>
116
+ </box>
117
+ )
118
+ }
@@ -0,0 +1,215 @@
1
+ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+ import type { ReactNode } from 'react'
3
+
4
+ import type { TerminalContentOrigin } from '../../input/raw-input-handler'
5
+ import type { TabSession, TerminalSpan } from '../../state/types'
6
+
7
+ import { logInputDebug } from '../../debug/input-log'
8
+ import { theme } from '../theme'
9
+
10
+ interface TerminalPaneProps {
11
+ tab?: TabSession
12
+ tabId?: string
13
+ focusMode: import('../../state/types').FocusMode
14
+ isActive?: boolean
15
+ contentOrigin: TerminalContentOrigin
16
+ mouseForwardingEnabled: boolean
17
+ localScrollbackEnabled: boolean
18
+ onTerminalMouseEvent: (event: OtuiMouseEvent, origin: TerminalContentOrigin) => void
19
+ onTerminalScrollEvent: (event: OtuiMouseEvent) => void
20
+ onTerminalClick?: (event: OtuiMouseEvent, origin: TerminalContentOrigin, tabId?: string) => void
21
+ onPaneActivate?: (tabId: string) => void
22
+ onSeparatorDrag?: (event: OtuiMouseEvent) => boolean
23
+ onSeparatorDragEnd?: () => void
24
+ }
25
+
26
+ function getTitle(
27
+ tab: TabSession | undefined,
28
+ isActive: boolean,
29
+ focusMode: TerminalPaneProps['focusMode']
30
+ ): string {
31
+ if (!tab) {
32
+ return 'No active session'
33
+ }
34
+
35
+ if (isActive && focusMode === 'terminal-input') {
36
+ return `● ${tab.title} · ${tab.status}`
37
+ }
38
+
39
+ if (isActive) {
40
+ return `▸ ${tab.title} · ${tab.status}`
41
+ }
42
+
43
+ return `${tab.title} · ${tab.status}`
44
+ }
45
+
46
+ function getBorderColor(isActive: boolean, focusMode: TerminalPaneProps['focusMode']): string {
47
+ if (isActive && focusMode === 'terminal-input') {
48
+ return theme.borderActive
49
+ }
50
+
51
+ if (isActive) {
52
+ return theme.accentAlt
53
+ }
54
+
55
+ return theme.dim
56
+ }
57
+
58
+ function renderSpan(span: TerminalSpan, key: string): ReactNode {
59
+ let node: ReactNode = span.text
60
+
61
+ if (span.underline) {
62
+ node = <u>{node}</u>
63
+ }
64
+
65
+ if (span.italic) {
66
+ node = <em>{node}</em>
67
+ }
68
+
69
+ if (span.bold) {
70
+ node = <strong>{node}</strong>
71
+ }
72
+
73
+ return (
74
+ <span key={key} fg={span.fg ?? theme.text} bg={span.bg}>
75
+ {node}
76
+ </span>
77
+ )
78
+ }
79
+
80
+ function renderViewport(tab: TabSession): ReactNode {
81
+ if (tab.viewport && tab.viewport.lines.length > 0) {
82
+ const lines = tab.viewport.lines
83
+ return (
84
+ <text fg={theme.text}>
85
+ {lines.map((line, lineIndex) => (
86
+ <span key={`line-${lineIndex}`}>
87
+ {line.spans.map((span, spanIndex) => renderSpan(span, `s-${spanIndex}`))}
88
+ {lineIndex < lines.length - 1 ? '\n' : ''}
89
+ </span>
90
+ ))}
91
+ </text>
92
+ )
93
+ }
94
+
95
+ return (
96
+ <text fg={theme.text}>
97
+ {tab.buffer.length > 0 ? tab.buffer : 'Waiting for session output...'}
98
+ </text>
99
+ )
100
+ }
101
+
102
+ export function TerminalPane({
103
+ contentOrigin,
104
+ focusMode,
105
+ isActive,
106
+ localScrollbackEnabled,
107
+ mouseForwardingEnabled,
108
+ onPaneActivate,
109
+ onSeparatorDrag,
110
+ onSeparatorDragEnd,
111
+ onTerminalClick,
112
+ onTerminalMouseEvent,
113
+ onTerminalScrollEvent,
114
+ tab,
115
+ tabId,
116
+ }: TerminalPaneProps) {
117
+ const paneIsActive = isActive ?? true
118
+ const canForwardMouse = focusMode === 'terminal-input' && !!tab && mouseForwardingEnabled
119
+ const canUseLocalScrollback = focusMode === 'terminal-input' && !!tab && localScrollbackEnabled
120
+ const forwardMouseEvent = (event: OtuiMouseEvent) => {
121
+ if (event.type === 'down') {
122
+ logInputDebug('pane.mouseDown', {
123
+ button: event.button,
124
+ canForward: canForwardMouse,
125
+ eventType: event.type,
126
+ tabId,
127
+ willClick: !canForwardMouse && !!tab && event.button === 0 && !!onTerminalClick,
128
+ x: event.x,
129
+ y: event.y,
130
+ })
131
+ }
132
+ if (event.type === 'drag' && onSeparatorDrag?.(event)) {
133
+ event.preventDefault()
134
+ return
135
+ }
136
+ if (event.type === 'up') {
137
+ onSeparatorDragEnd?.()
138
+ }
139
+ if (tabId && onPaneActivate && event.type === 'down') {
140
+ onPaneActivate(tabId)
141
+ }
142
+ if (!canForwardMouse) {
143
+ if (tab && event.type === 'down' && event.button === 0 && onTerminalClick) {
144
+ onTerminalClick(event, contentOrigin, tabId)
145
+ }
146
+ return
147
+ }
148
+
149
+ event.preventDefault()
150
+ event.stopPropagation()
151
+ onTerminalMouseEvent(event, contentOrigin)
152
+ }
153
+ const forwardScrollEvent = (event: OtuiMouseEvent) => {
154
+ if (!canForwardMouse && !canUseLocalScrollback) {
155
+ return
156
+ }
157
+
158
+ event.preventDefault()
159
+ event.stopPropagation()
160
+
161
+ if (canForwardMouse) {
162
+ onTerminalMouseEvent(event, contentOrigin)
163
+ return
164
+ }
165
+
166
+ onTerminalScrollEvent(event)
167
+ }
168
+ return (
169
+ <box flexDirection="column" flexGrow={1} gap={0}>
170
+ <box
171
+ border
172
+ borderColor={getBorderColor(paneIsActive, focusMode)}
173
+ title={getTitle(tab, paneIsActive, focusMode)}
174
+ padding={0}
175
+ flexDirection="column"
176
+ flexGrow={1}
177
+ backgroundColor={theme.background}
178
+ onMouseDrag={forwardMouseEvent}
179
+ onMouseScroll={forwardScrollEvent}
180
+ onMouseUp={forwardMouseEvent}
181
+ >
182
+ {!tab ? (
183
+ <box flexGrow={1} justifyContent="center" alignItems="center" flexDirection="column">
184
+ <text fg={theme.dim}>· · ·</text>
185
+ <text fg={theme.textMuted}> </text>
186
+ <box flexDirection="row">
187
+ <text fg={theme.textMuted}>Press </text>
188
+ <text fg={theme.accent}>Ctrl+n</text>
189
+ <text fg={theme.textMuted}> to launch an assistant</text>
190
+ </box>
191
+ </box>
192
+ ) : (
193
+ <box
194
+ flexDirection="column"
195
+ flexGrow={1}
196
+ width="100%"
197
+ onMouseDown={forwardMouseEvent}
198
+ onMouseUp={forwardMouseEvent}
199
+ onMouseDrag={forwardMouseEvent}
200
+ onMouseScroll={forwardScrollEvent}
201
+ >
202
+ {renderViewport(tab)}
203
+ </box>
204
+ )}
205
+ </box>
206
+ {tab?.status === 'exited' && tab.exitCode !== undefined ? (
207
+ <text fg={theme.warning}>Process exited with code {tab.exitCode}</text>
208
+ ) : null}
209
+ {tab?.status === 'disconnected' ? (
210
+ <text fg={theme.warning}>Restored snapshot. Press Ctrl+r to restart this session.</text>
211
+ ) : null}
212
+ {tab?.errorMessage ? <text fg={theme.danger}>{tab.errorMessage}</text> : null}
213
+ </box>
214
+ )
215
+ }
@@ -0,0 +1,35 @@
1
+ import { theme } from '../theme'
2
+ import { THEME_IDS, type ThemeId, THEMES } from '../themes'
3
+ import { uiTokens } from '../ui-tokens'
4
+ import { ListItem } from './list-item'
5
+ import { ModalShell } from './modal-shell'
6
+
7
+ interface ThemePickerModalProps {
8
+ selectedIndex: number
9
+ currentThemeId: ThemeId
10
+ }
11
+
12
+ export function ThemePickerModal({ currentThemeId, selectedIndex }: ThemePickerModalProps) {
13
+ return (
14
+ <ModalShell
15
+ title="Select theme"
16
+ help="j/k move, Enter confirm, Esc cancel."
17
+ width={uiTokens.modalWidth.md}
18
+ listGap={0}
19
+ >
20
+ {THEME_IDS.map((id, index) => {
21
+ const entry = THEMES[id]
22
+ const active = index === selectedIndex
23
+ const isCurrent = id === currentThemeId
24
+ return (
25
+ <ListItem
26
+ key={id}
27
+ active={active}
28
+ title={<text fg={active ? theme.text : theme.textMuted}>{entry.name}</text>}
29
+ trailing={isCurrent ? <text fg={theme.accent}>current</text> : undefined}
30
+ />
31
+ )
32
+ })}
33
+ </ModalShell>
34
+ )
35
+ }
@@ -0,0 +1,63 @@
1
+ import type { ScrollBoxRenderable } from '@opentui/core'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ import { getSidebarScrollTarget } from './sidebar-scroll'
6
+
7
+ interface UseSidebarAutoScrollOptions {
8
+ scrollRef: React.RefObject<ScrollBoxRenderable | null>
9
+ visible: boolean
10
+ activeTabId: string | null
11
+ activeIndex: number
12
+ tabCount: number
13
+ }
14
+
15
+ export function useSidebarAutoScroll({
16
+ activeIndex,
17
+ activeTabId,
18
+ scrollRef,
19
+ tabCount,
20
+ visible,
21
+ }: UseSidebarAutoScrollOptions): void {
22
+ const previousActiveIndexRef = useRef(-1)
23
+ const previousVisibilityRef = useRef(visible)
24
+
25
+ useEffect(() => {
26
+ if (!visible) {
27
+ previousVisibilityRef.current = false
28
+ previousActiveIndexRef.current = activeIndex
29
+ return
30
+ }
31
+
32
+ const scrollbox = scrollRef.current
33
+ if (!scrollbox) {
34
+ previousVisibilityRef.current = visible
35
+ previousActiveIndexRef.current = activeIndex
36
+ return
37
+ }
38
+
39
+ if (!previousVisibilityRef.current && activeTabId) {
40
+ scrollbox.scrollChildIntoView(`sidebar-tab-${activeTabId}`)
41
+ previousVisibilityRef.current = true
42
+ previousActiveIndexRef.current = activeIndex
43
+ return
44
+ }
45
+
46
+ const scrollTarget = getSidebarScrollTarget({
47
+ nextActiveIndex: activeIndex,
48
+ previousActiveIndex: previousActiveIndexRef.current,
49
+ tabCount,
50
+ })
51
+
52
+ if (scrollTarget === 'top') {
53
+ scrollbox.scrollTo({ x: 0, y: 0 })
54
+ } else if (scrollTarget === 'bottom') {
55
+ scrollbox.scrollTo({ x: 0, y: scrollbox.scrollHeight })
56
+ } else if (scrollTarget === 'active-item' && activeTabId) {
57
+ scrollbox.scrollChildIntoView(`sidebar-tab-${activeTabId}`)
58
+ }
59
+
60
+ previousVisibilityRef.current = visible
61
+ previousActiveIndexRef.current = activeIndex
62
+ }, [activeIndex, activeTabId, scrollRef, tabCount, visible])
63
+ }
@@ -0,0 +1,36 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ import { getCurrentBranch } from '../git-branch'
4
+
5
+ const BRANCH_POLL_INTERVAL_MS = 5_000
6
+
7
+ export function useSidebarBranch(projectPath: string | undefined): string | null {
8
+ const [branch, setBranch] = useState<string | null>(null)
9
+
10
+ useEffect(() => {
11
+ if (!projectPath) {
12
+ setBranch(null)
13
+ return
14
+ }
15
+
16
+ let isCurrent = true
17
+ const updateBranch = async () => {
18
+ const nextBranch = await getCurrentBranch(projectPath)
19
+ if (isCurrent) {
20
+ setBranch(nextBranch)
21
+ }
22
+ }
23
+
24
+ void updateBranch()
25
+ const interval = setInterval(() => {
26
+ void updateBranch()
27
+ }, BRANCH_POLL_INTERVAL_MS)
28
+
29
+ return () => {
30
+ isCurrent = false
31
+ clearInterval(interval)
32
+ }
33
+ }, [projectPath])
34
+
35
+ return branch
36
+ }
@@ -0,0 +1 @@
1
+ export { searchProjectDirectories as searchDirectories } from '../platform/project-search'
@@ -0,0 +1,11 @@
1
+ import { $ } from 'bun'
2
+
3
+ export async function getCurrentBranch(cwd: string): Promise<string | null> {
4
+ try {
5
+ const result = await $`git -C ${cwd} branch --show-current`.quiet()
6
+ const branch = result.text().trim()
7
+ return branch || null
8
+ } catch {
9
+ return null
10
+ }
11
+ }
@@ -0,0 +1,6 @@
1
+ import { homedir } from 'node:os'
2
+
3
+ export function abbreviatePath(path: string): string {
4
+ const home = homedir()
5
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path
6
+ }