@brimveyn/aimux 1.22.12 → 1.23.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.
Files changed (66) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/settings-actions.ts +24 -13
  3. package/src/app-runtime/side-effects.ts +5 -0
  4. package/src/app-runtime/use-mouse-handlers.ts +2 -0
  5. package/src/app.tsx +6 -0
  6. package/src/index.tsx +6 -0
  7. package/src/input/keymap/help-entries.ts +1 -0
  8. package/src/input/modes/bridge.ts +2 -1
  9. package/src/input/modes/transitions.ts +7 -3
  10. package/src/input/modes/types.ts +2 -1
  11. package/src/restart-daemon.ts +5 -0
  12. package/src/services/ai-usage/projection.ts +44 -0
  13. package/src/services/aimux-counters/index.ts +87 -0
  14. package/src/services/aimux-counters/observe.ts +39 -0
  15. package/src/services/aimux-counters/store.ts +150 -0
  16. package/src/services/aimux-counters/summary.ts +78 -0
  17. package/src/services/usage-history/cost.ts +149 -0
  18. package/src/services/usage-history/insights.ts +314 -0
  19. package/src/services/usage-history/rollup.ts +78 -6
  20. package/src/services/usage-history/stats.ts +66 -35
  21. package/src/services/usage-history/store.ts +128 -9
  22. package/src/settings/search.ts +13 -9
  23. package/src/settings/sections/about.ts +1 -0
  24. package/src/settings/sections/appearance.ts +1 -0
  25. package/src/settings/sections/automation.ts +1 -0
  26. package/src/settings/sections/commands.ts +1 -0
  27. package/src/settings/sections/editor.ts +1 -0
  28. package/src/settings/sections/experimental.ts +1 -0
  29. package/src/settings/sections/git.ts +1 -0
  30. package/src/settings/sections/index.ts +23 -12
  31. package/src/settings/sections/integrations.ts +1 -0
  32. package/src/settings/sections/layout.ts +1 -0
  33. package/src/settings/sections/notifications.ts +1 -0
  34. package/src/settings/sections/setup.ts +1 -0
  35. package/src/settings/sections/status-bar.ts +1 -0
  36. package/src/settings/sections/workspace.ts +1 -0
  37. package/src/settings/types.ts +10 -0
  38. package/src/state/actions.ts +13 -3
  39. package/src/state/app-store.ts +12 -1
  40. package/src/state/reducers/modal-state.ts +12 -17
  41. package/src/state/reducers/settings-state.ts +29 -51
  42. package/src/state/reducers/stats-state.ts +56 -0
  43. package/src/state/stats-pages.ts +33 -0
  44. package/src/state/store.ts +5 -0
  45. package/src/state/types.ts +22 -7
  46. package/src/ui/components/layout/sidebar/project-list.tsx +32 -1
  47. package/src/ui/components/modals/app/quotas-modal.tsx +42 -0
  48. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +4 -4
  49. package/src/ui/components/settings/row-value.tsx +22 -3
  50. package/src/ui/components/settings/settings-footer.tsx +170 -0
  51. package/src/ui/components/settings/settings-row.tsx +109 -33
  52. package/src/ui/components/settings/settings-search-bar.tsx +61 -0
  53. package/src/ui/components/settings/settings-view.tsx +163 -77
  54. package/src/ui/components/stats/aimux-page.tsx +245 -0
  55. package/src/ui/components/stats/chart.ts +157 -0
  56. package/src/ui/components/stats/day-facts.tsx +268 -0
  57. package/src/ui/components/stats/format.ts +98 -0
  58. package/src/ui/components/stats/heatmap.tsx +305 -0
  59. package/src/ui/components/stats/projects-page.tsx +293 -0
  60. package/src/ui/components/stats/quotas.tsx +210 -0
  61. package/src/ui/components/stats/shared.tsx +654 -0
  62. package/src/ui/components/stats/stats-view.tsx +153 -0
  63. package/src/ui/components/stats/usage-page.tsx +291 -0
  64. package/src/ui/components/stats/use-stats-data.ts +48 -0
  65. package/src/ui/root.tsx +7 -3
  66. package/src/ui/components/modals/app/ai-usage-modal.tsx +0 -520
@@ -4,6 +4,7 @@ import { createStore } from 'zustand/vanilla'
4
4
  import type { AppAction } from './actions'
5
5
  import type { AppState } from './types'
6
6
 
7
+ import { countAction } from '../services/aimux-counters/observe'
7
8
  import { appReducer, createInitialState } from './store'
8
9
 
9
10
  export interface AppStore extends AppState {
@@ -12,7 +13,17 @@ export interface AppStore extends AppState {
12
13
 
13
14
  export const appStore = createStore<AppStore>((set) => ({
14
15
  ...createInitialState(),
15
- dispatch: (action: AppAction) => set((state) => appReducer(state, action)),
16
+ // Counting happens here rather than inside the reducers, which stay pure —
17
+ // and on the outcome rather than the intent: an `add-tab` or a `split-pane`
18
+ // the reducer declines is not a tab or a split, and counting it would report
19
+ // something that never happened. Outside the `set` updater, which has to stay
20
+ // a pure function of the state it is handed.
21
+ dispatch: (action: AppAction) => {
22
+ const before = appStore.getState()
23
+ const after = appReducer(before, action)
24
+ if (after !== before) countAction(action)
25
+ set(after)
26
+ },
16
27
  }))
17
28
 
18
29
  export function useAppStore<T>(selector: (state: AppStore) => T): T {
@@ -62,9 +62,6 @@ function getCreateWorkspaceBaseOptions(state: AppState, queryOverride?: string):
62
62
  function getModalOptionCount(state: AppState): number {
63
63
  const { modal } = state
64
64
  switch (modal.type) {
65
- // Not a list: the two pages of the AI usage modal, Live and History.
66
- case 'ai-usage':
67
- return 2
68
65
  case 'create-project':
69
66
  return modal.directoryResults.length
70
67
  case 'help':
@@ -145,19 +142,6 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
145
142
  },
146
143
  }
147
144
  }
148
- case 'open-ai-usage-modal': {
149
- return {
150
- ...state,
151
- focusMode: 'modal',
152
- modal: {
153
- cursorPos: 0,
154
- editBuffer: '',
155
- projectTargetId: null,
156
- selectedIndex: 0,
157
- type: 'ai-usage',
158
- },
159
- }
160
- }
161
145
  case 'open-workspace-move-modal': {
162
146
  // Overlay: keep focusMode (git when opened via `m`, navigation when opened
163
147
  // from a tab menu) so the view underneath stays mounted, like the help
@@ -476,6 +460,18 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
476
460
  type: 'theme-picker',
477
461
  },
478
462
  }
463
+ case 'open-quotas-modal':
464
+ return {
465
+ ...state,
466
+ focusMode: 'modal',
467
+ modal: {
468
+ cursorPos: 0,
469
+ editBuffer: null,
470
+ projectTargetId: null,
471
+ selectedIndex: 0,
472
+ type: 'quotas',
473
+ },
474
+ }
479
475
  case 'open-update-available-modal':
480
476
  return {
481
477
  ...state,
@@ -540,7 +536,6 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
540
536
  return { ...state, modal: { ...state.modal, selectedIndex: nextIndex } }
541
537
  }
542
538
  if (
543
- state.modal.type !== 'ai-usage' &&
544
539
  state.modal.type !== 'new-tab' &&
545
540
  state.modal.type !== 'project-picker' &&
546
541
  state.modal.type !== 'snippet-picker' &&
@@ -1,10 +1,10 @@
1
1
  import type { AppAction } from '../actions'
2
2
  import type { AppState, SettingsUIState } from '../types'
3
3
 
4
- import { DEFAULT_SECTION_ID, getSectionRowCount, SETTING_SECTIONS } from '../../settings/sections'
4
+ import { sectionStartIndexes, totalRowCount } from '../../settings/sections'
5
5
 
6
6
  export function emptySettingsUI(): SettingsUIState {
7
- return { pane: 'nav', rowIndex: 0, sectionId: DEFAULT_SECTION_ID }
7
+ return { rowIndex: 0 }
8
8
  }
9
9
 
10
10
  function clamp(value: number, max: number): number {
@@ -12,68 +12,46 @@ function clamp(value: number, max: number): number {
12
12
  return Math.max(0, Math.min(max, value))
13
13
  }
14
14
 
15
- function withSettings(state: AppState, patch: Partial<SettingsUIState>): AppState {
16
- return { ...state, settings: { ...state.settings, ...patch } }
17
- }
18
-
19
- function moveSelection(state: AppState, delta: -1 | 1): AppState {
20
- const { pane, rowIndex, sectionId } = state.settings
21
-
22
- if (pane === 'nav') {
23
- const index = SETTING_SECTIONS.findIndex((section) => section.id === sectionId)
24
- const next = clamp((index === -1 ? 0 : index) + delta, SETTING_SECTIONS.length - 1)
25
- const nextId = SETTING_SECTIONS[next]?.id
26
- if (nextId == null || nextId === sectionId) return state
27
- // A new section has its own rows, so the row cursor from the old one means
28
- // nothing here.
29
- return withSettings(state, { rowIndex: 0, sectionId: nextId })
15
+ function withRowIndex(state: AppState, rowIndex: number): AppState {
16
+ return {
17
+ ...state,
18
+ settings: { ...state.settings, rowIndex: clamp(rowIndex, totalRowCount(state.projects) - 1) },
30
19
  }
20
+ }
31
21
 
32
- return withSettings(state, {
33
- rowIndex: clamp(rowIndex + delta, getSectionRowCount(sectionId, state.projects) - 1),
34
- })
22
+ /**
23
+ * The first row of the next section down, or of the one the cursor is already
24
+ * inside when going up and it is not on its first row — the paragraph motion `}`
25
+ * and `{` are named after, rather than a plain "section ± 1" that would skip the
26
+ * heading you were standing under.
27
+ */
28
+ function jumpSection(state: AppState, delta: -1 | 1): AppState {
29
+ const starts = sectionStartIndexes(state.projects)
30
+ const current = state.settings.rowIndex
31
+ const next =
32
+ delta === 1
33
+ ? starts.find((start) => start > current)
34
+ : starts.filter((start) => start < current).at(-1)
35
+ if (next === undefined) return state
36
+ return withRowIndex(state, next)
35
37
  }
36
38
 
37
39
  export function reduceSettingsState(state: AppState, action: AppAction): AppState | null {
38
40
  switch (action.type) {
39
41
  case 'enter-settings':
40
42
  if (state.focusMode === 'settings') return state
41
- // The section is remembered across a close/open within the session; the
42
- // cursor inside it is not reopening lands you at the top of it.
43
- return {
44
- ...state,
45
- focusMode: 'settings',
46
- settings: { ...state.settings, pane: 'nav', rowIndex: 0 },
47
- }
43
+ // Reopening lands at the top: the list is one column now, so the top of it
44
+ // is where the search and the first section both are.
45
+ return { ...state, focusMode: 'settings', settings: { ...state.settings, rowIndex: 0 } }
48
46
  case 'exit-settings':
49
47
  if (state.focusMode !== 'settings') return state
50
48
  return { ...state, focusMode: 'navigation' }
51
- case 'settings-focus-pane': {
52
- // A section with no rows has nothing to focus, so `l` stays put rather
53
- // than parking the cursor in an empty column.
54
- if (
55
- action.pane === 'rows' &&
56
- getSectionRowCount(state.settings.sectionId, state.projects) === 0
57
- ) {
58
- return state
59
- }
60
- return withSettings(state, { pane: action.pane })
61
- }
62
49
  case 'settings-move-selection':
63
- return moveSelection(state, action.delta)
64
- case 'settings-select-section':
65
- if (action.sectionId === state.settings.sectionId) {
66
- return withSettings(state, { pane: 'nav' })
67
- }
68
- return withSettings(state, { pane: 'nav', rowIndex: 0, sectionId: action.sectionId })
50
+ return withRowIndex(state, state.settings.rowIndex + action.delta)
51
+ case 'settings-jump-section':
52
+ return jumpSection(state, action.delta)
69
53
  case 'settings-select-row':
70
- return withSettings(state, {
71
- pane: 'rows',
72
- rowIndex: clamp(
73
- action.rowIndex,
74
- getSectionRowCount(state.settings.sectionId, state.projects) - 1
75
- ),
76
- })
54
+ return withRowIndex(state, action.rowIndex)
77
55
  default:
78
56
  return null
79
57
  }
@@ -0,0 +1,56 @@
1
+ import type { AppAction } from '../actions'
2
+ import type { AppState, StatsUIState } from '../types'
3
+
4
+ import { STATS_PAGES } from '../stats-pages'
5
+
6
+ export function emptyStatsUI(): StatsUIState {
7
+ return { pageIndex: 0, scrollTop: 0 }
8
+ }
9
+
10
+ function withStats(state: AppState, patch: Partial<StatsUIState>): AppState {
11
+ return { ...state, stats: { ...state.stats, ...patch } }
12
+ }
13
+
14
+ function clamp(value: number, max: number): number {
15
+ if (max < 0) return 0
16
+ return Math.max(0, Math.min(max, value))
17
+ }
18
+
19
+ export function reduceStatsState(state: AppState, action: AppAction): AppState | null {
20
+ switch (action.type) {
21
+ case 'enter-stats':
22
+ if (state.focusMode === 'stats') return state
23
+ // The page is remembered across a close/open within the session; the
24
+ // scroll inside it is not — reopening lands you at the top of it.
25
+ return { ...state, focusMode: 'stats', stats: { ...state.stats, scrollTop: 0 } }
26
+ case 'exit-stats':
27
+ if (state.focusMode !== 'stats') return state
28
+ return { ...state, focusMode: 'navigation' }
29
+ case 'stats-move-page': {
30
+ const pageIndex = clamp(state.stats.pageIndex + action.delta, STATS_PAGES.length - 1)
31
+ if (pageIndex === state.stats.pageIndex) return state
32
+ // A new page is a different length; carrying the old offset into it lands
33
+ // the viewport somewhere the user never scrolled to.
34
+ return withStats(state, { pageIndex, scrollTop: 0 })
35
+ }
36
+ case 'stats-select-page': {
37
+ const pageIndex = clamp(action.pageIndex, STATS_PAGES.length - 1)
38
+ if (pageIndex === state.stats.pageIndex) return state
39
+ return withStats(state, { pageIndex, scrollTop: 0 })
40
+ }
41
+ case 'stats-scroll':
42
+ // No upper clamp here: the reducer does not know how tall the rendered
43
+ // page is. The scrollbox is what bounds it, and `stats-scroll-settled`
44
+ // brings its answer back — without that round trip this offset climbs for
45
+ // as long as the key is held and the way back up is a dead zone the same
46
+ // length.
47
+ return withStats(state, { scrollTop: Math.max(0, state.stats.scrollTop + action.delta) })
48
+ case 'stats-scroll-settled': {
49
+ const scrollTop = Math.max(0, action.scrollTop)
50
+ if (scrollTop === state.stats.scrollTop) return state
51
+ return withStats(state, { scrollTop })
52
+ }
53
+ default:
54
+ return null
55
+ }
56
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The stats screen's pages, in nav order.
3
+ *
4
+ * Lives in the state layer rather than beside the view because the reducer is
5
+ * what clamps the page cursor: the list of pages is a bound, and a bound the
6
+ * reducer cannot see is a bound it cannot enforce.
7
+ *
8
+ * Three pages, not one per topic. Quotas, token cost and the calendar all answer
9
+ * "how much am I using this", so they share a page; everything else earns its
10
+ * own by being a different question.
11
+ *
12
+ * Records used to be a fourth. A page of eight one-line trivia rows was mostly
13
+ * empty, and every row was a record *of* something another page already covers —
14
+ * so each one now closes the page whose data it comes from.
15
+ */
16
+
17
+ export interface StatsPage {
18
+ glyph: string
19
+ id: StatsPageId
20
+ label: string
21
+ }
22
+
23
+ export type StatsPageId = 'aimux' | 'projects' | 'usage'
24
+
25
+ export const STATS_PAGES: readonly StatsPage[] = [
26
+ { glyph: '\u{25D4}', id: 'usage', label: 'Usage' },
27
+ { glyph: '\u{2632}', id: 'projects', label: 'Projects' },
28
+ { glyph: '\u{2318}', id: 'aimux', label: 'aimux' },
29
+ ]
30
+
31
+ export function statsPageAt(index: number): StatsPage {
32
+ return STATS_PAGES[index] ?? STATS_PAGES[0] ?? { glyph: '\u{25D4}', id: 'usage', label: 'Usage' }
33
+ }
@@ -8,6 +8,7 @@ import { emptyModal, reduceModalState } from './reducers/modal-state'
8
8
  import { reduceMultiRepoState } from './reducers/multi-repo-state'
9
9
  import { reduceProjectState } from './reducers/project-state'
10
10
  import { emptySettingsUI, reduceSettingsState } from './reducers/settings-state'
11
+ import { emptyStatsUI, reduceStatsState } from './reducers/stats-state'
11
12
  import { reduceTabState } from './reducers/tab-state'
12
13
  import { reduceUIState } from './reducers/ui-state'
13
14
  import { filterSnippets } from './selectors'
@@ -139,6 +140,7 @@ export function createInitialState(
139
140
  projectStatuses: {},
140
141
  settings: emptySettingsUI(),
141
142
  snippets,
143
+ stats: emptyStatsUI(),
142
144
  tabGroupMap: {},
143
145
  tabs: [],
144
146
  workspaceActivity: {},
@@ -174,6 +176,9 @@ export function appReducer(state: AppState, action: AppAction): AppState {
174
176
  const settingsState = reduceSettingsState(state, action)
175
177
  if (settingsState) return settingsState
176
178
 
179
+ const statsState = reduceStatsState(state, action)
180
+ if (statsState) return statsState
181
+
177
182
  switch (action.type) {
178
183
  case 'set-snippets':
179
184
  return { ...state, snippets: action.snippets }
@@ -69,6 +69,7 @@ export type FocusMode =
69
69
  | 'command-edit'
70
70
  | 'git'
71
71
  | 'settings'
72
+ | 'stats'
72
73
 
73
74
  export type ModalType =
74
75
  | 'new-tab'
@@ -85,7 +86,7 @@ export type ModalType =
85
86
  | 'split-picker'
86
87
  | 'git-commit'
87
88
  | 'update-available'
88
- | 'ai-usage'
89
+ | 'quotas'
89
90
  | 'workspace-move'
90
91
  | 'workspace-move-confirm'
91
92
  | 'workspace-delete-confirm'
@@ -580,8 +581,9 @@ export interface ModalUpdateAvailable extends ModalBase {
580
581
  latestVersion: string
581
582
  }
582
583
 
583
- export interface ModalAIUsage extends ModalBase {
584
- type: 'ai-usage'
584
+ /** The status bar's usage indicator, expanded. Carries nothing: it is a readout. */
585
+ export interface ModalQuotas extends ModalBase {
586
+ type: 'quotas'
585
587
  }
586
588
 
587
589
  export interface ModalWorkspaceMove extends ModalBase {
@@ -686,7 +688,7 @@ export type ModalState =
686
688
  | ModalSnippetEditor
687
689
  | ModalGitCommit
688
690
  | ModalUpdateAvailable
689
- | ModalAIUsage
691
+ | ModalQuotas
690
692
  | ModalWorkspaceMove
691
693
  | ModalWorkspaceMoveConfirm
692
694
  | ModalWorkspaceDeleteConfirm
@@ -732,12 +734,24 @@ export const EMPTY_MULTI_REPO_STATE: MultiRepoState = { prefixes: {}, repos: []
732
734
  * two of them.
733
735
  */
734
736
  export interface SettingsUIState {
735
- sectionId: string
736
- /** Which of the two columns has the keyboard: the section list, or its rows. */
737
- pane: 'nav' | 'rows'
737
+ /**
738
+ * Where the cursor is in the screen's one list, counted across every section.
739
+ * The sections are headings in that list, not a column you move into, so this
740
+ * is the whole of the screen's state.
741
+ */
738
742
  rowIndex: number
739
743
  }
740
744
 
745
+ /**
746
+ * Where the cursor is on the stats screen. Nothing measured lives here — the
747
+ * numbers are read from disk by the pages that render them.
748
+ */
749
+ export interface StatsUIState {
750
+ pageIndex: number
751
+ /** Rows scrolled from the top of the current page. Reset when the page changes. */
752
+ scrollTop: number
753
+ }
754
+
741
755
  export interface AppState {
742
756
  tabs: TabSession[]
743
757
  activeTabId: string | null
@@ -759,6 +773,7 @@ export interface AppState {
759
773
  autoCommit: AutoCommitState
760
774
  multiRepo: MultiRepoState
761
775
  settings: SettingsUIState
776
+ stats: StatsUIState
762
777
  /**
763
778
  * Commits each workspace's branch is ahead/behind the ref it forked from,
764
779
  * keyed by workspace id. Ephemeral (polled); not persisted to the catalog.
@@ -37,6 +37,19 @@ const HEADER_TITLE = 'Projects'
37
37
  * the one button that opens the settings.
38
38
  */
39
39
  const SETTINGS_GLYPH = '⚙'
40
+ /**
41
+ * U+25A4, chosen on the same rule as the gear above: one cell, text presentation,
42
+ * present in the base fonts. A ▁▄█ mini bar chart reads better but is three cells
43
+ * wide, which pushes this row past a narrow sidebar.
44
+ */
45
+ const STATS_GLYPH = '▤'
46
+ const SETTINGS_LABEL = `${SETTINGS_GLYPH} Settings`
47
+ const STATS_LABEL = `${STATS_GLYPH} Stats`
48
+ /** The two entries and the gap between them, so a renamed label re-measures itself. */
49
+ const FOOTER_GAP = 2
50
+ const FOOTER_FULL_WIDTH = SETTINGS_LABEL.length + FOOTER_GAP + STATS_LABEL.length
51
+ /** The row's own left and right padding. */
52
+ const FOOTER_PAD = 2
40
53
 
41
54
  interface DragState {
42
55
  id: string
@@ -183,7 +196,19 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
183
196
  dispatchGlobal({ type: 'enter-settings' })
184
197
  }, [])
185
198
 
199
+ // Stats sits beside it for the same reason, and because the two are the only
200
+ // full-screen views the panes step aside for that a mouse can reach at all.
201
+ const handleOpenStats = useCallback((e: OtuiMouseEvent) => {
202
+ e.stopPropagation()
203
+ e.preventDefault()
204
+ dispatchGlobal({ type: 'enter-stats' })
205
+ }, [])
206
+
186
207
  const rule = RULE.repeat(Math.max(1, contentWidth))
208
+ // The bar clamps down to 18 columns, narrower than both labels together, so
209
+ // below that the second entry drops to its glyph rather than being sliced
210
+ // mid-word by the overflow.
211
+ const statsLabel = contentWidth - FOOTER_PAD >= FOOTER_FULL_WIDTH ? STATS_LABEL : STATS_GLYPH
187
212
 
188
213
  return (
189
214
  // Drag and release are handled here, not on the row that started them:
@@ -293,9 +318,15 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
293
318
  {rule}
294
319
  </text>
295
320
  </box>
321
+ {/* Each entry is its own <text>, with a spacer box between them: one string
322
+ holding both would make the whole footer a single click target. */}
296
323
  <box flexDirection="row" flexShrink={0} paddingLeft={1} paddingRight={1}>
297
324
  <text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleOpenSettings}>
298
- {`${SETTINGS_GLYPH} Settings`}
325
+ {SETTINGS_LABEL}
326
+ </text>
327
+ <box width={FOOTER_GAP} flexShrink={1} />
328
+ <text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleOpenStats}>
329
+ {statsLabel}
299
330
  </text>
300
331
  </box>
301
332
  </box>
@@ -0,0 +1,42 @@
1
+ import { QuotaWindows } from '../../stats/quotas'
2
+ import { ModalShell } from '../shared/modal-shell'
3
+
4
+ /**
5
+ * The quota windows, and nothing else.
6
+ *
7
+ * What the status-bar indicator is a summary of: clicking it asks "how much is
8
+ * left", which is one block of the stats screen. Opening the whole screen for
9
+ * that made the reader find the answer among four other sections and then find
10
+ * their way back. The screen is still there, behind the Stats button.
11
+ */
12
+
13
+ /**
14
+ * Wider than the shell's usual sizes, and deliberately so: at 56 the reset times
15
+ * end a column from the border and the block reads as cramped. This one is a
16
+ * readout, not a list of choices — it can afford the air.
17
+ */
18
+ const WIDTH = 76
19
+ /** The shell's border and its one column of padding, both sides. */
20
+ const CHROME = 4
21
+
22
+ export function QuotasModal() {
23
+ // Read per render like the stats page does: the store pushes a new snapshot
24
+ // and the relative times ("1m ago") are measured against now, not against
25
+ // when the modal happened to open.
26
+ const now = new Date()
27
+
28
+ return (
29
+ <ModalShell
30
+ title="Quotas"
31
+ subtitle="live"
32
+ keybindsModeId="modal.quotas"
33
+ width={WIDTH}
34
+ listGap={1}
35
+ >
36
+ {/* No projection here. `empty ~Fri 13:02` is worth the width on a page
37
+ being read; on a glance at what is left it is a second timestamp to
38
+ parse next to the one that matters. */}
39
+ <QuotaWindows now={now} projection={false} width={WIDTH - CHROME} />
40
+ </ModalShell>
41
+ )
42
+ }
@@ -18,11 +18,11 @@ export function AIUsageIndicator() {
18
18
  const enabled = useAIUsageStore((s) => s.enabled)
19
19
  const snapshots = useAIUsageStore((s) => s.snapshots)
20
20
 
21
- const openModal = useCallback(
21
+ const openQuotas = useCallback(
22
22
  (e: { preventDefault: () => void; stopPropagation: () => void }) => {
23
23
  e.preventDefault()
24
24
  e.stopPropagation()
25
- dispatchGlobal({ type: 'open-ai-usage-modal' })
25
+ dispatchGlobal({ type: 'open-quotas-modal' })
26
26
  },
27
27
  []
28
28
  )
@@ -36,7 +36,7 @@ export function AIUsageIndicator() {
36
36
 
37
37
  if (entries.length === 0) {
38
38
  return (
39
- <box flexDirection="row" onMouseDown={openModal}>
39
+ <box flexDirection="row" onMouseDown={openQuotas}>
40
40
  <text fg={t.textMuted} selectable={false}>
41
41
 
42
42
  </text>
@@ -45,7 +45,7 @@ export function AIUsageIndicator() {
45
45
  }
46
46
 
47
47
  return (
48
- <box flexDirection="row" gap={2} onMouseDown={openModal}>
48
+ <box flexDirection="row" gap={2} onMouseDown={openQuotas}>
49
49
  {entries.map(({ snap, tool }) => {
50
50
  if (!snap) return null
51
51
 
@@ -5,6 +5,7 @@ import type { SettingRow, SettingValue } from '../../../settings/types'
5
5
  import { readRow, useSettingsStore } from '../../../settings/settings-store'
6
6
  import { useAppStore } from '../../../state/app-store'
7
7
  import { type ResolvedTuiTheme, useTheme } from '../../theme'
8
+ import { truncate } from '../../truncate'
8
9
 
9
10
  /** Filled means on. Both are one cell wide in a Latin-width terminal. */
10
11
  const ON = '●'
@@ -51,11 +52,17 @@ export function describeValue(row: SettingRow, value: SettingValue): string {
51
52
  }
52
53
  }
53
54
 
55
+ /**
56
+ * Text wears text tokens, the way it does on the stats screen — a value is not a
57
+ * status. The two exceptions earn it: a toggle whose colour *is* its state, and
58
+ * an action row, where the colour is the affordance saying it does something.
59
+ */
54
60
  function valueColor(row: SettingRow, value: SettingValue, t: ResolvedTuiTheme): string {
55
61
  // A toggle is read at a glance or not at all, so its state is the colour as much
56
62
  // as the glyph: lit when on, as quiet as the rest of the row when off.
57
63
  if (row.kind === 'toggle') return value === true ? t.success : t.textMuted
58
- return row.kind === 'info' ? t.textMuted : t.primary
64
+ if (row.kind === 'action') return t.primary
65
+ return row.kind === 'info' ? t.textMuted : t.text
59
66
  }
60
67
 
61
68
  /**
@@ -66,9 +73,21 @@ function valueColor(row: SettingRow, value: SettingValue, t: ResolvedTuiTheme):
66
73
  * renders nothing. A parent subscribing to the whole store for every row in the
67
74
  * list would repaint it at the rate the terminals print.
68
75
  */
69
- export const RowValue = memo(function RowValue({ row }: { row: SettingRow }) {
76
+ export const RowValue = memo(function RowValue({
77
+ maxWidth,
78
+ row,
79
+ }: {
80
+ /** Cropped rather than wrapped: a row that grows a line shifts the list. */
81
+ maxWidth?: number
82
+ row: SettingRow
83
+ }) {
70
84
  const t = useTheme()
71
85
  const values = useSettingsStore((s) => s.values)
72
86
  const value = useAppStore((s) => readRow(row, { state: s, values }))
73
- return <text fg={valueColor(row, value, t)}>{formatValue(row, value)}</text>
87
+ const text = formatValue(row, value)
88
+ return (
89
+ <text fg={valueColor(row, value, t)} wrapMode="none">
90
+ {maxWidth === undefined ? text : truncate(text, maxWidth)}
91
+ </text>
92
+ )
74
93
  })