@brimveyn/aimux 1.3.0 → 1.4.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 (60) hide show
  1. package/README.md +3 -0
  2. package/package.json +2 -2
  3. package/src/app-runtime/backend-runtime-events.ts +13 -1
  4. package/src/app-runtime/pty-write.ts +7 -4
  5. package/src/app-runtime/side-effects.ts +72 -4
  6. package/src/app-runtime/snippet-actions.ts +3 -2
  7. package/src/app-runtime/use-backend-runtime.ts +7 -2
  8. package/src/app-runtime/use-renderer-bindings.ts +2 -2
  9. package/src/app-runtime/use-terminal-resize.ts +15 -6
  10. package/src/app.tsx +113 -37
  11. package/src/config.ts +32 -1
  12. package/src/daemon/daemon.ts +19 -2
  13. package/src/daemon/session-manager.ts +20 -5
  14. package/src/daemon/session-registry.ts +18 -11
  15. package/src/index.tsx +8 -1
  16. package/src/input/keymap/describe-bindings.ts +68 -0
  17. package/src/input/keymap/key-format.ts +67 -0
  18. package/src/input/modes/bridge.ts +1 -0
  19. package/src/input/modes/transitions.ts +2 -0
  20. package/src/input/modes/types.ts +3 -0
  21. package/src/ipc/manager-protocol.ts +51 -2
  22. package/src/ipc/protocol.ts +43 -2
  23. package/src/pty/pty-manager.ts +29 -3
  24. package/src/session-backend/local-session-backend.ts +39 -5
  25. package/src/session-backend/remote-session-backend.ts +18 -5
  26. package/src/session-backend/types.ts +5 -2
  27. package/src/state/dispatch-ref.ts +11 -0
  28. package/src/state/reducers/modal-state.ts +18 -1
  29. package/src/state/reducers/session-state.ts +28 -0
  30. package/src/state/reducers/tab-state.ts +20 -2
  31. package/src/state/reducers/ui-state.ts +8 -0
  32. package/src/state/session-catalog.ts +22 -1
  33. package/src/state/session-persistence.ts +9 -2
  34. package/src/state/store.ts +8 -1
  35. package/src/state/types.ts +37 -0
  36. package/src/state/validation.ts +10 -0
  37. package/src/state/workspace-save.ts +2 -0
  38. package/src/terminal-manager/manager-client.ts +29 -5
  39. package/src/terminal-manager/terminal-manager.ts +19 -3
  40. package/src/ui/components/create-session-modal.tsx +3 -5
  41. package/src/ui/components/git-commit-modal.tsx +3 -5
  42. package/src/ui/components/help-modal.tsx +49 -68
  43. package/src/ui/components/list-item.tsx +24 -5
  44. package/src/ui/components/new-tab-modal.tsx +8 -9
  45. package/src/ui/components/pending-chord-overlay.tsx +28 -0
  46. package/src/ui/components/session-bar.tsx +208 -0
  47. package/src/ui/components/session-name-modal.tsx +3 -5
  48. package/src/ui/components/session-picker-modal.tsx +3 -1
  49. package/src/ui/components/snippet-editor-modal.tsx +3 -1
  50. package/src/ui/components/snippet-picker-modal.tsx +3 -1
  51. package/src/ui/components/status-bar.tsx +6 -2
  52. package/src/ui/components/tab-item.tsx +3 -15
  53. package/src/ui/components/theme-picker-modal.tsx +3 -6
  54. package/src/ui/components/update-available-modal.tsx +42 -0
  55. package/src/ui/hooks/use-busy-spinner.ts +18 -0
  56. package/src/ui/keymap-context.ts +39 -0
  57. package/src/ui/root.tsx +16 -0
  58. package/src/ui/session-ordering.ts +34 -0
  59. package/src/ui/status-bar-model.ts +67 -39
  60. package/src/update/version-check.ts +67 -0
@@ -1,6 +1,7 @@
1
1
  import type { SnippetRecord } from '../../state/types'
2
2
 
3
3
  import { filterSnippets } from '../../state/selectors'
4
+ import { useModalHelp } from '../keymap-context'
4
5
  import { theme } from '../theme'
5
6
  import { uiTokens } from '../ui-tokens'
6
7
  import { ListItem } from './list-item'
@@ -23,11 +24,12 @@ function truncateContent(content: string): string {
23
24
 
24
25
  export function SnippetPickerModal({ filter, selectedIndex, snippets }: SnippetPickerModalProps) {
25
26
  const filtered = filterSnippets(snippets, filter)
27
+ const help = useModalHelp('modal.snippet-picker')
26
28
 
27
29
  return (
28
30
  <ModalShell
29
31
  title="Snippets"
30
- help="j/k move, Enter send, n new, e edit, d delete, / filter, Esc cancel."
32
+ help={help}
31
33
  width={uiTokens.modalWidth.xl}
32
34
  footer={<ModalFilterBar filter={filter} />}
33
35
  >
@@ -1,6 +1,8 @@
1
1
  import type { AppState } from '../../state/types'
2
2
 
3
+ import { version as APP_VERSION } from '../../../package.json'
3
4
  import { useAppStore } from '../../state/app-store'
5
+ import { useKeymap } from '../keymap-context'
4
6
  import { getStatusBarModel } from '../status-bar-model'
5
7
  import { theme } from '../theme'
6
8
 
@@ -43,7 +45,8 @@ function getModeLabel(focusMode: AppState['focusMode']): string {
43
45
  export function StatusBar() {
44
46
  const state = useAppStore((s) => s)
45
47
  const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId)
46
- const model = getStatusBarModel(state, activeTab)
48
+ const config = useKeymap()
49
+ const model = getStatusBarModel(state, activeTab, config)
47
50
 
48
51
  return (
49
52
  <box
@@ -60,8 +63,9 @@ export function StatusBar() {
60
63
  <text> </text>
61
64
  <text fg={theme.text}>{model.left}</text>
62
65
  </box>
63
- <box width="100%">
66
+ <box width="100%" flexDirection="row" justifyContent="space-between">
64
67
  <text fg={theme.textMuted}>{model.right}</text>
68
+ <text fg={theme.dim}>v{APP_VERSION}</text>
65
69
  </box>
66
70
  </box>
67
71
  )
@@ -1,7 +1,6 @@
1
- import { useEffect, useState } from 'react'
2
-
3
1
  import type { TabSession } from '../../state/types'
4
2
 
3
+ import { useBusySpinner } from '../hooks/use-busy-spinner'
5
4
  import { theme } from '../theme'
6
5
 
7
6
  interface TabItemProps {
@@ -28,9 +27,6 @@ function getStatusColor(status: TabSession['status']): string {
28
27
  }
29
28
  }
30
29
 
31
- const BUSY_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
32
- const BUSY_FRAME_INTERVAL_MS = 80
33
-
34
30
  function getIndicator(active: boolean, focused: boolean, inLayout: boolean): string {
35
31
  if (active) {
36
32
  return focused ? '›' : '•'
@@ -48,16 +44,8 @@ function getIndicatorColor(active: boolean, focused: boolean, inLayout: boolean)
48
44
  }
49
45
 
50
46
  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>
47
+ const frame = useBusySpinner()
48
+ return <text fg={theme.accent}>{frame} busy</text>
61
49
  }
62
50
 
63
51
  function ActivityIndicator({ isFocusedInput, tab }: { tab: TabSession; isFocusedInput: boolean }) {
@@ -1,3 +1,4 @@
1
+ import { useModalHelp } from '../keymap-context'
1
2
  import { theme } from '../theme'
2
3
  import { THEME_IDS, type ThemeId, THEMES } from '../themes'
3
4
  import { uiTokens } from '../ui-tokens'
@@ -10,13 +11,9 @@ interface ThemePickerModalProps {
10
11
  }
11
12
 
12
13
  export function ThemePickerModal({ currentThemeId, selectedIndex }: ThemePickerModalProps) {
14
+ const help = useModalHelp('modal.theme-picker')
13
15
  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
- >
16
+ <ModalShell title="Select theme" help={help} width={uiTokens.modalWidth.md} listGap={0}>
20
17
  {THEME_IDS.map((id, index) => {
21
18
  const entry = THEMES[id]
22
19
  const active = index === selectedIndex
@@ -0,0 +1,42 @@
1
+ import { useModalHelp } from '../keymap-context'
2
+ import { theme } from '../theme'
3
+ import { uiTokens } from '../ui-tokens'
4
+ import { ListItem } from './list-item'
5
+ import { ModalShell } from './modal-shell'
6
+
7
+ interface UpdateAvailableModalProps {
8
+ currentVersion: string
9
+ latestVersion: string
10
+ selectedIndex: number
11
+ }
12
+
13
+ const OPTIONS: { label: string }[] = [
14
+ { label: 'Yes, update now' },
15
+ { label: 'No, skip this version' },
16
+ ]
17
+
18
+ export function UpdateAvailableModal({
19
+ currentVersion,
20
+ latestVersion,
21
+ selectedIndex,
22
+ }: UpdateAvailableModalProps) {
23
+ const modalHelp = useModalHelp('modal.update-available')
24
+ const help = `${currentVersion} → ${latestVersion} ${modalHelp}`
25
+ return (
26
+ <ModalShell title="Update available" help={help} width={uiTokens.modalWidth.md} listGap={1}>
27
+ <box flexDirection="row" gap={1} marginTop={1}>
28
+ {OPTIONS.map((option, index) => {
29
+ const active = index === selectedIndex
30
+ return (
31
+ <ListItem
32
+ key={option.label}
33
+ active={active}
34
+ direction="row"
35
+ title={<text fg={active ? theme.text : theme.textMuted}>{option.label}</text>}
36
+ />
37
+ )
38
+ })}
39
+ </box>
40
+ </ModalShell>
41
+ )
42
+ }
@@ -0,0 +1,18 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ export const BUSY_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
4
+ export const BUSY_FRAME_INTERVAL_MS = 80
5
+
6
+ export function useBusySpinner(enabled = true): string {
7
+ const [frame, setFrame] = useState(0)
8
+
9
+ useEffect(() => {
10
+ if (!enabled) return
11
+ const interval = setInterval(() => {
12
+ setFrame((prev) => (prev + 1) % BUSY_FRAMES.length)
13
+ }, BUSY_FRAME_INTERVAL_MS)
14
+ return () => clearInterval(interval)
15
+ }, [enabled])
16
+
17
+ return BUSY_FRAMES[frame] ?? BUSY_FRAMES[0] ?? ''
18
+ }
@@ -0,0 +1,39 @@
1
+ import {
2
+ getDefaultKeymapConfig,
3
+ type ModeId,
4
+ type ResolvedKeymapConfig,
5
+ } from '@brimveyn/aimux-config'
6
+ import { createContext, useContext, useMemo } from 'react'
7
+
8
+ import { describeBindings } from '../input/keymap/describe-bindings'
9
+
10
+ const FALLBACK_CONFIG = getDefaultKeymapConfig()
11
+
12
+ export const KeymapContext = createContext<ResolvedKeymapConfig>(FALLBACK_CONFIG)
13
+
14
+ export function useKeymap(): ResolvedKeymapConfig {
15
+ return useContext(KeymapContext)
16
+ }
17
+
18
+ const HELP_JOINER = ' · '
19
+ const MAX_HELP_ENTRIES = 5
20
+
21
+ /** Build a single-line help string for a mode (used in modal headers and status bar). */
22
+ export function buildHintText(
23
+ config: ResolvedKeymapConfig,
24
+ modeId: ModeId,
25
+ limit: number = MAX_HELP_ENTRIES
26
+ ): string {
27
+ const bindings = describeBindings(config, modeId, {
28
+ dedupeByDescription: true,
29
+ withDescriptionOnly: true,
30
+ })
31
+ if (bindings.length === 0) return ''
32
+ const trimmed = bindings.slice(0, limit)
33
+ return trimmed.map((b) => `${b.keysDisplay} ${b.description ?? ''}`.trim()).join(HELP_JOINER)
34
+ }
35
+
36
+ export function useModalHelp(modeId: ModeId, limit?: number): string {
37
+ const config = useKeymap()
38
+ return useMemo(() => buildHintText(config, modeId, limit), [config, modeId, limit])
39
+ }
package/src/ui/root.tsx CHANGED
@@ -11,6 +11,8 @@ import { GitCommitModal } from './components/git-commit-modal'
11
11
  import { GitView } from './components/git-view'
12
12
  import { HelpModal } from './components/help-modal'
13
13
  import { NewTabModal } from './components/new-tab-modal'
14
+ import { PendingChordOverlay } from './components/pending-chord-overlay'
15
+ import { SessionBar } from './components/session-bar'
14
16
  import { SessionNameModal } from './components/session-name-modal'
15
17
  import { SessionPickerModal } from './components/session-picker-modal'
16
18
  import { Sidebar } from './components/sidebar'
@@ -20,6 +22,7 @@ import { SplitLayout } from './components/split-layout'
20
22
  import { StatusBar } from './components/status-bar'
21
23
  import { TerminalPane } from './components/terminal-pane'
22
24
  import { ThemePickerModal } from './components/theme-picker-modal'
25
+ import { UpdateAvailableModal } from './components/update-available-modal'
23
26
  import { theme } from './theme'
24
27
 
25
28
  function getCreateSessionFields(modal: ModalState) {
@@ -132,6 +135,14 @@ function renderModal(
132
135
  return (
133
136
  <ThemePickerModal selectedIndex={modal.selectedIndex} currentThemeId={options.themeId} />
134
137
  )
138
+ case 'update-available':
139
+ return (
140
+ <UpdateAvailableModal
141
+ selectedIndex={modal.selectedIndex}
142
+ currentVersion={modal.currentVersion}
143
+ latestVersion={modal.latestVersion}
144
+ />
145
+ )
135
146
  case 'help':
136
147
  return <HelpModal />
137
148
  case 'git-commit': {
@@ -203,6 +214,7 @@ export function RootView({
203
214
  const customCommands = useAppStore((s) => s.customCommands)
204
215
  const sessions = useAppStore((s) => s.sessions)
205
216
  const currentSessionId = useAppStore((s) => s.currentSessionId)
217
+ const sessionBarPosition = useAppStore((s) => s.sessionBar.position)
206
218
 
207
219
  const activeTab = tabs.find((tab) => tab.id === activeTabId)
208
220
  const activeTree = activeTabId ? getTreeForTab(layoutTrees, tabGroupMap, activeTabId) : null
@@ -216,6 +228,7 @@ export function RootView({
216
228
  <box flexDirection="column" width="100%" height="100%" backgroundColor={theme.background}>
217
229
  <GitView />
218
230
  <StatusBar />
231
+ <PendingChordOverlay />
219
232
  {renderModal(modal, {
220
233
  createSessionFields,
221
234
  currentSessionId,
@@ -232,6 +245,7 @@ export function RootView({
232
245
 
233
246
  return (
234
247
  <box flexDirection="column" width="100%" height="100%" backgroundColor={theme.background}>
248
+ {sessionBarPosition === 'top' && <SessionBar />}
235
249
  <box flexDirection="row" gap={0} padding={0} flexGrow={1}>
236
250
  <Sidebar onTabActivate={onPaneActivate} />
237
251
  {activeTree && activeTree.type === 'split' ? (
@@ -279,7 +293,9 @@ export function RootView({
279
293
  />
280
294
  )}
281
295
  </box>
296
+ {sessionBarPosition === 'bottom' && <SessionBar />}
282
297
  <StatusBar />
298
+ <PendingChordOverlay />
283
299
  {renderModal(modal, {
284
300
  createSessionFields,
285
301
  currentSessionId,
@@ -0,0 +1,34 @@
1
+ import type { SessionRecord } from '../state/types'
2
+
3
+ /**
4
+ * Return sessions in user-facing display order: persisted `order` ascending,
5
+ * with any missing `order` falling back to `createdAt` ascending.
6
+ */
7
+ export function orderSessionsForDisplay(sessions: SessionRecord[]): SessionRecord[] {
8
+ return sessions.slice().sort((a, b) => {
9
+ const ao = a.order ?? Number.MAX_SAFE_INTEGER
10
+ const bo = b.order ?? Number.MAX_SAFE_INTEGER
11
+ if (ao !== bo) return ao - bo
12
+ return a.createdAt.localeCompare(b.createdAt)
13
+ })
14
+ }
15
+
16
+ /**
17
+ * Move `moveId` to the slot currently held by `intoPositionOfId`, shifting the
18
+ * displaced id in the opposite direction. Pure; returns a new array. Returns
19
+ * the input unchanged if either id is missing or both refer to the same slot.
20
+ */
21
+ export function moveIdToIdPosition(
22
+ ids: string[],
23
+ moveId: string,
24
+ intoPositionOfId: string
25
+ ): string[] {
26
+ if (moveId === intoPositionOfId) return ids
27
+ const from = ids.indexOf(moveId)
28
+ const to = ids.indexOf(intoPositionOfId)
29
+ if (from < 0 || to < 0) return ids
30
+ const next = ids.slice()
31
+ next.splice(from, 1)
32
+ next.splice(to, 0, moveId)
33
+ return next
34
+ }
@@ -1,5 +1,8 @@
1
+ import type { ModeId, ResolvedKeymapConfig } from '@brimveyn/aimux-config'
2
+
1
3
  import type { AppState, TabSession } from '../state/types'
2
4
 
5
+ import { buildHintText } from './keymap-context'
3
6
  import { abbreviatePath } from './path-format'
4
7
 
5
8
  export interface StatusBarModel {
@@ -8,6 +11,7 @@ export interface StatusBarModel {
8
11
  }
9
12
 
10
13
  const MAX_TAB_LABEL_LENGTH = 24
14
+ const HINT_LIMIT = 6
11
15
 
12
16
  function truncateLabel(label: string): string {
13
17
  if (label.length <= MAX_TAB_LABEL_LENGTH) {
@@ -25,39 +29,15 @@ function getActiveTabLabel(tab?: TabSession): string {
25
29
  return `${truncateLabel(tab.title)} (${tab.status})`
26
30
  }
27
31
 
28
- function getNavigationHint(activeTab?: TabSession): string {
29
- if (!activeTab) {
30
- return 'Ctrl+g sessions Ctrl+n new Ctrl+b toggle Ctrl+h/l resize ? help'
31
- }
32
-
33
- if (activeTab.status === 'disconnected') {
34
- return 'Ctrl+r restart restored tab Ctrl+w close i focus'
35
- }
36
-
37
- return 'Ctrl+g sessions j/k move Shift+J/K reorder Ctrl+r restart Ctrl+w close i focus ? help'
38
- }
39
-
40
- function getInputHint(activeTab?: TabSession): string {
41
- if (!activeTab) {
42
- return 'Ctrl+n new no active tab to focus'
43
- }
44
-
45
- if (activeTab.status === 'disconnected') {
46
- return 'Ctrl+z unfocus Ctrl+w layout Ctrl+r restart'
47
- }
48
-
49
- return 'Ctrl+z unfocus Ctrl+w layout typing goes to active tab'
32
+ function hintForMode(config: ResolvedKeymapConfig, modeId: ModeId): string {
33
+ return buildHintText(config, modeId, HINT_LIMIT)
50
34
  }
51
35
 
52
- function getGitHint(modalType: AppState['modal']['type']): string {
53
- if (modalType === 'git-commit') {
54
- return 'Tab switch field Enter newline Ctrl+Enter commit Esc cancel'
55
- }
56
-
57
- return 'j/k file Ctrl+d/u page a stage d unstage/delete c commit p push Esc exit'
58
- }
59
-
60
- export function getStatusBarModel(state: AppState, activeTab?: TabSession): StatusBarModel {
36
+ export function getStatusBarModel(
37
+ state: AppState,
38
+ activeTab: TabSession | undefined,
39
+ config: ResolvedKeymapConfig
40
+ ): StatusBarModel {
61
41
  const currentSession = state.currentSessionId
62
42
  ? state.sessions.find((session) => session.id === state.currentSessionId)
63
43
  : undefined
@@ -73,33 +53,81 @@ export function getStatusBarModel(state: AppState, activeTab?: TabSession): Stat
73
53
  case 'terminal-input':
74
54
  return {
75
55
  left: `${getActiveTabLabel(activeTab)} ${sessionIcon} ${sessionLabel}`,
76
- right: getInputHint(activeTab),
56
+ right: hintForMode(config, 'terminal-input'),
77
57
  }
78
- case 'modal':
58
+ case 'modal': {
59
+ const modalMode = deriveModalModeId(state.modal.type)
79
60
  return {
80
61
  left: `${sessionIcon} ${sessionLabel}`,
81
- right: 'j/k move Enter confirm n/r/d actions Esc cancel',
62
+ right: modalMode ? hintForMode(config, modalMode) : '',
82
63
  }
64
+ }
83
65
  case 'layout':
84
66
  return {
85
67
  left: `${getActiveTabLabel(activeTab)} ${sessionIcon} ${sessionLabel}`,
86
- right: 'h/j/k/l focus |/- split H/L resize q close Esc cancel',
68
+ right: hintForMode(config, 'layout'),
87
69
  }
88
70
  case 'git':
89
71
  return {
90
72
  left: `${sessionIcon} ${sessionLabel}`,
91
- right: getGitHint(state.modal.type),
73
+ right: hintForMode(config, 'git-mode'),
92
74
  }
93
- case 'command-edit':
75
+ case 'command-edit': {
76
+ const commandEditMode = deriveCommandEditModeId(state.modal.type)
94
77
  return {
95
78
  left: `${sessionIcon} ${sessionLabel}`,
96
- right: state.modal.type === 'git-commit' ? getGitHint('git-commit') : 'Esc cancel',
79
+ right: commandEditMode ? hintForMode(config, commandEditMode) : '',
97
80
  }
81
+ }
98
82
  case 'navigation':
99
83
  default:
100
84
  return {
101
85
  left: `${sessionIcon} ${sessionLabel} ${getActiveTabLabel(activeTab)}`,
102
- right: getNavigationHint(activeTab),
86
+ right: hintForMode(config, 'navigation'),
103
87
  }
104
88
  }
105
89
  }
90
+
91
+ function deriveModalModeId(modalType: AppState['modal']['type']): ModeId | null {
92
+ switch (modalType) {
93
+ case 'help':
94
+ return 'modal.help'
95
+ case 'new-tab':
96
+ return 'modal.new-tab'
97
+ case 'session-picker':
98
+ return 'modal.session-picker'
99
+ case 'snippet-picker':
100
+ return 'modal.snippet-picker'
101
+ case 'split-picker':
102
+ return 'modal.split-picker'
103
+ case 'theme-picker':
104
+ return 'modal.theme-picker'
105
+ case 'update-available':
106
+ return 'modal.update-available'
107
+ default:
108
+ return null
109
+ }
110
+ }
111
+
112
+ function deriveCommandEditModeId(modalType: AppState['modal']['type']): ModeId | null {
113
+ switch (modalType) {
114
+ case 'create-session':
115
+ return 'modal.create-session'
116
+ case 'git-commit':
117
+ return 'modal.git-commit'
118
+ case 'new-tab':
119
+ return 'modal.new-tab.command-edit'
120
+ case 'rename-tab':
121
+ return 'modal.rename-tab'
122
+ case 'session-name':
123
+ return 'modal.session-name'
124
+ case 'session-picker':
125
+ return 'modal.session-picker.filtering'
126
+ case 'snippet-editor':
127
+ return 'modal.snippet-editor'
128
+ case 'snippet-picker':
129
+ return 'modal.snippet-picker.filtering'
130
+ default:
131
+ return null
132
+ }
133
+ }
@@ -0,0 +1,67 @@
1
+ import { logDebug } from '../debug/input-log'
2
+
3
+ const NPM_REGISTRY_BASE = 'https://registry.npmjs.org'
4
+
5
+ export async function getCurrentPackageVersion(): Promise<string> {
6
+ const { version } = await import('../../package.json')
7
+ return version
8
+ }
9
+
10
+ export async function fetchLatestNpmVersion(packageName: string): Promise<string | null> {
11
+ const debugOverride = process.env.AIMUX_DEBUG_UPDATE_LATEST
12
+ if (debugOverride) {
13
+ return debugOverride
14
+ }
15
+
16
+ try {
17
+ const url = `${NPM_REGISTRY_BASE}/${encodeURIComponent(packageName).replace('%40', '@')}/latest`
18
+ const controller = new AbortController()
19
+ const timer = setTimeout(() => controller.abort(), 5_000)
20
+ const res = await fetch(url, { signal: controller.signal })
21
+ clearTimeout(timer)
22
+ if (!res.ok) {
23
+ logDebug('update.fetchLatest.nonOk', { packageName, status: res.status })
24
+ return null
25
+ }
26
+ const data = (await res.json()) as { version?: unknown }
27
+ if (typeof data.version !== 'string' || data.version.length === 0) {
28
+ return null
29
+ }
30
+ return data.version
31
+ } catch (error) {
32
+ logDebug('update.fetchLatest.error', {
33
+ error: error instanceof Error ? error.message : String(error),
34
+ packageName,
35
+ })
36
+ return null
37
+ }
38
+ }
39
+
40
+ function parseSemver(version: string): number[] | null {
41
+ const trimmed = version.trim().replace(/^v/, '')
42
+ const core = trimmed.split(/[-+]/)[0] ?? ''
43
+ const parts = core.split('.')
44
+ if (parts.length === 0) return null
45
+ const numeric: number[] = []
46
+ for (const part of parts) {
47
+ const n = Number.parseInt(part, 10)
48
+ if (!Number.isFinite(n) || n < 0) return null
49
+ numeric.push(n)
50
+ }
51
+ return numeric
52
+ }
53
+
54
+ export function isNewerVersion(latest: string, current: string): boolean {
55
+ const a = parseSemver(latest)
56
+ const b = parseSemver(current)
57
+ if (!a || !b) return false
58
+
59
+ const len = Math.max(a.length, b.length)
60
+ for (let i = 0; i < len; i++) {
61
+ const av = a[i] ?? 0
62
+ const bv = b[i] ?? 0
63
+ if (av > bv) return true
64
+ if (av < bv) return false
65
+ }
66
+ return false
67
+ }