@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.
- package/package.json +2 -2
- package/src/app-runtime/settings-actions.ts +24 -13
- package/src/app-runtime/side-effects.ts +5 -0
- package/src/app-runtime/use-mouse-handlers.ts +2 -0
- package/src/app.tsx +6 -0
- package/src/index.tsx +6 -0
- package/src/input/keymap/help-entries.ts +1 -0
- package/src/input/modes/bridge.ts +2 -1
- package/src/input/modes/transitions.ts +7 -3
- package/src/input/modes/types.ts +2 -1
- package/src/restart-daemon.ts +5 -0
- package/src/services/ai-usage/projection.ts +44 -0
- package/src/services/aimux-counters/index.ts +87 -0
- package/src/services/aimux-counters/observe.ts +39 -0
- package/src/services/aimux-counters/store.ts +150 -0
- package/src/services/aimux-counters/summary.ts +78 -0
- package/src/services/usage-history/cost.ts +149 -0
- package/src/services/usage-history/insights.ts +314 -0
- package/src/services/usage-history/rollup.ts +78 -6
- package/src/services/usage-history/stats.ts +66 -35
- package/src/services/usage-history/store.ts +128 -9
- package/src/settings/search.ts +13 -9
- package/src/settings/sections/about.ts +1 -0
- package/src/settings/sections/appearance.ts +1 -0
- package/src/settings/sections/automation.ts +1 -0
- package/src/settings/sections/commands.ts +1 -0
- package/src/settings/sections/editor.ts +1 -0
- package/src/settings/sections/experimental.ts +1 -0
- package/src/settings/sections/git.ts +1 -0
- package/src/settings/sections/index.ts +23 -12
- package/src/settings/sections/integrations.ts +1 -0
- package/src/settings/sections/layout.ts +1 -0
- package/src/settings/sections/notifications.ts +1 -0
- package/src/settings/sections/setup.ts +1 -0
- package/src/settings/sections/status-bar.ts +1 -0
- package/src/settings/sections/workspace.ts +1 -0
- package/src/settings/types.ts +10 -0
- package/src/state/actions.ts +13 -3
- package/src/state/app-store.ts +12 -1
- package/src/state/reducers/modal-state.ts +12 -17
- package/src/state/reducers/settings-state.ts +29 -51
- package/src/state/reducers/stats-state.ts +56 -0
- package/src/state/stats-pages.ts +33 -0
- package/src/state/store.ts +5 -0
- package/src/state/types.ts +22 -7
- package/src/ui/components/layout/sidebar/project-list.tsx +32 -1
- package/src/ui/components/modals/app/quotas-modal.tsx +42 -0
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +4 -4
- package/src/ui/components/settings/row-value.tsx +22 -3
- package/src/ui/components/settings/settings-footer.tsx +170 -0
- package/src/ui/components/settings/settings-row.tsx +109 -33
- package/src/ui/components/settings/settings-search-bar.tsx +61 -0
- package/src/ui/components/settings/settings-view.tsx +163 -77
- package/src/ui/components/stats/aimux-page.tsx +245 -0
- package/src/ui/components/stats/chart.ts +157 -0
- package/src/ui/components/stats/day-facts.tsx +268 -0
- package/src/ui/components/stats/format.ts +98 -0
- package/src/ui/components/stats/heatmap.tsx +305 -0
- package/src/ui/components/stats/projects-page.tsx +293 -0
- package/src/ui/components/stats/quotas.tsx +210 -0
- package/src/ui/components/stats/shared.tsx +654 -0
- package/src/ui/components/stats/stats-view.tsx +153 -0
- package/src/ui/components/stats/usage-page.tsx +291 -0
- package/src/ui/components/stats/use-stats-data.ts +48 -0
- package/src/ui/root.tsx +7 -3
- package/src/ui/components/modals/app/ai-usage-modal.tsx +0 -520
package/src/state/app-store.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
4
|
+
import { sectionStartIndexes, totalRowCount } from '../../settings/sections'
|
|
5
5
|
|
|
6
6
|
export function emptySettingsUI(): SettingsUIState {
|
|
7
|
-
return {
|
|
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
|
|
16
|
-
return {
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
//
|
|
42
|
-
//
|
|
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
|
|
64
|
-
case 'settings-
|
|
65
|
-
|
|
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
|
|
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
|
+
}
|
package/src/state/store.ts
CHANGED
|
@@ -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 }
|
package/src/state/types.ts
CHANGED
|
@@ -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
|
-
| '
|
|
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
|
-
|
|
584
|
-
|
|
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
|
-
|
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
{
|
|
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
|
|
21
|
+
const openQuotas = useCallback(
|
|
22
22
|
(e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
|
23
23
|
e.preventDefault()
|
|
24
24
|
e.stopPropagation()
|
|
25
|
-
dispatchGlobal({ type: 'open-
|
|
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={
|
|
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={
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
})
|