@brimveyn/aimux 1.12.6 → 1.13.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 (130) hide show
  1. package/README.md +3 -0
  2. package/package.json +4 -3
  3. package/src/app-runtime/auto-commit-driver.ts +8 -4
  4. package/src/app-runtime/auto-commit-ref.ts +1 -1
  5. package/src/app-runtime/backend-attach-runtime.ts +18 -2
  6. package/src/app-runtime/backend-runtime-events.ts +1 -1
  7. package/src/app-runtime/selection-scroll.ts +2 -2
  8. package/src/app-runtime/session-actions.ts +16 -4
  9. package/src/app-runtime/side-effects.ts +538 -83
  10. package/src/app-runtime/snippet-actions.ts +3 -3
  11. package/src/app-runtime/use-auto-commit-driver.ts +7 -4
  12. package/src/app-runtime/use-backend-runtime.ts +3 -3
  13. package/src/app-runtime/use-directory-search.ts +7 -5
  14. package/src/app-runtime/use-mouse-handlers.ts +11 -6
  15. package/src/app-runtime/use-pane-size-report.ts +1 -1
  16. package/src/app-runtime/use-renderer-bindings.ts +6 -5
  17. package/src/app.tsx +12 -7
  18. package/src/auto-commit/headless-commands.ts +4 -6
  19. package/src/daemon/daemon.ts +3 -3
  20. package/src/daemon/runtime-paths.ts +1 -1
  21. package/src/daemon/session-manager.ts +1 -1
  22. package/src/daemon/session-registry.ts +10 -3
  23. package/src/diff-parser/parse-patch-files.ts +18 -18
  24. package/src/git/command-queue.ts +1 -1
  25. package/src/git/divergence.ts +46 -0
  26. package/src/git/git-diff.ts +12 -5
  27. package/src/git/git-poller.ts +33 -11
  28. package/src/git/git-status.ts +14 -3
  29. package/src/git/move-worktree.ts +96 -0
  30. package/src/git/use-repo-discovery.ts +2 -1
  31. package/src/git/worktree-divergence-poller.ts +59 -0
  32. package/src/git/worktree.ts +99 -0
  33. package/src/index.tsx +1 -1
  34. package/src/input/keymap/describe-bindings.ts +27 -9
  35. package/src/input/keymap/key-chord.ts +4 -4
  36. package/src/input/keymap/key-format.ts +2 -2
  37. package/src/input/keymap/sequence-resolver.ts +1 -1
  38. package/src/input/keymap/trie.ts +1 -1
  39. package/src/input/modes/bridge.ts +7 -0
  40. package/src/input/modes/transitions.ts +2 -1
  41. package/src/input/modes/types.ts +11 -1
  42. package/src/input/raw-input-handler.ts +1 -1
  43. package/src/input/terminal-text-extraction.ts +9 -5
  44. package/src/integrations/claude-syntax-overlay.ts +8 -8
  45. package/src/integrations/claude-theme-sync.ts +12 -12
  46. package/src/ipc/protocol.ts +3 -3
  47. package/src/platform/clipboard.ts +4 -2
  48. package/src/platform/worktree-deps.ts +22 -0
  49. package/src/platform/worktree-paths.ts +67 -0
  50. package/src/profile-paths.ts +3 -3
  51. package/src/pty/assistant-status-detection-loop.ts +2 -2
  52. package/src/pty/assistant-status-detector.ts +15 -6
  53. package/src/pty/command-registry.ts +2 -1
  54. package/src/pty/pty-manager.ts +1 -1
  55. package/src/services/ai-usage/adapters/claude.ts +5 -5
  56. package/src/services/ai-usage/adapters/codex.ts +8 -8
  57. package/src/services/ai-usage/cache.ts +2 -2
  58. package/src/services/ai-usage/pace.ts +3 -6
  59. package/src/services/ai-usage/provider.ts +1 -1
  60. package/src/session-backend/bootstrap.ts +2 -2
  61. package/src/session-backend/local-session-backend.ts +11 -11
  62. package/src/session-backend/remote-session-backend.ts +3 -3
  63. package/src/session-backend/types.ts +2 -2
  64. package/src/snippets/expand-variables.ts +7 -7
  65. package/src/snippets/run-shell-var.ts +1 -1
  66. package/src/snippets/trigger-detector.ts +1 -1
  67. package/src/state/ai-usage-store.ts +1 -1
  68. package/src/state/git-tree.ts +8 -4
  69. package/src/state/layout-tree.ts +8 -5
  70. package/src/state/reducers/git-mode-state.ts +44 -14
  71. package/src/state/reducers/git-panel-state.ts +38 -2
  72. package/src/state/reducers/modal-state.ts +355 -21
  73. package/src/state/reducers/session-state.ts +60 -1
  74. package/src/state/reducers/tab-state.ts +151 -85
  75. package/src/state/selectors.ts +6 -4
  76. package/src/state/session-catalog.ts +20 -5
  77. package/src/state/session-persistence.ts +8 -4
  78. package/src/state/session-worktrees.ts +227 -0
  79. package/src/state/store.ts +1 -0
  80. package/src/state/toast-store.ts +95 -0
  81. package/src/state/types.ts +92 -13
  82. package/src/state/validation.ts +25 -2
  83. package/src/terminal-manager/manager-client.ts +15 -13
  84. package/src/ui/components/git/diff-renderer/fold-strip.tsx +2 -1
  85. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
  86. package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
  87. package/src/ui/components/git/diff-renderer/split-view.tsx +6 -4
  88. package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
  89. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
  90. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
  91. package/src/ui/components/git/git-panel.tsx +17 -10
  92. package/src/ui/components/git/git-view.tsx +79 -19
  93. package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
  94. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +5 -4
  95. package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
  96. package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
  97. package/src/ui/components/layout/session-bar.tsx +3 -3
  98. package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
  99. package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
  100. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
  101. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
  102. package/src/ui/components/layout/split-layout.tsx +1 -1
  103. package/src/ui/components/layout/terminal-pane.tsx +43 -40
  104. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  105. package/src/ui/components/modals/app/help-modal.tsx +4 -2
  106. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  107. package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
  108. package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
  109. package/src/ui/components/modals/shared/form.tsx +4 -5
  110. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  111. package/src/ui/components/modals/shared/picker.tsx +28 -5
  112. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +11 -3
  113. package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
  114. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
  115. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
  116. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  117. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  118. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  119. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  120. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  121. package/src/ui/components/primitives/input-field.tsx +1 -1
  122. package/src/ui/components/primitives/list-item.tsx +3 -3
  123. package/src/ui/filter-themes.ts +1 -1
  124. package/src/ui/root.tsx +57 -3
  125. package/src/ui/session-ordering.ts +2 -2
  126. package/src/ui/status-bar-model.ts +14 -7
  127. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  128. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  129. package/src/ui/terminal-graphics/kitty.ts +2 -7
  130. package/src/update/version-check.ts +1 -1
@@ -17,11 +17,15 @@ export function getLineText(line: TerminalLine): string {
17
17
  */
18
18
  export function extractStreamText(
19
19
  lines: TerminalLine[],
20
- startRow: number,
21
- startCol: number,
22
- endRow: number,
23
- endCol: number
20
+ startRowArg: number,
21
+ startColArg: number,
22
+ endRowArg: number,
23
+ endColArg: number
24
24
  ): string {
25
+ let startRow = startRowArg
26
+ let startCol = startColArg
27
+ let endRow = endRowArg
28
+ let endCol = endColArg
25
29
  if (startRow > endRow || (startRow === endRow && startCol > endCol)) {
26
30
  ;[startRow, endRow] = [endRow, startRow]
27
31
  ;[startCol, endCol] = [endCol, startCol]
@@ -60,7 +64,7 @@ export function getWordAtColumn(
60
64
  }
61
65
 
62
66
  const ch = lineText[column]
63
- if (!ch || !/\S/.test(ch)) {
67
+ if (ch == null || ch === '' || !/\S/.test(ch)) {
64
68
  return { endCol: column, startCol: column, text: '' }
65
69
  }
66
70
 
@@ -103,10 +103,10 @@ export async function warmClaudeSyntaxOverlay(): Promise<void> {
103
103
  warmedHighlighter = h
104
104
  const themeName = await ensureActiveShikiTheme(h)
105
105
  activeThemeName = themeName
106
- await Promise.all(PRELOAD_LANGS.map((lang) => ensureShikiLang(h, lang)))
106
+ await Promise.all(PRELOAD_LANGS.map(async (lang) => ensureShikiLang(h, lang)))
107
107
  ready = true
108
- } catch (err) {
109
- logDebug('claude-syntax-overlay:warm-failed', { err: String(err) })
108
+ } catch (error) {
109
+ logDebug('claude-syntax-overlay:warm-failed', { err: String(error) })
110
110
  }
111
111
  }
112
112
 
@@ -150,7 +150,7 @@ function lineText(line: TerminalLine): string {
150
150
  function dominantBg(line: TerminalLine): string | undefined {
151
151
  const counts = new Map<string, number>()
152
152
  for (const span of line.spans) {
153
- if (!span.bg) continue
153
+ if (!(span.bg != null && span.bg !== '')) continue
154
154
  if (span.text.trim().length === 0) continue
155
155
  counts.set(span.bg, (counts.get(span.bg) ?? 0) + span.text.length)
156
156
  }
@@ -166,7 +166,7 @@ function dominantBg(line: TerminalLine): string | undefined {
166
166
  }
167
167
 
168
168
  function tokenize(code: string, lang: string): ShikiToken[][] {
169
- if (!ready || !activeThemeName || !warmedHighlighter) return []
169
+ if (!ready || activeThemeName == null || activeThemeName === '' || !warmedHighlighter) return []
170
170
  try {
171
171
  /* eslint-disable typescript-eslint/no-explicit-any */
172
172
  return warmedHighlighter.codeToTokens(code, {
@@ -210,7 +210,7 @@ function buildSpans(
210
210
  if (!tok.content) continue
211
211
  const fs = tok.fontStyle ?? 0
212
212
  const tokColor = tok.color?.toLowerCase()
213
- const fg = tokColor && accents.has(tokColor) ? tok.color : fallbackFg
213
+ const fg = tokColor != null && tokColor !== '' && accents.has(tokColor) ? tok.color : fallbackFg
214
214
  spans.push({
215
215
  bg,
216
216
  bold: (fs & 2) !== 0 || undefined,
@@ -381,7 +381,7 @@ function processLines(
381
381
  accents: Set<string>,
382
382
  targetWidth: number
383
383
  ): TerminalLine[] {
384
- const out = lines.slice()
384
+ const out = [...lines]
385
385
  let block: BlockContext | null = null
386
386
 
387
387
  for (let i = 0; i < out.length; i += 1) {
@@ -393,7 +393,7 @@ function processLines(
393
393
  const headerMatch = text.match(TOOL_HEADER_RE)
394
394
  if (headerMatch) {
395
395
  const lang = inferLangFromPath(headerMatch[1] ?? '')
396
- if (lang) tabLang.set(tabId, lang)
396
+ if (lang != null && lang !== '') tabLang.set(tabId, lang)
397
397
  }
398
398
 
399
399
  const prefixMatch = text.match(PREFIX_RE)
@@ -37,13 +37,13 @@ function writeAtomic(target: string, contents: string): void {
37
37
  writeFileSync(tmp, contents, 'utf8')
38
38
  try {
39
39
  renameSync(tmp, target)
40
- } catch (err) {
40
+ } catch (error) {
41
41
  try {
42
42
  unlinkSync(tmp)
43
43
  } catch {
44
44
  /* ignore */
45
45
  }
46
- throw err
46
+ throw error
47
47
  }
48
48
  }
49
49
 
@@ -60,8 +60,8 @@ export function syncClaudeTheme(resolved: ResolvedTuiTheme, mode: ThemeMode): vo
60
60
  let theme: ClaudeThemeFile
61
61
  try {
62
62
  theme = resolveClaudeTheme(resolved, mode)
63
- } catch (err) {
64
- logSyncWarn('resolve-failed', { err: String(err) })
63
+ } catch (error) {
64
+ logSyncWarn('resolve-failed', { err: String(error) })
65
65
  return
66
66
  }
67
67
 
@@ -69,8 +69,8 @@ export function syncClaudeTheme(resolved: ResolvedTuiTheme, mode: ThemeMode): vo
69
69
  try {
70
70
  mkdirSync(join(claudeDir(), 'themes'), { recursive: true })
71
71
  writeAtomic(target, `${JSON.stringify(theme, null, 2)}\n`)
72
- } catch (err) {
73
- logSyncWarn('write-failed', { err: String(err), path: target })
72
+ } catch (error) {
73
+ logSyncWarn('write-failed', { err: String(error), path: target })
74
74
  }
75
75
  }
76
76
 
@@ -86,8 +86,8 @@ export function ensureClaudeSettingsThemePref(): void {
86
86
  let raw: string
87
87
  try {
88
88
  raw = readFileSync(target, 'utf8')
89
- } catch (err) {
90
- logSyncWarn('settings-read-failed', { err: String(err), path: target })
89
+ } catch (error) {
90
+ logSyncWarn('settings-read-failed', { err: String(error), path: target })
91
91
  return
92
92
  }
93
93
  if (raw.trim().length > 0) {
@@ -98,8 +98,8 @@ export function ensureClaudeSettingsThemePref(): void {
98
98
  return
99
99
  }
100
100
  parsed = json as Record<string, unknown>
101
- } catch (err) {
102
- logSyncWarn('settings-parse-failed', { err: String(err), path: target })
101
+ } catch (error) {
102
+ logSyncWarn('settings-parse-failed', { err: String(error), path: target })
103
103
  return
104
104
  }
105
105
  }
@@ -112,7 +112,7 @@ export function ensureClaudeSettingsThemePref(): void {
112
112
  try {
113
113
  mkdirSync(claudeDir(), { recursive: true })
114
114
  writeAtomic(target, `${JSON.stringify(parsed, null, 2)}\n`)
115
- } catch (err) {
116
- logSyncWarn('settings-write-failed', { err: String(err), path: target })
115
+ } catch (error) {
116
+ logSyncWarn('settings-write-failed', { err: String(error), path: target })
117
117
  }
118
118
  }
@@ -10,8 +10,8 @@ import type {
10
10
 
11
11
  import { isWorkspaceSnapshotV1 } from '../state/validation'
12
12
 
13
- export const IPC_PROTOCOL_MIN_VERSION = 7
14
- export const IPC_PROTOCOL_VERSION = 7
13
+ export const IPC_PROTOCOL_MIN_VERSION = 8
14
+ export const IPC_PROTOCOL_VERSION = 8
15
15
 
16
16
  export interface ProtocolHelloRequest {
17
17
  minVersion: number
@@ -43,7 +43,7 @@ export interface AttachResult {
43
43
  * right state immediately, without the per-event race where a separate
44
44
  * `sessionStatus` event could arrive before the session was in state.
45
45
  */
46
- initialSessionStatuses: Array<{ sessionId: string; status: SessionStatus }>
46
+ initialSessionStatuses: { sessionId: string; status: SessionStatus }[]
47
47
  }
48
48
 
49
49
  export type ClientRequest =
@@ -1,14 +1,16 @@
1
1
  import { logDebug } from '../debug/input-log'
2
+ import { toast } from '../state/toast-store'
2
3
 
3
4
  export function copyToSystemClipboard(text: string): void {
4
5
  try {
5
6
  const proc = Bun.spawn(['pbcopy'], { stdin: 'pipe' })
6
- proc.stdin.write(text)
7
- proc.stdin.end()
7
+ void proc.stdin.write(text)
8
+ void proc.stdin.end()
8
9
  } catch (error) {
9
10
  logDebug('platform.clipboard.copyError', {
10
11
  error: error instanceof Error ? error.message : String(error),
11
12
  })
13
+ toast.error('Copy failed')
12
14
  }
13
15
  }
14
16
 
@@ -0,0 +1,22 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { realpath, symlink } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * Symlink the source checkout's `node_modules` into a freshly created worktree
7
+ * so a JS project's dependencies don't need reinstalling. No-op when the source
8
+ * isn't a JS project, has no installed `node_modules`, or the worktree already
9
+ * has one. Returns true when a symlink was created.
10
+ */
11
+ export async function linkNodeModules(sourceDir: string, worktreePath: string): Promise<boolean> {
12
+ if (!existsSync(join(sourceDir, 'package.json'))) return false
13
+ const sourceModules = join(sourceDir, 'node_modules')
14
+ if (!existsSync(sourceModules)) return false
15
+ const targetModules = join(worktreePath, 'node_modules')
16
+ if (existsSync(targetModules)) return false
17
+ // Resolve through any existing symlink so the new link points at the real
18
+ // dependency store rather than chaining through another worktree.
19
+ const resolved = await realpath(sourceModules)
20
+ await symlink(resolved, targetModules, 'dir')
21
+ return true
22
+ }
@@ -0,0 +1,67 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { lstat, mkdir, realpath } from 'node:fs/promises'
3
+ import { join, resolve } from 'node:path'
4
+
5
+ const DEFAULT_WORKTREE_ROOT = '/tmp/aimux-wt'
6
+ const MAX_SLUG_LENGTH = 24
7
+
8
+ export function getAimuxWorktreeRoot(): string {
9
+ const root = process.env.AIMUX_WORKTREE_ROOT
10
+ return root != null && root !== '' ? root : DEFAULT_WORKTREE_ROOT
11
+ }
12
+
13
+ export function sanitizePathSegment(input: string, maxLength = MAX_SLUG_LENGTH): string {
14
+ const sanitized = input
15
+ .trim()
16
+ .replaceAll(/[^A-Za-z0-9._-]+/g, '-')
17
+ .replaceAll(/\.\.+/g, '-')
18
+ .replaceAll(/^-+|-+$/g, '')
19
+ return (sanitized || 'worktree').slice(0, maxLength).replaceAll(/[-_.]+$/g, '') || 'worktree'
20
+ }
21
+
22
+ function shortHash(input: string, length = 8): string {
23
+ return createHash('sha1').update(input).digest('hex').slice(0, length)
24
+ }
25
+
26
+ export function makeWorktreePath({
27
+ repoRoot,
28
+ worktreeId,
29
+ worktreeName,
30
+ }: {
31
+ repoRoot: string
32
+ worktreeName: string
33
+ worktreeId: string
34
+ }): string {
35
+ const repoKey = `r-${shortHash(resolve(repoRoot))}`
36
+ const idSuffix = shortHash(worktreeId, 5)
37
+ const slug = `${sanitizePathSegment(worktreeName)}-${idSuffix}`
38
+ return join(getAimuxWorktreeRoot(), repoKey, slug)
39
+ }
40
+
41
+ export function isInsideAimuxWorktreeRoot(path: string): boolean {
42
+ const normalizeTmp = (value: string) => value.replace(/^\/private\/tmp(?=\/|$)/, '/tmp')
43
+ const root = `${normalizeTmp(resolve(getAimuxWorktreeRoot()))}/`
44
+ const target = `${normalizeTmp(resolve(path))}/`
45
+ return target.startsWith(root)
46
+ }
47
+
48
+ export async function ensureAimuxWorktreeRoot(): Promise<string> {
49
+ const root = getAimuxWorktreeRoot()
50
+ await mkdir(root, { recursive: true })
51
+ const stat = await lstat(root)
52
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
53
+ throw new Error(`unsafe Aimux worktree root: ${root}`)
54
+ }
55
+ return root
56
+ }
57
+
58
+ export async function assertSafeAimuxWorktreePath(path: string): Promise<void> {
59
+ const root = await ensureAimuxWorktreeRoot()
60
+ if (!isInsideAimuxWorktreeRoot(path)) {
61
+ throw new Error(`refusing worktree path outside Aimux temp root: ${path}`)
62
+ }
63
+ const realRoot = await realpath(root)
64
+ const realParent = await realpath(resolve(path, '..'))
65
+ if (`${realParent}/`.startsWith(`${realRoot}/`)) return
66
+ throw new Error(`unsafe Aimux worktree parent: ${realParent}`)
67
+ }
@@ -4,14 +4,14 @@ export const DEFAULT_PROFILE = 'default'
4
4
 
5
5
  function sanitizeProfile(profile: string): string {
6
6
  const trimmed = profile.trim().toLowerCase()
7
- const normalized = trimmed.replace(/[^a-z0-9._-]+/g, '-')
8
- const collapsed = normalized.replace(/-+/g, '-').replace(/^-|-$/g, '')
7
+ const normalized = trimmed.replaceAll(/[^a-z0-9._-]+/g, '-')
8
+ const collapsed = normalized.replaceAll(/-+/g, '-').replaceAll(/^-|-$/g, '')
9
9
  return collapsed || DEFAULT_PROFILE
10
10
  }
11
11
 
12
12
  export function getProfileName(): string {
13
13
  const configured = process.env.AIMUX_PROFILE ?? process.env.AIMUX_RUNTIME_PROFILE
14
- if (!configured) {
14
+ if (!(configured != null && configured !== '')) {
15
15
  return DEFAULT_PROFILE
16
16
  }
17
17
 
@@ -58,9 +58,9 @@ export interface StatusDetectionLoopHandle {
58
58
  /** Last session flags, for on-attach replay. */
59
59
  getSessionStatus: (sessionId: string) => SessionStatus | undefined
60
60
  /** Snapshot of every known session's flags. */
61
- snapshotSessions: () => Array<{ sessionId: string; status: SessionStatus }>
61
+ snapshotSessions: () => { sessionId: string; status: SessionStatus }[]
62
62
  /** Snapshot of every known tab's status plus its session. */
63
- snapshotTabs: () => Array<{ tabId: string; sessionId: string; status: TabActivity }>
63
+ snapshotTabs: () => { tabId: string; sessionId: string; status: TabActivity }[]
64
64
  /**
65
65
  * Synchronously run classification for a single session. Used on client
66
66
  * attach so the replay snapshot is populated *before* the client reads it,
@@ -20,6 +20,7 @@ const ACTIVE_CHANGE_WINDOW_MS = 600
20
20
 
21
21
  /** Spinner glyphs used by claude code's status lines. */
22
22
  const CLAUDE_SPINNER_GLYPHS = '·✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆'
23
+ const CLAUDE_SPINNER_GLYPH_SET = new Set(CLAUDE_SPINNER_GLYPHS)
23
24
 
24
25
  const SHELL_COMMAND_PATTERN =
25
26
  /(^|\/)(bash|zsh|fish|sh|dash|ash|ksh|tcsh|csh|nu|pwsh|powershell|elvish|xonsh)(\.exe)?$/i
@@ -85,7 +86,8 @@ export class AssistantStatusDetector {
85
86
  }
86
87
 
87
88
  function extractTailText(viewport: TerminalSnapshot, lineCount: number): string {
88
- const lines = viewport.tailLines ?? viewport.lines
89
+ const isScrolledToBottom = viewport.viewportY === viewport.baseY
90
+ const lines = isScrolledToBottom ? viewport.lines : (viewport.tailLines ?? viewport.lines)
89
91
  // Full-screen TUIs (claude, opencode) paint in the alternate buffer and
90
92
  // often leave the last rows blank, putting their status bar higher up.
91
93
  // Skip trailing blank rows before taking the last `lineCount`.
@@ -152,10 +154,17 @@ function classifyClaude(haystack: string, rawTail: string): TabActivity {
152
154
  return 'idle'
153
155
  }
154
156
 
157
+ // A real spinner line looks like `✱ Thinking…`: glyph at the line start, with
158
+ // the ellipsis on the same line. Anchoring to the line start rejects the `·`
159
+ // separators Claude scatters mid-line through its header/footer (e.g.
160
+ // `Opus 4.7 … · Claude Max`, `… · ← for agents`), which would otherwise pair
161
+ // with a stray `...` placeholder and mark an idle home screen as working.
155
162
  function hasClaudeSpinner(rawTail: string): boolean {
156
- if (!rawTail.includes('…') && !rawTail.includes('...')) return false
157
- for (const ch of CLAUDE_SPINNER_GLYPHS) {
158
- if (rawTail.includes(ch)) return true
163
+ for (const line of rawTail.split('\n')) {
164
+ const trimmed = line.trimStart()
165
+ const first = trimmed[0]
166
+ if (first == null || !CLAUDE_SPINNER_GLYPH_SET.has(first)) continue
167
+ if (trimmed.includes('…') || trimmed.includes('...')) return true
159
168
  }
160
169
  return false
161
170
  }
@@ -219,8 +228,8 @@ function classifyGeneric(haystack: string, changedAt: number, now: number): TabA
219
228
  }
220
229
 
221
230
  export function isShellCommand(command: string | undefined): boolean {
222
- if (!command) return false
231
+ if (!(command != null && command !== '')) return false
223
232
  const first = command.trim().split(/\s+/u)[0]
224
- if (!first) return false
233
+ if (!(first != null && first !== '')) return false
225
234
  return SHELL_COMMAND_PATTERN.test(first)
226
235
  }
@@ -7,7 +7,8 @@ export interface AssistantOption {
7
7
  description: string
8
8
  }
9
9
 
10
- const DEFAULT_SHELL = process.env.SHELL || 'sh'
10
+ const DEFAULT_SHELL =
11
+ process.env.SHELL != null && process.env.SHELL !== '' ? process.env.SHELL : 'sh'
11
12
  const SHELL_NAME = DEFAULT_SHELL.split('/').pop() ?? 'shell'
12
13
 
13
14
  export const ASSISTANT_OPTIONS: AssistantOption[] = [
@@ -7,7 +7,7 @@ import type { ScrollIntent, TerminalModeState, TerminalSnapshot } from '../state
7
7
  import { logDebug } from '../debug/input-log'
8
8
  import { areTerminalSnapshotsEqual, snapshotTerminal } from './terminal-snapshot'
9
9
 
10
- type PtyManagerEvents = {
10
+ interface PtyManagerEvents {
11
11
  render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
12
12
  exit: [tabId: string, exitCode: number]
13
13
  error: [tabId: string, message: string]
@@ -47,7 +47,7 @@ let cachedCreds: ClaudeOAuthCreds | null = null
47
47
  const CREDS_EXPIRY_BUFFER_MS = 60_000
48
48
 
49
49
  function normalizePlanTier(raw: string | undefined | null): string | null {
50
- if (!raw) return null
50
+ if (!(raw != null && raw !== '')) return null
51
51
  const trimmed = raw.trim()
52
52
  if (!trimmed) return null
53
53
  const lower = trimmed.toLowerCase()
@@ -64,7 +64,7 @@ function planTierFromPayload(payload: ClaudeKeychainPayload): string | null {
64
64
  if (!o) return null
65
65
  const raw = o.rateLimitTier ?? o.rate_limit_tier ?? o.subscriptionType
66
66
  const normalized = normalizePlanTier(raw)
67
- if (normalized) return normalized
67
+ if (normalized != null && normalized !== '') return normalized
68
68
  if (Array.isArray(o.scopes)) {
69
69
  for (const scope of o.scopes) {
70
70
  const s = scope.toLowerCase()
@@ -101,7 +101,7 @@ async function readClaudeCreds(): Promise<ClaudeOAuthCreds> {
101
101
  }
102
102
  const parsed = JSON.parse(result.stdout.trim()) as ClaudeKeychainPayload
103
103
  const access = parsed.claudeAiOauth?.accessToken
104
- if (!access) {
104
+ if (!(access != null && access !== '')) {
105
105
  throw new Error('no accessToken in Claude Code keychain')
106
106
  }
107
107
  cachedCreds = {
@@ -123,8 +123,8 @@ function buildWindow(
123
123
  const util = typeof payload.utilization === 'number' ? payload.utilization : null
124
124
  const percent = util === null ? null : Math.max(0, Math.min(100, util))
125
125
  const resetAt = payload.resets_at ?? null
126
- const resetAtMs = resetAt ? new Date(resetAt).getTime() : null
127
- if (percent === null && !resetAt) return null
126
+ const resetAtMs = resetAt != null && resetAt !== '' ? new Date(resetAt).getTime() : null
127
+ if (percent === null && !(resetAt != null && resetAt !== '')) return null
128
128
  return {
129
129
  kind,
130
130
  label,
@@ -40,7 +40,7 @@ const AUTH_TIMEOUT_MS = 15_000
40
40
 
41
41
  function codexHome(): string {
42
42
  const env = process.env.CODEX_HOME?.trim()
43
- if (env) return env
43
+ if (env != null && env !== '') return env
44
44
  return join(homedir(), '.codex')
45
45
  }
46
46
 
@@ -81,7 +81,7 @@ async function resolveUsageURL(): Promise<string> {
81
81
  try {
82
82
  const contents = await readFile(join(codexHome(), 'config.toml'), 'utf8')
83
83
  const parsed = parseChatGPTBaseFromConfig(contents)
84
- if (parsed) base = parsed
84
+ if (parsed != null && parsed !== '') base = parsed
85
85
  } catch {
86
86
  // no config.toml or unreadable — fall back to default
87
87
  }
@@ -94,7 +94,7 @@ async function loadAuth(): Promise<{ accessToken: string; accountId: string | nu
94
94
  const raw = await readFile(join(codexHome(), 'auth.json'), 'utf8')
95
95
  const parsed = JSON.parse(raw) as CodexAuthFile
96
96
  const accessToken = parsed.tokens?.access_token
97
- if (!accessToken) {
97
+ if (!(accessToken != null && accessToken !== '')) {
98
98
  throw new Error('no access_token in ~/.codex/auth.json — run `codex` to sign in')
99
99
  }
100
100
  return {
@@ -104,7 +104,7 @@ async function loadAuth(): Promise<{ accessToken: string; accountId: string | nu
104
104
  }
105
105
 
106
106
  function normalizePlanTier(raw: string | undefined | null): string | null {
107
- if (!raw) return null
107
+ if (!(raw != null && raw !== '')) return null
108
108
  const trimmed = raw.trim()
109
109
  if (!trimmed) return null
110
110
  const lower = trimmed.toLowerCase()
@@ -128,11 +128,11 @@ function buildCodexWindow(
128
128
  if (!window) return null
129
129
  const percent =
130
130
  typeof window.used_percent === 'number' ? Math.max(0, Math.min(100, window.used_percent)) : null
131
- const resetAtMs = window.reset_at ? window.reset_at * 1000 : null
132
- const resetAt = resetAtMs ? new Date(resetAtMs).toISOString() : null
131
+ const resetAtMs = window.reset_at != null && window.reset_at !== 0 ? window.reset_at * 1000 : null
132
+ const resetAt = resetAtMs != null && resetAtMs !== 0 ? new Date(resetAtMs).toISOString() : null
133
133
  const windowSeconds =
134
134
  typeof window.limit_window_seconds === 'number' ? window.limit_window_seconds : null
135
- if (percent === null && !resetAt) return null
135
+ if (percent === null && !(resetAt != null && resetAt !== '')) return null
136
136
  return {
137
137
  kind,
138
138
  label,
@@ -186,7 +186,7 @@ export async function fetchCodexUsage(_config: AIUsageToolConfig): Promise<Usage
186
186
  'Authorization': `Bearer ${accessToken}`,
187
187
  'User-Agent': 'aimux',
188
188
  }
189
- if (accountId) headers['ChatGPT-Account-Id'] = accountId
189
+ if (accountId != null && accountId !== '') headers['ChatGPT-Account-Id'] = accountId
190
190
 
191
191
  const controller = new AbortController()
192
192
  const timer = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS)
@@ -40,12 +40,12 @@ export function loadCachedSnapshot(tool: AIUsageTool, maxAgeMs: number): CachedS
40
40
  if (typeof entry.fetchedAt !== 'number') return null
41
41
  const ageMs = Date.now() - entry.fetchedAt
42
42
  if (ageMs > maxAgeMs) return null
43
- if (!entry.snapshot || entry.snapshot.tool !== tool) return null
43
+ if (entry.snapshot.tool !== tool) return null
44
44
  return { ageMs, snapshot: entry.snapshot }
45
45
  }
46
46
 
47
47
  export function saveCachedSnapshot(snapshot: UsageSnapshot): void {
48
- if (snapshot.error) return
48
+ if (snapshot.error != null && snapshot.error !== '') return
49
49
  if (snapshot.percent === null) return
50
50
  try {
51
51
  mkdirSync(CACHE_DIR, { recursive: true })
@@ -29,7 +29,7 @@ export function computePace(opts: {
29
29
  }): UsagePace | null {
30
30
  const { percent, resetAtMs, windowSeconds } = opts
31
31
  const now = opts.now ?? Date.now()
32
- if (percent === null || resetAtMs === null || !windowSeconds || windowSeconds <= 0) {
32
+ if (percent === null || resetAtMs === null || windowSeconds == null || windowSeconds <= 0) {
33
33
  return null
34
34
  }
35
35
 
@@ -49,11 +49,8 @@ export function computePace(opts: {
49
49
  if (rate > 0) {
50
50
  const remaining = Math.max(0, 100 - actual)
51
51
  const candidate = remaining / rate
52
- if (candidate >= timeUntilReset) {
53
- rightText = 'Lasts to reset'
54
- } else {
55
- rightText = `Runs out in ${formatDuration(candidate)}`
56
- }
52
+ rightText =
53
+ candidate >= timeUntilReset ? 'Lasts to reset' : `Runs out in ${formatDuration(candidate)}`
57
54
  }
58
55
  } else if (elapsed > 0 && actual === 0) {
59
56
  rightText = 'Lasts to reset'
@@ -49,7 +49,7 @@ export function startAIUsageService(
49
49
  }
50
50
 
51
51
  if (toFetch.length > 0) {
52
- const results = await Promise.allSettled(toFetch.map((t) => fetchFor(t, config)))
52
+ const results = await Promise.allSettled(toFetch.map(async (t) => fetchFor(t, config)))
53
53
  if (stopped) return
54
54
  for (let i = 0; i < results.length; i++) {
55
55
  const result = results[i]
@@ -48,7 +48,7 @@ async function spawnDaemon(): Promise<void> {
48
48
 
49
49
  async function canConnectToDaemon(socketPath: string): Promise<boolean> {
50
50
  const securityIssue = getSocketSecurityIssue(socketPath)
51
- if (securityIssue) {
51
+ if (securityIssue != null && securityIssue !== '') {
52
52
  logDebug('backend.healthcheck.socketIssue', { issue: securityIssue, socketPath })
53
53
  return false
54
54
  }
@@ -77,7 +77,7 @@ export async function probeDaemonProtocolCompatibility(
77
77
  socketPath: string
78
78
  ): Promise<DaemonHandshakeProbeResult> {
79
79
  const securityIssue = getSocketSecurityIssue(socketPath)
80
- if (securityIssue) {
80
+ if (securityIssue != null && securityIssue !== '') {
81
81
  return { compatible: false, error: securityIssue }
82
82
  }
83
83
 
@@ -114,7 +114,7 @@ export class LocalSessionBackend
114
114
  rows: number
115
115
  cwd?: string
116
116
  }): void {
117
- if (!this.currentSessionId) {
117
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) {
118
118
  logDebug('backend.local.skipCreateWithoutSession', { tabId: options.tabId })
119
119
  return
120
120
  }
@@ -127,7 +127,7 @@ export class LocalSessionBackend
127
127
  }
128
128
 
129
129
  write(tabId: string, input: string): void {
130
- if (!this.currentSessionId) {
130
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) {
131
131
  logDebug('backend.local.skipWriteWithoutSession', { inputLength: input.length, tabId })
132
132
  return
133
133
  }
@@ -140,22 +140,22 @@ export class LocalSessionBackend
140
140
  }
141
141
 
142
142
  scrollViewport(tabId: string, deltaLines: number): void {
143
- if (!this.currentSessionId) return
143
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
144
144
  this.sessionManager.scroll(this.currentSessionId, tabId, deltaLines)
145
145
  }
146
146
 
147
147
  scrollViewportToBottom(tabId: string): void {
148
- if (!this.currentSessionId) return
148
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
149
149
  this.sessionManager.scrollToBottom(this.currentSessionId, tabId)
150
150
  }
151
151
 
152
152
  reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
153
- if (!this.currentSessionId) return
153
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
154
154
  this.sessionManager.reapplyScrollIntent(this.currentSessionId, tabId, intent)
155
155
  }
156
156
 
157
157
  setActiveTab(tabId: string | null): void {
158
- if (!this.currentSessionId) return
158
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
159
159
  logDebug('backend.local.setActiveTab', { sessionId: this.currentSessionId, tabId })
160
160
  this.sessionManager.setActiveTab(this.currentSessionId, tabId)
161
161
  }
@@ -166,7 +166,7 @@ export class LocalSessionBackend
166
166
  intents?: Map<string, ScrollIntent>,
167
167
  options?: { sync?: boolean }
168
168
  ): void {
169
- if (!this.currentSessionId) return
169
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
170
170
  this.sessionManager.resize(this.currentSessionId, cols, rows, intents, options)
171
171
  }
172
172
 
@@ -177,25 +177,25 @@ export class LocalSessionBackend
177
177
  intent?: ScrollIntent,
178
178
  options?: { sync?: boolean }
179
179
  ): void {
180
- if (!this.currentSessionId) return
180
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
181
181
  this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent, options)
182
182
  }
183
183
 
184
184
  disposeSession(tabId: string): void {
185
- if (!this.currentSessionId) return
185
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
186
186
  logDebug('backend.local.disposeSession', { sessionId: this.currentSessionId, tabId })
187
187
  this.sessionManager.closeTab(this.currentSessionId, tabId)
188
188
  }
189
189
 
190
190
  disposeAll(): void {
191
- if (!this.currentSessionId) return
191
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
192
192
  logDebug('backend.local.disposeAll', { sessionId: this.currentSessionId })
193
193
  this.sessionManager.disposeSession(this.currentSessionId)
194
194
  }
195
195
 
196
196
  destroy(keepSessions = true): void {
197
197
  logDebug('backend.local.destroy', { keepSessions, sessionId: this.currentSessionId })
198
- if (!keepSessions && this.currentSessionId) {
198
+ if (!keepSessions && this.currentSessionId != null && this.currentSessionId !== '') {
199
199
  this.sessionManager.disposeSession(this.currentSessionId)
200
200
  }
201
201
  this.statusLoop.stop()