@brimveyn/aimux 1.9.9 → 1.10.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.
@@ -12,6 +12,7 @@ import { getTerminalManagerSocketPath } from '../daemon/runtime-paths'
12
12
  import { logDebug } from '../debug/input-log'
13
13
  import {
14
14
  encodeManagerMessage,
15
+ MANAGER_PROTOCOL_BROADCAST_GATE_VERSION,
15
16
  MANAGER_PROTOCOL_MIN_VERSION,
16
17
  MANAGER_PROTOCOL_VERSION,
17
18
  type ManagerAttachResult,
@@ -378,6 +379,30 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
378
379
  })
379
380
  }
380
381
 
382
+ /**
383
+ * Tell the TM whether to bother snapshotting and broadcasting renders.
384
+ * No-ops on TMs that negotiated a protocol version without the feature
385
+ * (older builds): the worst case is the daemon keeps receiving renders it
386
+ * doesn't strictly need, which matches the pre-fix behaviour.
387
+ */
388
+ async setBroadcastEnabled(enabled: boolean): Promise<void> {
389
+ if (
390
+ this.selectedProtocolVersion === null ||
391
+ this.selectedProtocolVersion < MANAGER_PROTOCOL_BROADCAST_GATE_VERSION
392
+ ) {
393
+ logDebug('managerClient.setBroadcastEnabled.skipped', {
394
+ enabled,
395
+ selectedVersion: this.selectedProtocolVersion,
396
+ })
397
+ return
398
+ }
399
+ await this.sendExpectOk({
400
+ id: crypto.randomUUID(),
401
+ payload: { enabled },
402
+ type: 'setBroadcastEnabled',
403
+ })
404
+ }
405
+
381
406
  destroy(): void {
382
407
  this.resetConnection('Terminal manager client destroyed')
383
408
  }
@@ -52,6 +52,49 @@ export async function runTerminalManager(): Promise<void> {
52
52
  const sockets = new Set<Socket>()
53
53
  const negotiatedVersions = new Map<Socket, number>()
54
54
 
55
+ /**
56
+ * Auto-exit when fully idle: no clients connected AND no live PTY sessions.
57
+ * Prevents the "zombie TM" pattern where a stale terminal-manager from an
58
+ * older install keeps consuming CPU after the user has closed everything.
59
+ * The grace window allows brief reconnects (e.g. daemon restart during
60
+ * `aimux update`) without killing the process.
61
+ *
62
+ * Set AIMUX_TM_IDLE_EXIT_MS=0 to disable.
63
+ */
64
+ const idleExitMs = (() => {
65
+ const raw = process.env.AIMUX_TM_IDLE_EXIT_MS
66
+ if (raw === undefined) return 60_000
67
+ const parsed = Number(raw)
68
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 60_000
69
+ })()
70
+ let idleExitTimer: ReturnType<typeof setTimeout> | null = null
71
+ const scheduleIdleExitIfApplicable = (): void => {
72
+ if (idleExitMs === 0) return
73
+ if (idleExitTimer !== null) return
74
+ if (sockets.size > 0) return
75
+ if (sessionManager.hasAnySessions()) return
76
+ logDebug('terminalManager.idleExit.schedule', { idleExitMs })
77
+ idleExitTimer = setTimeout(() => {
78
+ idleExitTimer = null
79
+ if (sockets.size > 0 || sessionManager.hasAnySessions()) {
80
+ logDebug('terminalManager.idleExit.cancelled')
81
+ return
82
+ }
83
+ logDebug('terminalManager.idleExit.fire')
84
+ sessionManager.disposeAll()
85
+ // Graceful: drop the listening socket so the file is unlinked, matching
86
+ // SIGTERM/SIGINT shutdown. Bail-out timer guards against close() hanging
87
+ // on a bad socket.
88
+ server.close(() => process.exit(0))
89
+ setTimeout(() => process.exit(0), 1_000).unref?.()
90
+ }, idleExitMs)
91
+ idleExitTimer.unref?.()
92
+ }
93
+
94
+ // A session can exit naturally (PTY child finishes) while no client is
95
+ // attached — re-evaluate idle state in that case too.
96
+ sessionManager.on('exit', () => scheduleIdleExitIfApplicable())
97
+
55
98
  sessionManager.on('render', (sessionId, tabId, viewport, terminalModes) => {
56
99
  const event: ManagerEvent = {
57
100
  payload: { sessionId, tabId, terminalModes, viewport },
@@ -233,6 +276,11 @@ export async function runTerminalManager(): Promise<void> {
233
276
  case 'ping':
234
277
  sendOk(socket, message.id)
235
278
  break
279
+ case 'setBroadcastEnabled':
280
+ requireNegotiatedVersion(socket, negotiatedVersions)
281
+ sessionManager.setBroadcastEnabled(message.payload.enabled)
282
+ sendOk(socket, message.id)
283
+ break
236
284
  }
237
285
  } catch (error) {
238
286
  const errorMessage = error instanceof Error ? error.message : String(error)
@@ -256,11 +304,13 @@ export async function runTerminalManager(): Promise<void> {
256
304
  logDebug('terminalManager.client.close')
257
305
  sockets.delete(socket)
258
306
  negotiatedVersions.delete(socket)
307
+ scheduleIdleExitIfApplicable()
259
308
  })
260
309
  socket.on('error', () => {
261
310
  logDebug('terminalManager.client.error')
262
311
  sockets.delete(socket)
263
312
  negotiatedVersions.delete(socket)
313
+ scheduleIdleExitIfApplicable()
264
314
  })
265
315
  })
266
316
 
@@ -270,8 +320,12 @@ export async function runTerminalManager(): Promise<void> {
270
320
  })
271
321
  tightenSocketPermissions(socketPath)
272
322
 
323
+ // First idle eval: if no client connects within idleExitMs of startup, exit.
324
+ scheduleIdleExitIfApplicable()
325
+
273
326
  const gracefulShutdown = (signal: string) => {
274
327
  logDebug(`terminalManager.${signal}`)
328
+ if (idleExitTimer) clearTimeout(idleExitTimer)
275
329
  sessionManager.disposeAll()
276
330
  server.close()
277
331
  process.exit(0)
@@ -26,6 +26,8 @@ interface SplitLayoutProps {
26
26
  onTerminalMouseEvent: (event: OtuiMouseEvent, origin: TerminalContentOrigin) => void
27
27
  onTerminalScrollEvent: (event: OtuiMouseEvent) => void
28
28
  onTerminalClick?: (event: OtuiMouseEvent, origin: TerminalContentOrigin, tabId?: string) => void
29
+ onTerminalDrag?: (event: OtuiMouseEvent, origin: TerminalContentOrigin, tabId?: string) => boolean
30
+ onTerminalMouseUp?: (event: OtuiMouseEvent) => boolean
29
31
  onPaneActivate?: (tabId: string) => void
30
32
  onSplitResize?: (tabId: string, ratio: number, axis: SplitDirection) => void
31
33
  onSeparatorDragStart?: (info: {
@@ -54,7 +56,9 @@ export function SplitLayout({
54
56
  onSeparatorDragStart,
55
57
  onSplitResize,
56
58
  onTerminalClick,
59
+ onTerminalDrag,
57
60
  onTerminalMouseEvent,
61
+ onTerminalMouseUp,
58
62
  onTerminalScrollEvent,
59
63
  tabs,
60
64
  }: SplitLayoutProps) {
@@ -92,6 +96,8 @@ export function SplitLayout({
92
96
  onTerminalMouseEvent={onTerminalMouseEvent}
93
97
  onTerminalScrollEvent={onTerminalScrollEvent}
94
98
  onTerminalClick={onTerminalClick}
99
+ onTerminalDrag={onTerminalDrag}
100
+ onTerminalMouseUp={onTerminalMouseUp}
95
101
  onPaneActivate={onPaneActivate}
96
102
  onSeparatorDrag={onSeparatorDrag}
97
103
  onSeparatorDragEnd={onSeparatorDragEnd}
@@ -123,6 +129,8 @@ export function SplitLayout({
123
129
  onTerminalMouseEvent={onTerminalMouseEvent}
124
130
  onTerminalScrollEvent={onTerminalScrollEvent}
125
131
  onTerminalClick={onTerminalClick}
132
+ onTerminalDrag={onTerminalDrag}
133
+ onTerminalMouseUp={onTerminalMouseUp}
126
134
  onPaneActivate={onPaneActivate}
127
135
  onSplitResize={onSplitResize}
128
136
  onSeparatorDragStart={onSeparatorDragStart}
@@ -163,6 +171,8 @@ export function SplitLayout({
163
171
  onTerminalMouseEvent={onTerminalMouseEvent}
164
172
  onTerminalScrollEvent={onTerminalScrollEvent}
165
173
  onTerminalClick={onTerminalClick}
174
+ onTerminalDrag={onTerminalDrag}
175
+ onTerminalMouseUp={onTerminalMouseUp}
166
176
  onPaneActivate={onPaneActivate}
167
177
  onSplitResize={onSplitResize}
168
178
  onSeparatorDragStart={onSeparatorDragStart}
@@ -22,6 +22,8 @@ interface TerminalPaneProps {
22
22
  onTerminalMouseEvent: (event: OtuiMouseEvent, origin: TerminalContentOrigin) => void
23
23
  onTerminalScrollEvent: (event: OtuiMouseEvent) => void
24
24
  onTerminalClick?: (event: OtuiMouseEvent, origin: TerminalContentOrigin, tabId?: string) => void
25
+ onTerminalDrag?: (event: OtuiMouseEvent, origin: TerminalContentOrigin, tabId?: string) => boolean
26
+ onTerminalMouseUp?: (event: OtuiMouseEvent) => boolean
25
27
  onPaneActivate?: (tabId: string) => void
26
28
  onSeparatorDrag?: (event: OtuiMouseEvent) => boolean
27
29
  onSeparatorDragEnd?: () => void
@@ -47,9 +49,10 @@ function getTitle(
47
49
  return `${tab.title} · ${tab.status}`
48
50
  }
49
51
 
50
- function getBorderColor(isActive: boolean, _focusMode: TerminalPaneProps['focusMode']): string {
52
+ function getBorderColor(isActive: boolean, focusMode: TerminalPaneProps['focusMode']): string {
51
53
  const t = getCurrentTheme()
52
- return isActive ? t.borderActive : t.border
54
+ if (!isActive) return t.border
55
+ return focusMode === 'terminal-input' ? t.accent : t.primary
53
56
  }
54
57
 
55
58
  function renderSpan(span: TerminalSpan, key: string): ReactNode {
@@ -111,7 +114,9 @@ export function TerminalPane({
111
114
  onSeparatorDrag,
112
115
  onSeparatorDragEnd,
113
116
  onTerminalClick,
117
+ onTerminalDrag,
114
118
  onTerminalMouseEvent,
119
+ onTerminalMouseUp,
115
120
  onTerminalScrollEvent,
116
121
  tab,
117
122
  tabId,
@@ -168,11 +173,20 @@ export function TerminalPane({
168
173
  y: event.y,
169
174
  })
170
175
  }
171
- if (event.type === 'drag' && onSeparatorDrag?.(event)) {
172
- event.preventDefault()
173
- return
176
+ if (event.type === 'drag') {
177
+ if (onTerminalDrag?.(event, contentOrigin, tabId)) {
178
+ event.preventDefault()
179
+ return
180
+ }
181
+ if (onSeparatorDrag?.(event)) {
182
+ event.preventDefault()
183
+ return
184
+ }
174
185
  }
175
186
  if (event.type === 'up') {
187
+ if (onTerminalMouseUp?.(event)) {
188
+ event.preventDefault()
189
+ }
176
190
  onSeparatorDragEnd?.()
177
191
  }
178
192
  if (tabId && onPaneActivate && event.type === 'down') {
@@ -4,7 +4,7 @@ import type { ThemeId } from '../../../themes'
4
4
 
5
5
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
6
6
  import { filterThemeIds, themeDisplayName } from '../../../filter-themes'
7
- import { useTheme, useTransparent } from '../../../theme'
7
+ import { useMode, useTheme, useTransparent } from '../../../theme'
8
8
  import { uiTokens } from '../../../ui-tokens'
9
9
  import { Picker, type PickerItem } from '../shared/picker'
10
10
 
@@ -28,6 +28,7 @@ export function ThemePickerModal({
28
28
  }: ThemePickerModalProps) {
29
29
  const t = useTheme()
30
30
  const transparent = useTransparent()
31
+ const mode = useMode()
31
32
  const filtered = useMemo(() => filterThemeIds(filter), [filter])
32
33
 
33
34
  useLayoutEffect(() => {
@@ -63,6 +64,7 @@ export function ThemePickerModal({
63
64
  {filtered.length === 0 ? '' : ` ${effectiveIndex + 1} / ${filtered.length}`}
64
65
  </text>
65
66
  <text fg={t.textMuted}>{` transparent: ${transparent ? 'on' : 'off'} (ctrl-t)`}</text>
67
+ <text fg={t.textMuted}>{` mode: ${mode} (ctrl-l)`}</text>
66
68
  </box>
67
69
  }
68
70
  items={items}
package/src/ui/root.tsx CHANGED
@@ -206,6 +206,8 @@ interface RootViewProps {
206
206
  onTerminalMouseEvent: (event: MouseEvent, origin: TerminalContentOrigin) => void
207
207
  onTerminalScrollEvent: (event: MouseEvent) => void
208
208
  onTerminalClick?: (event: MouseEvent, origin: TerminalContentOrigin, tabId?: string) => void
209
+ onTerminalDrag?: (event: MouseEvent, origin: TerminalContentOrigin, tabId?: string) => boolean
210
+ onTerminalMouseUp?: (event: MouseEvent) => boolean
209
211
  onPaneActivate?: (tabId: string) => void
210
212
  onSplitResize?: (tabId: string, ratio: number, axis: SplitDirection) => void
211
213
  onSidebarResizeStart?: (info: { initialWidth: number; screenStart: number }) => void
@@ -244,7 +246,9 @@ export function RootView({
244
246
  onSidebarResizeStart,
245
247
  onSplitResize,
246
248
  onTerminalClick,
249
+ onTerminalDrag,
247
250
  onTerminalMouseEvent,
251
+ onTerminalMouseUp,
248
252
  onTerminalScrollEvent,
249
253
  terminalCols,
250
254
  terminalRows,
@@ -312,7 +316,11 @@ export function RootView({
312
316
  event.stopPropagation()
313
317
  }
314
318
  }}
315
- onMouseUp={() => {
319
+ onMouseUp={(event) => {
320
+ // Catch releases that land outside any TerminalPane (sidebar, gap,
321
+ // status bar, …) so an in-flight multi-click drag — and its
322
+ // auto-scroll interval — is always finalised.
323
+ onTerminalMouseUp?.(event)
316
324
  onSeparatorDragEnd?.()
317
325
  }}
318
326
  >
@@ -349,6 +357,8 @@ export function RootView({
349
357
  onTerminalMouseEvent={onTerminalMouseEvent}
350
358
  onTerminalScrollEvent={onTerminalScrollEvent}
351
359
  onTerminalClick={onTerminalClick}
360
+ onTerminalDrag={onTerminalDrag}
361
+ onTerminalMouseUp={onTerminalMouseUp}
352
362
  onPaneActivate={onPaneActivate}
353
363
  onSplitResize={onSplitResize}
354
364
  onSeparatorDragStart={onSeparatorDragStart}
@@ -373,6 +383,8 @@ export function RootView({
373
383
  onTerminalMouseEvent={onTerminalMouseEvent}
374
384
  onTerminalScrollEvent={onTerminalScrollEvent}
375
385
  onTerminalClick={onTerminalClick}
386
+ onTerminalDrag={onTerminalDrag}
387
+ onTerminalMouseUp={onTerminalMouseUp}
376
388
  onPaneActivate={onPaneActivate}
377
389
  />
378
390
  )}
@@ -73,6 +73,10 @@ export function useTransparent(): boolean {
73
73
  return useStore(themeStore, (s) => s.transparent)
74
74
  }
75
75
 
76
+ export function useMode(): ThemeMode {
77
+ return useStore(themeStore, (s) => s.mode)
78
+ }
79
+
76
80
  export function getTransparent(): boolean {
77
81
  return themeStore.getState().transparent
78
82
  }
@@ -81,3 +85,17 @@ export function setTransparent(value: boolean): void {
81
85
  if (themeStore.getState().transparent === value) return
82
86
  themeStore.setState({ transparent: value })
83
87
  }
88
+
89
+ /** Subscribe to theme id/mode changes for non-React side-effects. */
90
+ export function subscribeThemeChanges(
91
+ listener: (resolved: ResolvedTuiTheme, mode: ThemeMode) => void
92
+ ): () => void {
93
+ let lastId: ThemeId | null = null
94
+ let lastMode: ThemeMode | null = null
95
+ return themeStore.subscribe((s) => {
96
+ if (s.id === lastId && s.mode === lastMode) return
97
+ lastId = s.id
98
+ lastMode = s.mode
99
+ listener(derive(s.id, s.mode), s.mode)
100
+ })
101
+ }
package/src/ui/theme.ts CHANGED
@@ -11,6 +11,8 @@ export {
11
11
  getTransparent,
12
12
  setMode,
13
13
  setTransparent,
14
+ subscribeThemeChanges,
15
+ useMode,
14
16
  useTheme,
15
17
  useTransparent,
16
18
  } from './theme-store'
package/src/update.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { getDaemonSocketPath } from './daemon/runtime-paths'
2
- import { findIpcDaemonPid } from './platform/daemon-control'
2
+ import { findIpcDaemonPid, findTerminalManagerPid } from './platform/daemon-control'
3
3
  import { runRestartDaemon } from './restart-daemon'
4
4
 
5
5
  const REPO = 'BrimVeyn/aimux'
@@ -59,5 +59,25 @@ export async function runUpdate(): Promise<number> {
59
59
  await runRestartDaemon()
60
60
  }
61
61
 
62
+ // The terminal-manager is intentionally NOT restarted here: doing so kills
63
+ // live AI sessions. The flip side: the running TM keeps executing the
64
+ // previous version's code path, so any TM-side fix (perf, lifecycle) only
65
+ // takes effect after a manual restart. Surface that explicitly so users
66
+ // aren't silently stuck on stale behaviour.
67
+ const tmPid = await findTerminalManagerPid()
68
+ if (tmPid !== null) {
69
+ process.stdout.write(
70
+ [
71
+ '',
72
+ `Note: terminal-manager (pid ${tmPid}) is still running the previous version.`,
73
+ 'TM-side fixes in this update apply to new TMs only — the running one',
74
+ 'keeps its old behaviour until restarted.',
75
+ 'Run `aimux restart-terminal-manager` when you can afford to lose your',
76
+ 'current PTY sessions to upgrade it.',
77
+ '',
78
+ ].join('\n')
79
+ )
80
+ }
81
+
62
82
  return 0
63
83
  }