@brimveyn/aimux 1.7.4 → 1.9.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 (56) hide show
  1. package/README.md +27 -15
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +286 -0
  4. package/src/app-runtime/auto-commit-ref.ts +20 -0
  5. package/src/app-runtime/backend-attach-runtime.ts +3 -1
  6. package/src/app-runtime/session-actions.ts +7 -2
  7. package/src/app-runtime/side-effects.ts +99 -1
  8. package/src/app-runtime/use-auto-commit-driver.ts +133 -0
  9. package/src/app-runtime/use-terminal-resize.ts +20 -12
  10. package/src/app.tsx +59 -22
  11. package/src/auto-commit/default-auto-commit-prompt.md +46 -0
  12. package/src/auto-commit/headless-commands.ts +40 -0
  13. package/src/auto-commit/output-parser.ts +21 -0
  14. package/src/auto-commit/prompt-loader.ts +33 -0
  15. package/src/auto-commit/staging-mode.ts +5 -0
  16. package/src/auto-commit/strip-ansi.ts +13 -0
  17. package/src/auto-commit/suggestion-runner.ts +55 -0
  18. package/src/auto-commit/working-tree-hash.ts +24 -0
  19. package/src/config.ts +24 -0
  20. package/src/daemon/session-registry.ts +1 -0
  21. package/src/index.tsx +1 -1
  22. package/src/input/keymap/help-entries.ts +4 -4
  23. package/src/input/modes/bridge.ts +7 -0
  24. package/src/input/modes/transitions.ts +6 -2
  25. package/src/input/modes/types.ts +5 -0
  26. package/src/ipc/manager-protocol.ts +2 -2
  27. package/src/ipc/protocol.ts +2 -8
  28. package/src/pty/assistant-status-detector.ts +1 -1
  29. package/src/pty/terminal-snapshot.ts +38 -4
  30. package/src/services/ai-usage/adapters/claude.ts +215 -0
  31. package/src/services/ai-usage/adapters/codex.ts +242 -0
  32. package/src/services/ai-usage/cache.ts +60 -0
  33. package/src/services/ai-usage/pace.ts +79 -0
  34. package/src/services/ai-usage/provider.ts +107 -0
  35. package/src/services/ai-usage/spawn.ts +49 -0
  36. package/src/services/ai-usage/types.ts +51 -0
  37. package/src/session-backend/local-session-backend.ts +10 -2
  38. package/src/state/ai-usage-store.ts +36 -0
  39. package/src/state/reducers/auto-commit-state.ts +59 -0
  40. package/src/state/reducers/modal-state.ts +119 -2
  41. package/src/state/reducers/session-state.ts +26 -14
  42. package/src/state/session-persistence.ts +14 -5
  43. package/src/state/store.ts +24 -14
  44. package/src/state/types.ts +62 -2
  45. package/src/state/workspace-save.ts +4 -0
  46. package/src/ui/components/ai-usage-indicator.tsx +163 -0
  47. package/src/ui/components/ai-usage-modal.tsx +186 -0
  48. package/src/ui/components/create-session-modal.tsx +2 -2
  49. package/src/ui/components/git-commit-modal.tsx +167 -18
  50. package/src/ui/components/session-bar.tsx +2 -2
  51. package/src/ui/components/session-picker-modal.tsx +4 -4
  52. package/src/ui/components/sidebar.tsx +3 -1
  53. package/src/ui/components/status-bar.tsx +2 -0
  54. package/src/ui/components/terminal-pane.tsx +18 -3
  55. package/src/ui/root.tsx +14 -2
  56. package/src/ui/status-bar-model.ts +1 -1
@@ -2,6 +2,7 @@ import { allLeafIds, createGroupId, type LayoutNode, pruneLayoutTree } from './l
2
2
  import {
3
3
  type AppState,
4
4
  DEFAULT_SCROLL_INTENT,
5
+ type ScrollIntent,
5
6
  type TabSession,
6
7
  type TabStatus,
7
8
  type WorkspaceSnapshotV1,
@@ -82,6 +83,18 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
82
83
  }))
83
84
  }
84
85
 
86
+ export function getSnapshotScrollIntents(
87
+ snapshot: WorkspaceSnapshotV1 | undefined
88
+ ): Map<string, ScrollIntent> {
89
+ if (!snapshot || snapshot.version !== 1) {
90
+ return new Map()
91
+ }
92
+
93
+ return new Map(
94
+ snapshot.tabs.map((tab) => [tab.id, tab.scrollIntent ?? DEFAULT_SCROLL_INTENT] as const)
95
+ )
96
+ }
97
+
85
98
  export function restoreLayoutTrees(
86
99
  snapshot: WorkspaceSnapshotV1 | undefined,
87
100
  tabs: TabSession[]
@@ -192,11 +205,7 @@ export function restoreWorkspaceState(
192
205
  activeTabId,
193
206
  focusMode: 'navigation',
194
207
  layoutTrees,
195
- sidebar: {
196
- ...state.sidebar,
197
- visible: workspaceSnapshot?.sidebar.visible ?? state.sidebar.visible,
198
- width: workspaceSnapshot?.sidebar.width ?? state.sidebar.width,
199
- },
208
+ sidebar: state.sidebar,
200
209
  tabGroupMap,
201
210
  tabs: orderedTabs,
202
211
  }
@@ -1,15 +1,4 @@
1
- import type {
2
- AppAction,
3
- AppState,
4
- GitModeState,
5
- GitPaneMode,
6
- GitPanePosition,
7
- GitPaneState,
8
- SessionBarPosition,
9
- SessionRecord,
10
- SnippetRecord,
11
- } from './types'
12
-
1
+ import { reduceAutoCommit } from './reducers/auto-commit-state'
13
2
  import { emptyGitMode, reduceGitModeState } from './reducers/git-mode-state'
14
3
  import { emptyGitPanel, reduceGitPanelState } from './reducers/git-panel-state'
15
4
  import { emptyModal, reduceModalState } from './reducers/modal-state'
@@ -17,6 +6,18 @@ import { reduceSessionState } from './reducers/session-state'
17
6
  import { reduceTabState } from './reducers/tab-state'
18
7
  import { reduceUIState } from './reducers/ui-state'
19
8
  import { filterSnippets } from './selectors'
9
+ import {
10
+ type AppAction,
11
+ type AppState,
12
+ EMPTY_AUTO_COMMIT_STATE,
13
+ type GitModeState,
14
+ type GitPaneMode,
15
+ type GitPanePosition,
16
+ type GitPaneState,
17
+ type SessionBarPosition,
18
+ type SessionRecord,
19
+ type SnippetRecord,
20
+ } from './types'
20
21
 
21
22
  const DEFAULT_SIDEBAR_WIDTH = 28
22
23
  const DEFAULT_SIDEBAR_MIN_WIDTH = 18
@@ -27,6 +28,7 @@ const DEFAULT_TERMINAL_ROWS = 24
27
28
  export interface InitialStateOverrides {
28
29
  gitMode?: Partial<GitModeState>
29
30
  gitPane?: Partial<GitPaneState>
31
+ sidebar?: Pick<AppState['sidebar'], 'visible' | 'width'>
30
32
  sessionBarVisible?: boolean
31
33
  sessionBarPosition?: SessionBarPosition
32
34
  }
@@ -52,6 +54,10 @@ function resolveGitPanePosition(mode: GitPaneMode, position: GitPanePosition): G
52
54
  return position === 'left' || position === 'right' ? position : 'left'
53
55
  }
54
56
 
57
+ function clampSidebarWidth(width: number): number {
58
+ return Math.min(DEFAULT_SIDEBAR_MAX_WIDTH, Math.max(DEFAULT_SIDEBAR_MIN_WIDTH, width))
59
+ }
60
+
55
61
  export function createInitialState(
56
62
  customCommands: Record<string, string> = {},
57
63
  sessions: SessionRecord[] = [],
@@ -66,6 +72,7 @@ export function createInitialState(
66
72
  )
67
73
  return {
68
74
  activeTabId: null,
75
+ autoCommit: EMPTY_AUTO_COMMIT_STATE,
69
76
  currentSessionId: null,
70
77
  customCommands,
71
78
  focusMode: showSessionPicker ? 'command-edit' : 'navigation',
@@ -101,8 +108,8 @@ export function createInitialState(
101
108
  sidebar: {
102
109
  maxWidth: DEFAULT_SIDEBAR_MAX_WIDTH,
103
110
  minWidth: DEFAULT_SIDEBAR_MIN_WIDTH,
104
- visible: true,
105
- width: DEFAULT_SIDEBAR_WIDTH,
111
+ visible: overrides.sidebar?.visible ?? true,
112
+ width: clampSidebarWidth(overrides.sidebar?.width ?? DEFAULT_SIDEBAR_WIDTH),
106
113
  },
107
114
  snippets,
108
115
  tabGroupMap: {},
@@ -129,6 +136,9 @@ export function appReducer(state: AppState, action: AppAction): AppState {
129
136
  const gitModeState = reduceGitModeState(state, action)
130
137
  if (gitModeState) return gitModeState
131
138
 
139
+ const autoCommitState = reduceAutoCommit(state, action)
140
+ if (autoCommitState) return autoCommitState
141
+
132
142
  switch (action.type) {
133
143
  case 'set-snippets':
134
144
  return { ...state, snippets: action.snippets }
@@ -44,6 +44,7 @@ export type ModalType =
44
44
  | 'split-picker'
45
45
  | 'git-commit'
46
46
  | 'update-available'
47
+ | 'ai-usage'
47
48
  | null
48
49
 
49
50
  export interface TerminalSpan {
@@ -62,6 +63,7 @@ export interface TerminalLine {
62
63
 
63
64
  export interface TerminalSnapshot {
64
65
  lines: TerminalLine[]
66
+ tailLines?: TerminalLine[]
65
67
  viewportY: number
66
68
  baseY: number
67
69
  cursorVisible: boolean
@@ -323,6 +325,7 @@ export interface ModalGitCommit extends ModalBase {
323
325
  type: 'git-commit'
324
326
  activeField: 'title' | 'body'
325
327
  contentBuffer: string
328
+ stage: 'edit' | 'generating' | 'confirm'
326
329
  }
327
330
 
328
331
  export interface ModalCreateSession extends ModalBase {
@@ -346,6 +349,10 @@ export interface ModalUpdateAvailable extends ModalBase {
346
349
  latestVersion: string
347
350
  }
348
351
 
352
+ export interface ModalAIUsage extends ModalBase {
353
+ type: 'ai-usage'
354
+ }
355
+
349
356
  export type DirectoryResultType = 'git-repo' | 'worktree' | 'workspace'
350
357
 
351
358
  export interface DirectoryResult {
@@ -367,6 +374,7 @@ export type ModalState =
367
374
  | ModalSnippetEditor
368
375
  | ModalGitCommit
369
376
  | ModalUpdateAvailable
377
+ | ModalAIUsage
370
378
 
371
379
  export interface LayoutState {
372
380
  terminalCols: number
@@ -397,6 +405,7 @@ export interface AppState {
397
405
  customCommands: Record<AssistantId, string>
398
406
  gitPanel: GitPanelState
399
407
  gitMode: GitModeState
408
+ autoCommit: AutoCommitState
400
409
  /** Chord prefix the sequence resolver is currently waiting on, or null when idle. */
401
410
  pendingChords: string[] | null
402
411
  }
@@ -431,6 +440,7 @@ export type ModalAction =
431
440
  | { type: 'open-theme-picker' }
432
441
  | { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
433
442
  | { type: 'set-modal-selection-index'; index: number }
443
+ | { type: 'open-ai-usage-modal' }
434
444
 
435
445
  // -- Session actions --
436
446
  export type SessionAction =
@@ -438,7 +448,7 @@ export type SessionAction =
438
448
  | { type: 'set-sessions'; sessions: SessionRecord[] }
439
449
  | { type: 'create-session-record'; session: SessionRecord }
440
450
  | { type: 'rename-session-record'; sessionId: string; name: string }
441
- | { type: 'delete-session-record'; sessionId: string }
451
+ | { type: 'delete-session-record'; sessionId: string; openSessionPicker?: boolean }
442
452
  | { type: 'reorder-sessions'; orderedIds: string[] }
443
453
  | { type: 'set-session-status'; sessionId: string; status: SessionStatus }
444
454
 
@@ -527,6 +537,50 @@ export type GitPanelAction =
527
537
  | { type: 'git-refresh-error'; kind: GitPanelError }
528
538
  | { type: 'git-panel-reset' }
529
539
 
540
+ // -- Auto-commit state & actions --
541
+ export type AutoCommitSuggestion =
542
+ | { kind: 'idle' }
543
+ | {
544
+ kind: 'generating'
545
+ tabId: string
546
+ workingTreeHash: string
547
+ abortController: AbortController
548
+ startedAt: number
549
+ }
550
+ | {
551
+ kind: 'ready'
552
+ tabId: string
553
+ workingTreeHash: string
554
+ title: string
555
+ body: string
556
+ generatedAt: number
557
+ }
558
+
559
+ export interface AutoCommitState {
560
+ bySession: Record<string, AutoCommitSuggestion>
561
+ }
562
+
563
+ export const EMPTY_AUTO_COMMIT_STATE: AutoCommitState = { bySession: {} }
564
+
565
+ export type AutoCommitAction =
566
+ | {
567
+ type: 'auto-commit-generation-started'
568
+ sessionId: string
569
+ tabId: string
570
+ workingTreeHash: string
571
+ abortController: AbortController
572
+ startedAt: number
573
+ }
574
+ | {
575
+ type: 'auto-commit-generation-ready'
576
+ sessionId: string
577
+ workingTreeHash: string
578
+ title: string
579
+ body: string
580
+ generatedAt: number
581
+ }
582
+ | { type: 'auto-commit-clear'; sessionId: string }
583
+
530
584
  export type GitModeAction =
531
585
  | { type: 'enter-git-mode' }
532
586
  | { type: 'exit-git-mode' }
@@ -576,7 +630,12 @@ export type GitModeAction =
576
630
  fromSection: GitFileSection
577
631
  toSection: GitFileSection | null
578
632
  }
579
- | { type: 'open-git-commit-modal' }
633
+ | { type: 'open-git-commit-modal'; sessionId?: string }
634
+ | { type: 'git-commit-enter-confirm' }
635
+ | { type: 'git-commit-leave-confirm' }
636
+ | { type: 'git-commit-enter-generating'; sessionId: string }
637
+ | { type: 'git-commit-leave-generating' }
638
+ | { type: 'git-commit-use-background-suggestion'; sessionId: string }
580
639
 
581
640
  // -- Data actions --
582
641
  export type DataAction =
@@ -593,3 +652,4 @@ export type AppAction =
593
652
  | DataAction
594
653
  | GitPanelAction
595
654
  | GitModeAction
655
+ | AutoCommitAction
@@ -35,6 +35,10 @@ export function saveCurrentWorkspace(state: AppState): void {
35
35
  },
36
36
  sessionBarPosition: state.sessionBar.position,
37
37
  sessionBarVisible: state.sessionBar.visible,
38
+ sidebar: {
39
+ visible: state.sidebar.visible,
40
+ width: state.sidebar.width,
41
+ },
38
42
  })
39
43
  saveSessionCatalog(
40
44
  buildSessionsWithCurrentSnapshot(state.sessions, state.currentSessionId, state)
@@ -0,0 +1,163 @@
1
+ import type { AIUsageTool } from '@brimveyn/aimux-config'
2
+
3
+ import { useAIUsageStore } from '../../state/ai-usage-store'
4
+ import { dispatchGlobal } from '../../state/dispatch-ref'
5
+ import { useBg, useTokens } from '../theme'
6
+
7
+ const TOOL_ICON: Record<AIUsageTool, string> = {
8
+ claude: 'CC',
9
+ codex: 'CO',
10
+ }
11
+
12
+ const BAR_SEGMENTS = 4
13
+ const BAR_FILLED_CHAR = '\u{2501}'
14
+ const BAR_EMPTY_CHAR = '\u{2500}'
15
+
16
+ function formatTokens(total: number): string {
17
+ if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M`
18
+ if (total >= 1_000) return `${(total / 1_000).toFixed(1)}k`
19
+ return String(total)
20
+ }
21
+
22
+ function buildBar(percent: number): { empty: string; filled: string } {
23
+ let filledCount = 0
24
+ for (let i = 0; i < BAR_SEGMENTS; i++) {
25
+ if (percent > i * (100 / BAR_SEGMENTS)) filledCount++
26
+ }
27
+ return {
28
+ empty: BAR_EMPTY_CHAR.repeat(BAR_SEGMENTS - filledCount),
29
+ filled: BAR_FILLED_CHAR.repeat(filledCount),
30
+ }
31
+ }
32
+
33
+ function formatResetIn(snap: {
34
+ resetAt: string | null
35
+ timeRemaining: string | null
36
+ }): string | null {
37
+ if (snap.resetAt) {
38
+ const diffMs = new Date(snap.resetAt).getTime() - Date.now()
39
+ if (diffMs > 0) {
40
+ const totalMin = Math.round(diffMs / 60_000)
41
+ const h = Math.floor(totalMin / 60)
42
+ const m = totalMin % 60
43
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}h`
44
+ }
45
+ }
46
+ return snap.timeRemaining
47
+ }
48
+
49
+ export function AIUsageIndicator() {
50
+ const t = useTokens()
51
+ const bg = useBg('elevated')
52
+ const enabled = useAIUsageStore((s) => s.enabled)
53
+ const snapshots = useAIUsageStore((s) => s.snapshots)
54
+
55
+ if (!enabled) return null
56
+
57
+ const ordered: AIUsageTool[] = ['claude', 'codex']
58
+ const entries = ordered
59
+ .map((tool) => ({ snap: snapshots[tool], tool }))
60
+ .filter((entry) => entry.snap !== undefined)
61
+
62
+ const openModal = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
63
+ e.preventDefault()
64
+ e.stopPropagation()
65
+ dispatchGlobal({ type: 'open-ai-usage-modal' })
66
+ }
67
+
68
+ if (entries.length === 0) {
69
+ return (
70
+ <box
71
+ flexDirection="row"
72
+ paddingLeft={1}
73
+ paddingRight={1}
74
+ backgroundColor={bg}
75
+ onMouseDown={openModal}
76
+ >
77
+ <text fg={t.muted}>…</text>
78
+ </box>
79
+ )
80
+ }
81
+
82
+ return (
83
+ <box flexDirection="row" gap={1}>
84
+ {entries.map(({ snap, tool }) => {
85
+ if (!snap) return null
86
+ const icon = TOOL_ICON[tool]
87
+
88
+ if (snap.error && !snap.stale) {
89
+ return (
90
+ <box
91
+ key={tool}
92
+ flexDirection="row"
93
+ paddingLeft={1}
94
+ paddingRight={1}
95
+ backgroundColor={bg}
96
+ onMouseDown={openModal}
97
+ >
98
+ <text fg={t.palette.error} selectable={false}>
99
+ {`${icon} —`}
100
+ </text>
101
+ </box>
102
+ )
103
+ }
104
+
105
+ if (snap.percent !== null) {
106
+ const p = Math.round(snap.percent)
107
+ let color = t.palette.success
108
+ if (p >= 85) {
109
+ color = t.palette.error
110
+ } else if (p >= 60) {
111
+ color = t.palette.warning
112
+ }
113
+ const { empty, filled } = buildBar(snap.percent)
114
+ const reset = formatResetIn(snap)
115
+ const pctText = `${String(p).padStart(2, ' ')}%`
116
+ return (
117
+ <box
118
+ key={tool}
119
+ flexDirection="row"
120
+ paddingLeft={1}
121
+ paddingRight={1}
122
+ backgroundColor={bg}
123
+ onMouseDown={openModal}
124
+ >
125
+ <text fg={color} selectable={false}>
126
+ {`${icon} `}
127
+ </text>
128
+ <text fg={color} selectable={false}>
129
+ {filled}
130
+ </text>
131
+ <text fg={t.muted} selectable={false}>
132
+ {empty}
133
+ </text>
134
+ <text fg={t.palette.ink} selectable={false}>
135
+ {` ${pctText}`}
136
+ </text>
137
+ {reset ? (
138
+ <text fg={t.muted} selectable={false}>
139
+ {` · ${reset}`}
140
+ </text>
141
+ ) : null}
142
+ </box>
143
+ )
144
+ }
145
+
146
+ return (
147
+ <box
148
+ key={tool}
149
+ flexDirection="row"
150
+ paddingLeft={1}
151
+ paddingRight={1}
152
+ backgroundColor={bg}
153
+ onMouseDown={openModal}
154
+ >
155
+ <text fg={t.muted} selectable={false}>
156
+ {`${icon} ${formatTokens(snap.tokens.total)}`}
157
+ </text>
158
+ </box>
159
+ )
160
+ })}
161
+ </box>
162
+ )
163
+ }
@@ -0,0 +1,186 @@
1
+ import type { AIUsageTool } from '@brimveyn/aimux-config'
2
+ import type { ReactNode } from 'react'
3
+
4
+ import type { UsagePaceStage, UsageSnapshot, UsageWindow } from '../../services/ai-usage/types'
5
+
6
+ import { useAIUsageStore } from '../../state/ai-usage-store'
7
+ import { useTokens } from '../theme'
8
+ import { uiTokens } from '../ui-tokens'
9
+ import { ModalShell } from './modal-shell'
10
+
11
+ const TOOL_TITLE: Record<AIUsageTool, string> = {
12
+ claude: 'Claude',
13
+ codex: 'Codex',
14
+ }
15
+
16
+ const BAR_SEGMENTS = 32
17
+ const BAR_FILLED_CHAR = '\u{2501}'
18
+ const BAR_EMPTY_CHAR = '\u{2500}'
19
+
20
+ function buildBar(percent: number | null): { empty: string; filled: string } {
21
+ const p = percent ?? 0
22
+ let filledCount = 0
23
+ for (let i = 0; i < BAR_SEGMENTS; i++) {
24
+ if (p > i * (100 / BAR_SEGMENTS)) filledCount++
25
+ }
26
+ return {
27
+ empty: BAR_EMPTY_CHAR.repeat(BAR_SEGMENTS - filledCount),
28
+ filled: BAR_FILLED_CHAR.repeat(filledCount),
29
+ }
30
+ }
31
+
32
+ function formatRelative(iso: string, now: number = Date.now()): string {
33
+ const diffMs = now - new Date(iso).getTime()
34
+ if (!Number.isFinite(diffMs) || diffMs < 0) return 'just now'
35
+ const s = Math.floor(diffMs / 1000)
36
+ if (s < 10) return 'just now'
37
+ if (s < 60) return `${s}s ago`
38
+ const m = Math.floor(s / 60)
39
+ if (m < 60) return `${m}m ago`
40
+ const h = Math.floor(m / 60)
41
+ if (h < 24) return `${h}h ago`
42
+ const d = Math.floor(h / 24)
43
+ return `${d}d ago`
44
+ }
45
+
46
+ function paceStageIsAhead(stage: UsagePaceStage): boolean {
47
+ return stage === 'ahead' || stage === 'farAhead' || stage === 'slightlyAhead'
48
+ }
49
+
50
+ function paceStageIsBehind(stage: UsagePaceStage): boolean {
51
+ return stage === 'behind' || stage === 'farBehind' || stage === 'slightlyBehind'
52
+ }
53
+
54
+ export function AIUsageModal() {
55
+ const t = useTokens()
56
+ const snapshots = useAIUsageStore((s) => s.snapshots)
57
+
58
+ const tools: AIUsageTool[] = ['claude', 'codex']
59
+ const sections = tools
60
+ .map((tool) => ({ snap: snapshots[tool], tool }))
61
+ .filter((s): s is { snap: UsageSnapshot; tool: AIUsageTool } => s.snap !== undefined)
62
+
63
+ return (
64
+ <ModalShell
65
+ title="AI usage"
66
+ keybindsModeId="modal.ai-usage"
67
+ width={uiTokens.modalWidth.md}
68
+ listGap={1}
69
+ >
70
+ {sections.length === 0 ? (
71
+ <text fg={t.muted} selectable={false}>
72
+ no data yet — collecting…
73
+ </text>
74
+ ) : (
75
+ <box flexDirection="column" gap={1}>
76
+ {sections.map(({ snap, tool }) => (
77
+ <ToolSection key={tool} snap={snap} tool={tool} />
78
+ ))}
79
+ </box>
80
+ )}
81
+ </ModalShell>
82
+ )
83
+ }
84
+
85
+ interface ToolSectionProps {
86
+ snap: UsageSnapshot
87
+ tool: AIUsageTool
88
+ }
89
+
90
+ function ToolSection({ snap, tool }: ToolSectionProps) {
91
+ const t = useTokens()
92
+ const isHardError = Boolean(snap.error) && !snap.stale
93
+ const relative = formatRelative(snap.lastUpdated)
94
+
95
+ let body: ReactNode
96
+ if (isHardError) {
97
+ body = (
98
+ <text fg={t.palette.error} selectable={false}>
99
+ {`error: ${snap.error ?? ''}`}
100
+ </text>
101
+ )
102
+ } else if (snap.windows.length === 0) {
103
+ body = (
104
+ <text fg={t.muted} selectable={false}>
105
+ no window data
106
+ </text>
107
+ )
108
+ } else {
109
+ body = snap.windows.map((window) => <WindowRow key={window.kind} window={window} />)
110
+ }
111
+
112
+ return (
113
+ <box flexDirection="column">
114
+ <box flexDirection="row" justifyContent="space-between">
115
+ <text fg={t.accent} selectable={false}>
116
+ {TOOL_TITLE[tool]}
117
+ </text>
118
+ {snap.planTier ? (
119
+ <text fg={t.muted} selectable={false}>
120
+ {snap.planTier}
121
+ </text>
122
+ ) : null}
123
+ </box>
124
+ <text fg={t.muted} selectable={false}>
125
+ {`Updated ${relative}`}
126
+ </text>
127
+ {body}
128
+ </box>
129
+ )
130
+ }
131
+
132
+ function WindowRow({ window }: { window: UsageWindow }) {
133
+ const t = useTokens()
134
+ const percent = window.percent
135
+ const { empty, filled } = buildBar(percent)
136
+
137
+ let barColor = t.palette.success
138
+ if (percent !== null) {
139
+ if (percent >= 85) barColor = t.palette.error
140
+ else if (percent >= 60) barColor = t.palette.warning
141
+ }
142
+
143
+ const pctText = percent === null ? '—' : `${Math.round(percent)}% used`
144
+ const resetText = window.timeRemaining ? `Resets in ${window.timeRemaining}` : null
145
+
146
+ return (
147
+ <box flexDirection="column" paddingTop={1}>
148
+ <text fg={t.palette.ink} selectable={false}>
149
+ {window.label}
150
+ </text>
151
+ <box flexDirection="row">
152
+ <text fg={barColor} selectable={false}>
153
+ {filled}
154
+ </text>
155
+ <text fg={t.muted} selectable={false}>
156
+ {empty}
157
+ </text>
158
+ </box>
159
+ <box flexDirection="row" justifyContent="space-between">
160
+ <text fg={t.muted} selectable={false}>
161
+ {pctText}
162
+ </text>
163
+ {resetText ? (
164
+ <text fg={t.muted} selectable={false}>
165
+ {resetText}
166
+ </text>
167
+ ) : null}
168
+ </box>
169
+ {window.pace ? <PaceLine pace={window.pace} /> : null}
170
+ </box>
171
+ )
172
+ }
173
+
174
+ function PaceLine({ pace }: { pace: NonNullable<UsageWindow['pace']> }) {
175
+ const t = useTokens()
176
+ let color = t.muted
177
+ if (paceStageIsBehind(pace.stage)) color = t.palette.warning
178
+ else if (paceStageIsAhead(pace.stage)) color = t.palette.success
179
+
180
+ const suffix = pace.rightText ? ` · ${pace.rightText}` : ''
181
+ return (
182
+ <text fg={color} selectable={false}>
183
+ {`Pace: ${pace.label}${suffix}`}
184
+ </text>
185
+ )
186
+ }
@@ -48,7 +48,7 @@ export function CreateSessionModal({
48
48
 
49
49
  return (
50
50
  <ModalShell
51
- title="Create session"
51
+ title="Create workspace"
52
52
  keybindsModeId="modal.create-session"
53
53
  width={uiTokens.modalWidth.xl}
54
54
  >
@@ -88,7 +88,7 @@ export function CreateSessionModal({
88
88
  </box>
89
89
 
90
90
  <box flexDirection="column">
91
- <text fg={nameActive ? t.palette.ink : t.muted}>Session name</text>
91
+ <text fg={nameActive ? t.palette.ink : t.muted}>Workspace name</text>
92
92
  <InputField active={nameActive} value={sessionName} />
93
93
  </box>
94
94
  </ModalShell>