@brimveyn/aimux 1.7.3 → 1.8.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.
- package/README.md +27 -15
- package/package.json +2 -2
- package/src/app-runtime/auto-commit-driver.ts +286 -0
- package/src/app-runtime/auto-commit-ref.ts +20 -0
- package/src/app-runtime/backend-attach-runtime.ts +3 -1
- package/src/app-runtime/session-actions.ts +7 -2
- package/src/app-runtime/side-effects.ts +111 -4
- package/src/app-runtime/split-drag-controller.ts +31 -1
- package/src/app-runtime/use-auto-commit-driver.ts +133 -0
- package/src/app-runtime/use-mouse-handlers.ts +93 -5
- package/src/app-runtime/use-terminal-resize.ts +24 -16
- package/src/app.tsx +71 -19
- package/src/auto-commit/default-auto-commit-prompt.md +46 -0
- package/src/auto-commit/headless-commands.ts +40 -0
- package/src/auto-commit/output-parser.ts +21 -0
- package/src/auto-commit/prompt-loader.ts +33 -0
- package/src/auto-commit/staging-mode.ts +5 -0
- package/src/auto-commit/strip-ansi.ts +13 -0
- package/src/auto-commit/suggestion-runner.ts +55 -0
- package/src/auto-commit/working-tree-hash.ts +24 -0
- package/src/config.ts +45 -2
- package/src/daemon/session-registry.ts +1 -0
- package/src/index.tsx +1 -1
- package/src/input/keymap/help-entries.ts +4 -4
- package/src/input/modes/bridge.ts +6 -0
- package/src/input/modes/transitions.ts +3 -1
- package/src/input/modes/types.ts +4 -0
- package/src/ipc/manager-protocol.ts +2 -2
- package/src/ipc/protocol.ts +2 -8
- package/src/pty/assistant-status-detector.ts +1 -1
- package/src/pty/terminal-snapshot.ts +38 -4
- package/src/services/ai-usage/adapters/claude.ts +139 -0
- package/src/services/ai-usage/adapters/codex.ts +191 -0
- package/src/services/ai-usage/provider.ts +84 -0
- package/src/services/ai-usage/spawn.ts +49 -0
- package/src/services/ai-usage/types.ts +20 -0
- package/src/session-backend/local-session-backend.ts +10 -2
- package/src/state/ai-usage-store.ts +29 -0
- package/src/state/git-pane-sizing.ts +15 -0
- package/src/state/reducers/auto-commit-state.ts +59 -0
- package/src/state/reducers/git-panel-state.ts +12 -7
- package/src/state/reducers/modal-state.ts +106 -2
- package/src/state/reducers/session-state.ts +26 -14
- package/src/state/reducers/ui-state.ts +6 -0
- package/src/state/session-persistence.ts +14 -5
- package/src/state/store.ts +26 -15
- package/src/state/types.ts +59 -3
- package/src/state/workspace-save.ts +6 -1
- package/src/ui/ai-usage/controller.ts +35 -0
- package/src/ui/components/ai-usage-indicator.tsx +131 -0
- package/src/ui/components/ai-usage-popover.tsx +152 -0
- package/src/ui/components/context-menu-overlay.tsx +4 -2
- package/src/ui/components/create-session-modal.tsx +2 -2
- package/src/ui/components/git-commit-modal.tsx +167 -18
- package/src/ui/components/git-pane-context-menu.ts +26 -0
- package/src/ui/components/session-bar.tsx +2 -2
- package/src/ui/components/session-picker-modal.tsx +4 -4
- package/src/ui/components/sidebar.tsx +117 -31
- package/src/ui/components/status-bar.tsx +2 -0
- package/src/ui/components/terminal-pane.tsx +18 -3
- package/src/ui/root.tsx +114 -13
- package/src/ui/status-bar-model.ts +1 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { AIUsageToolConfig } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import type { UsageSnapshot } from '../types'
|
|
8
|
+
|
|
9
|
+
interface CodexAuthFile {
|
|
10
|
+
tokens?: {
|
|
11
|
+
access_token?: string
|
|
12
|
+
account_id?: string
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface WindowSnapshot {
|
|
17
|
+
used_percent?: number
|
|
18
|
+
reset_at?: number
|
|
19
|
+
limit_window_seconds?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CodexUsageResponse {
|
|
23
|
+
plan_type?: string
|
|
24
|
+
rate_limit?: {
|
|
25
|
+
primary_window?: WindowSnapshot | null
|
|
26
|
+
secondary_window?: WindowSnapshot | null
|
|
27
|
+
}
|
|
28
|
+
credits?: {
|
|
29
|
+
has_credits?: boolean
|
|
30
|
+
unlimited?: boolean
|
|
31
|
+
balance?: number | string | null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_CHATGPT_BASE = 'https://chatgpt.com/backend-api'
|
|
36
|
+
const USAGE_PATH = '/wham/usage'
|
|
37
|
+
const AUTH_TIMEOUT_MS = 15_000
|
|
38
|
+
|
|
39
|
+
function codexHome(): string {
|
|
40
|
+
const env = process.env.CODEX_HOME?.trim()
|
|
41
|
+
if (env) return env
|
|
42
|
+
return join(homedir(), '.codex')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseChatGPTBaseFromConfig(contents: string): string | null {
|
|
46
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
47
|
+
const line = rawLine.split('#', 1)[0]?.trim() ?? ''
|
|
48
|
+
if (!line) continue
|
|
49
|
+
const eq = line.indexOf('=')
|
|
50
|
+
if (eq < 0) continue
|
|
51
|
+
const key = line.slice(0, eq).trim()
|
|
52
|
+
if (key !== 'chatgpt_base_url') continue
|
|
53
|
+
let value = line.slice(eq + 1).trim()
|
|
54
|
+
if (
|
|
55
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
56
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
57
|
+
) {
|
|
58
|
+
value = value.slice(1, -1)
|
|
59
|
+
}
|
|
60
|
+
return value.trim()
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeBase(value: string): string {
|
|
66
|
+
let trimmed = value.trim() || DEFAULT_CHATGPT_BASE
|
|
67
|
+
while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1)
|
|
68
|
+
if (
|
|
69
|
+
(trimmed.startsWith('https://chatgpt.com') || trimmed.startsWith('https://chat.openai.com')) &&
|
|
70
|
+
!trimmed.includes('/backend-api')
|
|
71
|
+
) {
|
|
72
|
+
trimmed += '/backend-api'
|
|
73
|
+
}
|
|
74
|
+
return trimmed
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function resolveUsageURL(): Promise<string> {
|
|
78
|
+
let base = DEFAULT_CHATGPT_BASE
|
|
79
|
+
try {
|
|
80
|
+
const contents = await readFile(join(codexHome(), 'config.toml'), 'utf8')
|
|
81
|
+
const parsed = parseChatGPTBaseFromConfig(contents)
|
|
82
|
+
if (parsed) base = parsed
|
|
83
|
+
} catch {
|
|
84
|
+
// no config.toml or unreadable — fall back to default
|
|
85
|
+
}
|
|
86
|
+
const normalized = normalizeBase(base)
|
|
87
|
+
const path = normalized.includes('/backend-api') ? USAGE_PATH : '/api/codex/usage'
|
|
88
|
+
return normalized + path
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function loadAuth(): Promise<{ accessToken: string; accountId: string | null }> {
|
|
92
|
+
const raw = await readFile(join(codexHome(), 'auth.json'), 'utf8')
|
|
93
|
+
const parsed = JSON.parse(raw) as CodexAuthFile
|
|
94
|
+
const accessToken = parsed.tokens?.access_token
|
|
95
|
+
if (!accessToken) {
|
|
96
|
+
throw new Error('no access_token in ~/.codex/auth.json — run `codex` to sign in')
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
accessToken,
|
|
100
|
+
accountId: parsed.tokens?.account_id ?? null,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatRemainingFromReset(resetAtSeconds: number | undefined): string | null {
|
|
105
|
+
if (!resetAtSeconds) return null
|
|
106
|
+
const diffMs = resetAtSeconds * 1000 - Date.now()
|
|
107
|
+
if (diffMs <= 0) return null
|
|
108
|
+
const totalMin = Math.round(diffMs / 60_000)
|
|
109
|
+
const h = Math.floor(totalMin / 60)
|
|
110
|
+
const m = totalMin % 60
|
|
111
|
+
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}h`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function pickPrimaryWindow(rate: CodexUsageResponse['rate_limit']): WindowSnapshot | null {
|
|
115
|
+
if (!rate) return null
|
|
116
|
+
// Primary = 5h session window. Pick the one closest to 300 minutes (18000s),
|
|
117
|
+
// falling back to whichever is defined.
|
|
118
|
+
const windows = [rate.primary_window, rate.secondary_window].filter(
|
|
119
|
+
(w): w is WindowSnapshot => !!w
|
|
120
|
+
)
|
|
121
|
+
if (windows.length === 0) return null
|
|
122
|
+
const sessionWindow = windows.find((w) => w.limit_window_seconds === 18_000)
|
|
123
|
+
return sessionWindow ?? windows[0] ?? null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function fetchCodexUsage(_config: AIUsageToolConfig): Promise<UsageSnapshot> {
|
|
127
|
+
const now = new Date().toISOString()
|
|
128
|
+
const base: UsageSnapshot = {
|
|
129
|
+
burnRatePerHour: null,
|
|
130
|
+
costUSD: null,
|
|
131
|
+
lastUpdated: now,
|
|
132
|
+
percent: null,
|
|
133
|
+
resetAt: null,
|
|
134
|
+
timeRemaining: null,
|
|
135
|
+
tokens: { cache: 0, input: 0, output: 0, total: 0 },
|
|
136
|
+
tool: 'codex',
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const { accessToken, accountId } = await loadAuth()
|
|
141
|
+
const url = await resolveUsageURL()
|
|
142
|
+
|
|
143
|
+
const headers: Record<string, string> = {
|
|
144
|
+
'Accept': 'application/json',
|
|
145
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
146
|
+
'User-Agent': 'aimux',
|
|
147
|
+
}
|
|
148
|
+
if (accountId) headers['ChatGPT-Account-Id'] = accountId
|
|
149
|
+
|
|
150
|
+
const controller = new AbortController()
|
|
151
|
+
const timer = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS)
|
|
152
|
+
let response: Response
|
|
153
|
+
try {
|
|
154
|
+
response = await fetch(url, { headers, signal: controller.signal })
|
|
155
|
+
} finally {
|
|
156
|
+
clearTimeout(timer)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (response.status === 401 || response.status === 403) {
|
|
160
|
+
return { ...base, error: 'codex oauth expired — run `codex` to re-auth' }
|
|
161
|
+
}
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
return { ...base, error: `codex api ${response.status}` }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const parsed = (await response.json()) as CodexUsageResponse
|
|
167
|
+
const window = pickPrimaryWindow(parsed.rate_limit)
|
|
168
|
+
if (!window) {
|
|
169
|
+
return { ...base, error: 'no rate_limit data' }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const percent =
|
|
173
|
+
typeof window.used_percent === 'number'
|
|
174
|
+
? Math.max(0, Math.min(100, window.used_percent))
|
|
175
|
+
: null
|
|
176
|
+
const resetAt = window.reset_at ? new Date(window.reset_at * 1000).toISOString() : null
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
...base,
|
|
180
|
+
percent,
|
|
181
|
+
resetAt,
|
|
182
|
+
timeRemaining: formatRemainingFromReset(window.reset_at),
|
|
183
|
+
tool: 'codex',
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return {
|
|
187
|
+
...base,
|
|
188
|
+
error: error instanceof Error ? error.message : String(error),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { AIUsageTool, AIUsageToolConfig } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import type { UsageSnapshot } from './types'
|
|
4
|
+
|
|
5
|
+
import { fetchClaudeUsage } from './adapters/claude'
|
|
6
|
+
import { fetchCodexUsage } from './adapters/codex'
|
|
7
|
+
|
|
8
|
+
const DEFAULT_POLL_SECONDS = 60
|
|
9
|
+
const DEFAULT_TOOLS: AIUsageTool[] = ['claude', 'codex']
|
|
10
|
+
|
|
11
|
+
export interface AIUsageServiceHandle {
|
|
12
|
+
stop: () => void
|
|
13
|
+
refresh: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function fetchFor(tool: AIUsageTool, config: AIUsageToolConfig): Promise<UsageSnapshot> {
|
|
17
|
+
switch (tool) {
|
|
18
|
+
case 'claude':
|
|
19
|
+
return fetchClaudeUsage(config)
|
|
20
|
+
case 'codex':
|
|
21
|
+
return fetchCodexUsage(config)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function startAIUsageService(
|
|
26
|
+
config: AIUsageToolConfig,
|
|
27
|
+
onUpdate: (snap: UsageSnapshot) => void
|
|
28
|
+
): AIUsageServiceHandle {
|
|
29
|
+
const tools = config.tools && config.tools.length > 0 ? config.tools : DEFAULT_TOOLS
|
|
30
|
+
const pollMs = Math.max(5, config.pollSeconds ?? DEFAULT_POLL_SECONDS) * 1000
|
|
31
|
+
|
|
32
|
+
let stopped = false
|
|
33
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
34
|
+
|
|
35
|
+
const tick = async (): Promise<void> => {
|
|
36
|
+
if (stopped) return
|
|
37
|
+
const results = await Promise.allSettled(tools.map((t) => fetchFor(t, config)))
|
|
38
|
+
if (stopped) return
|
|
39
|
+
for (let i = 0; i < results.length; i++) {
|
|
40
|
+
const result = results[i]
|
|
41
|
+
const tool = tools[i]
|
|
42
|
+
if (!result || !tool) continue
|
|
43
|
+
if (result.status === 'fulfilled') {
|
|
44
|
+
onUpdate(result.value)
|
|
45
|
+
} else {
|
|
46
|
+
onUpdate({
|
|
47
|
+
burnRatePerHour: null,
|
|
48
|
+
costUSD: null,
|
|
49
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
50
|
+
lastUpdated: new Date().toISOString(),
|
|
51
|
+
percent: null,
|
|
52
|
+
resetAt: null,
|
|
53
|
+
timeRemaining: null,
|
|
54
|
+
tokens: { cache: 0, input: 0, output: 0, total: 0 },
|
|
55
|
+
tool,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!stopped) {
|
|
60
|
+
timer = setTimeout(() => {
|
|
61
|
+
void tick()
|
|
62
|
+
}, pollMs)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
void tick()
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
refresh: () => {
|
|
70
|
+
if (timer) {
|
|
71
|
+
clearTimeout(timer)
|
|
72
|
+
timer = null
|
|
73
|
+
}
|
|
74
|
+
void tick()
|
|
75
|
+
},
|
|
76
|
+
stop: () => {
|
|
77
|
+
stopped = true
|
|
78
|
+
if (timer) {
|
|
79
|
+
clearTimeout(timer)
|
|
80
|
+
timer = null
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
interface CliResult {
|
|
2
|
+
ok: boolean
|
|
3
|
+
stdout: string
|
|
4
|
+
stderr: string
|
|
5
|
+
error?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 15_000
|
|
9
|
+
|
|
10
|
+
export async function runCli(
|
|
11
|
+
command: string,
|
|
12
|
+
args: string[],
|
|
13
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS
|
|
14
|
+
): Promise<CliResult> {
|
|
15
|
+
const proc = Bun.spawn([command, ...args], {
|
|
16
|
+
stderr: 'pipe',
|
|
17
|
+
stdin: 'ignore',
|
|
18
|
+
stdout: 'pipe',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const timeout = setTimeout(() => {
|
|
22
|
+
try {
|
|
23
|
+
proc.kill()
|
|
24
|
+
} catch {
|
|
25
|
+
// process already gone
|
|
26
|
+
}
|
|
27
|
+
}, timeoutMs)
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
31
|
+
new Response(proc.stdout).text(),
|
|
32
|
+
new Response(proc.stderr).text(),
|
|
33
|
+
proc.exited,
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
if (exitCode !== 0) {
|
|
37
|
+
return {
|
|
38
|
+
error: `${command} exit ${exitCode}: ${stderr.trim().slice(0, 200)}`,
|
|
39
|
+
ok: false,
|
|
40
|
+
stderr,
|
|
41
|
+
stdout,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { ok: true, stderr, stdout }
|
|
46
|
+
} finally {
|
|
47
|
+
clearTimeout(timeout)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AIUsageTool } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
export type { AIUsageTool }
|
|
4
|
+
|
|
5
|
+
export interface UsageSnapshot {
|
|
6
|
+
tool: AIUsageTool
|
|
7
|
+
percent: number | null
|
|
8
|
+
tokens: {
|
|
9
|
+
input: number
|
|
10
|
+
output: number
|
|
11
|
+
cache: number
|
|
12
|
+
total: number
|
|
13
|
+
}
|
|
14
|
+
costUSD: number | null
|
|
15
|
+
resetAt: string | null
|
|
16
|
+
timeRemaining: string | null
|
|
17
|
+
burnRatePerHour: number | null
|
|
18
|
+
lastUpdated: string
|
|
19
|
+
error?: string
|
|
20
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getSnapshotTrees,
|
|
13
13
|
toTerminalContentSize,
|
|
14
14
|
} from '../state/layout-resize'
|
|
15
|
+
import { getSnapshotScrollIntents } from '../state/session-persistence'
|
|
15
16
|
|
|
16
17
|
export class LocalSessionBackend
|
|
17
18
|
extends EventEmitter<SessionBackendEvents>
|
|
@@ -66,15 +67,22 @@ export class LocalSessionBackend
|
|
|
66
67
|
})
|
|
67
68
|
this.currentSessionId = options.sessionId
|
|
68
69
|
const trees = getSnapshotTrees(options.workspaceSnapshot)
|
|
70
|
+
const intents = getSnapshotScrollIntents(options.workspaceSnapshot)
|
|
69
71
|
const splitTrees = trees.filter((t) => t.type === 'split')
|
|
70
72
|
if (splitTrees.length > 0) {
|
|
71
73
|
const bounds = createTerminalBounds(options.cols, options.rows)
|
|
72
74
|
forEachSplitPaneRect(splitTrees, bounds, (tabId, rect) => {
|
|
73
75
|
const size = toTerminalContentSize(rect)
|
|
74
|
-
this.sessionManager.resizeTab(
|
|
76
|
+
this.sessionManager.resizeTab(
|
|
77
|
+
options.sessionId,
|
|
78
|
+
tabId,
|
|
79
|
+
size.cols,
|
|
80
|
+
size.rows,
|
|
81
|
+
intents.get(tabId)
|
|
82
|
+
)
|
|
75
83
|
})
|
|
76
84
|
} else {
|
|
77
|
-
this.sessionManager.resize(options.sessionId, options.cols, options.rows)
|
|
85
|
+
this.sessionManager.resize(options.sessionId, options.cols, options.rows, intents)
|
|
78
86
|
}
|
|
79
87
|
const attachResult = this.sessionManager.attachSession(
|
|
80
88
|
options.sessionId,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AIUsageTool } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import { useStore } from 'zustand'
|
|
4
|
+
import { createStore } from 'zustand/vanilla'
|
|
5
|
+
|
|
6
|
+
import type { UsageSnapshot } from '../services/ai-usage/types'
|
|
7
|
+
|
|
8
|
+
export interface AIUsageState {
|
|
9
|
+
enabled: boolean
|
|
10
|
+
snapshots: Partial<Record<AIUsageTool, UsageSnapshot>>
|
|
11
|
+
setEnabled: (enabled: boolean) => void
|
|
12
|
+
setSnapshot: (snap: UsageSnapshot) => void
|
|
13
|
+
clear: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const aiUsageStore = createStore<AIUsageState>((set) => ({
|
|
17
|
+
clear: () => set({ snapshots: {} }),
|
|
18
|
+
enabled: false,
|
|
19
|
+
setEnabled: (enabled: boolean) => set({ enabled }),
|
|
20
|
+
setSnapshot: (snap: UsageSnapshot) =>
|
|
21
|
+
set((state) => ({
|
|
22
|
+
snapshots: { ...state.snapshots, [snap.tool]: snap },
|
|
23
|
+
})),
|
|
24
|
+
snapshots: {},
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
export function useAIUsageStore<T>(selector: (state: AIUsageState) => T): T {
|
|
28
|
+
return useStore(aiUsageStore, selector)
|
|
29
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const GIT_PANE_MIN_RATIO = 0.2
|
|
2
|
+
export const GIT_PANE_MAX_RATIO = 0.8
|
|
3
|
+
export const GIT_PANE_MIN_WIDTH = 20
|
|
4
|
+
export const GIT_PANE_MAX_WIDTH = 80
|
|
5
|
+
|
|
6
|
+
export function clampGitPaneRatio(value: number): number {
|
|
7
|
+
return Math.max(GIT_PANE_MIN_RATIO, Math.min(GIT_PANE_MAX_RATIO, value))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getGitPaneWidthFromRatio(ratio: number): number {
|
|
11
|
+
return Math.max(
|
|
12
|
+
GIT_PANE_MIN_WIDTH,
|
|
13
|
+
Math.min(GIT_PANE_MAX_WIDTH, Math.round(clampGitPaneRatio(ratio) * GIT_PANE_MAX_WIDTH))
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { AppAction, AppState, AutoCommitState } from '../types'
|
|
2
|
+
|
|
3
|
+
function setBySession(
|
|
4
|
+
state: AutoCommitState,
|
|
5
|
+
sessionId: string,
|
|
6
|
+
next: AutoCommitState['bySession'][string]
|
|
7
|
+
): AutoCommitState {
|
|
8
|
+
return { bySession: { ...state.bySession, [sessionId]: next } }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function reduceAutoCommitState(
|
|
12
|
+
state: AutoCommitState,
|
|
13
|
+
action: AppAction
|
|
14
|
+
): AutoCommitState | null {
|
|
15
|
+
switch (action.type) {
|
|
16
|
+
case 'auto-commit-generation-started': {
|
|
17
|
+
return setBySession(state, action.sessionId, {
|
|
18
|
+
abortController: action.abortController,
|
|
19
|
+
kind: 'generating',
|
|
20
|
+
startedAt: action.startedAt,
|
|
21
|
+
tabId: action.tabId,
|
|
22
|
+
workingTreeHash: action.workingTreeHash,
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
case 'auto-commit-generation-ready': {
|
|
26
|
+
const current = state.bySession[action.sessionId]
|
|
27
|
+
if (!current || current.kind !== 'generating') return null
|
|
28
|
+
if (current.workingTreeHash !== action.workingTreeHash) return null
|
|
29
|
+
return setBySession(state, action.sessionId, {
|
|
30
|
+
body: action.body,
|
|
31
|
+
generatedAt: action.generatedAt,
|
|
32
|
+
kind: 'ready',
|
|
33
|
+
tabId: current.tabId,
|
|
34
|
+
title: action.title,
|
|
35
|
+
workingTreeHash: current.workingTreeHash,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
case 'auto-commit-clear': {
|
|
39
|
+
const current = state.bySession[action.sessionId]
|
|
40
|
+
if (!current || current.kind === 'idle') return null
|
|
41
|
+
if (current.kind === 'generating') {
|
|
42
|
+
try {
|
|
43
|
+
current.abortController.abort()
|
|
44
|
+
} catch {
|
|
45
|
+
// ignore
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return setBySession(state, action.sessionId, { kind: 'idle' })
|
|
49
|
+
}
|
|
50
|
+
default:
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function reduceAutoCommit(state: AppState, action: AppAction): AppState | null {
|
|
56
|
+
const next = reduceAutoCommitState(state.autoCommit, action)
|
|
57
|
+
if (next === null) return null
|
|
58
|
+
return { ...state, autoCommit: next }
|
|
59
|
+
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import type { AppAction, AppState, GitFileEntry, GitFileSection, GitPanelState } from '../types'
|
|
2
2
|
|
|
3
|
+
import { clampGitPaneRatio } from '../git-pane-sizing'
|
|
3
4
|
import { reconcileSelectedGitEntryKey } from '../git-tree'
|
|
4
5
|
import { clearDiffCacheForPaths } from './diff-cache'
|
|
5
6
|
|
|
6
|
-
export const GIT_PANEL_MIN_RATIO = 0.2
|
|
7
|
-
export const GIT_PANEL_MAX_RATIO = 0.8
|
|
8
|
-
|
|
9
7
|
const SECTION_RANK: Record<GitFileSection, number> = {
|
|
10
8
|
historical: 0,
|
|
11
9
|
staged: 1,
|
|
@@ -23,7 +21,7 @@ export function sortFilesBySection(files: GitFileEntry[]): GitFileEntry[] {
|
|
|
23
21
|
}
|
|
24
22
|
|
|
25
23
|
function clampRatio(value: number): number {
|
|
26
|
-
return
|
|
24
|
+
return clampGitPaneRatio(value)
|
|
27
25
|
}
|
|
28
26
|
|
|
29
27
|
export function emptyGitPanel(): GitPanelState {
|
|
@@ -93,9 +91,16 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
|
|
|
93
91
|
}
|
|
94
92
|
}
|
|
95
93
|
case 'resize-git-pane': {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
const target = state.gitPane.mode === 'pane' ? 'paneRatio' : 'embeddedRatio'
|
|
95
|
+
const nextRatio = clampRatio(state.gitPane[target] + action.delta)
|
|
96
|
+
if (nextRatio === state.gitPane[target]) return state
|
|
97
|
+
return { ...state, gitPane: { ...state.gitPane, [target]: nextRatio } }
|
|
98
|
+
}
|
|
99
|
+
case 'set-git-pane-ratio': {
|
|
100
|
+
const key = action.target === 'pane' ? 'paneRatio' : 'embeddedRatio'
|
|
101
|
+
const nextRatio = clampRatio(action.ratio)
|
|
102
|
+
if (nextRatio === state.gitPane[key]) return state
|
|
103
|
+
return { ...state, gitPane: { ...state.gitPane, [key]: nextRatio } }
|
|
99
104
|
}
|
|
100
105
|
case 'resize-git-diff-pane': {
|
|
101
106
|
const nextRatio = clampRatio(state.gitPane.diffModeRatio + action.delta)
|
|
@@ -7,6 +7,7 @@ import { getActiveKeymap } from '../../input/keymap/keymap-ref'
|
|
|
7
7
|
import { getAllAssistantOptions } from '../../pty/command-registry'
|
|
8
8
|
import { filterThemeIds } from '../../ui/filter-themes'
|
|
9
9
|
import { filterAssistants, filterSessions, filterSnippets } from '../selectors'
|
|
10
|
+
import { reduceAutoCommitState } from './auto-commit-state'
|
|
10
11
|
|
|
11
12
|
function emptyModal() {
|
|
12
13
|
return {
|
|
@@ -199,7 +200,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
199
200
|
type: 'update-available',
|
|
200
201
|
},
|
|
201
202
|
}
|
|
202
|
-
case 'open-git-commit-modal':
|
|
203
|
+
case 'open-git-commit-modal': {
|
|
204
|
+
const sessionId = action.sessionId
|
|
203
205
|
return {
|
|
204
206
|
...state,
|
|
205
207
|
focusMode: 'command-edit',
|
|
@@ -209,10 +211,112 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
209
211
|
cursorPos: 0,
|
|
210
212
|
editBuffer: '',
|
|
211
213
|
selectedIndex: 0,
|
|
212
|
-
sessionTargetId: null,
|
|
214
|
+
sessionTargetId: sessionId ?? null,
|
|
215
|
+
stage: 'edit',
|
|
213
216
|
type: 'git-commit',
|
|
214
217
|
},
|
|
215
218
|
}
|
|
219
|
+
}
|
|
220
|
+
case 'git-commit-enter-confirm': {
|
|
221
|
+
if (state.modal.type !== 'git-commit') return state
|
|
222
|
+
return {
|
|
223
|
+
...state,
|
|
224
|
+
focusMode: 'command-edit',
|
|
225
|
+
modal: { ...state.modal, stage: 'confirm' },
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
case 'git-commit-leave-confirm': {
|
|
229
|
+
if (state.modal.type !== 'git-commit') return state
|
|
230
|
+
return {
|
|
231
|
+
...state,
|
|
232
|
+
focusMode: 'command-edit',
|
|
233
|
+
modal: { ...state.modal, stage: 'edit' },
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
case 'git-commit-enter-generating': {
|
|
237
|
+
if (state.modal.type !== 'git-commit') return state
|
|
238
|
+
return {
|
|
239
|
+
...state,
|
|
240
|
+
focusMode: 'modal',
|
|
241
|
+
modal: { ...state.modal, sessionTargetId: action.sessionId, stage: 'generating' },
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
case 'git-commit-leave-generating': {
|
|
245
|
+
if (state.modal.type !== 'git-commit') return state
|
|
246
|
+
return {
|
|
247
|
+
...state,
|
|
248
|
+
focusMode: 'command-edit',
|
|
249
|
+
modal: { ...state.modal, stage: 'edit' },
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
case 'auto-commit-generation-ready': {
|
|
253
|
+
if (
|
|
254
|
+
state.modal.type !== 'git-commit' ||
|
|
255
|
+
state.modal.stage !== 'generating' ||
|
|
256
|
+
state.modal.sessionTargetId !== action.sessionId
|
|
257
|
+
) {
|
|
258
|
+
return null
|
|
259
|
+
}
|
|
260
|
+
const nextAutoCommit = reduceAutoCommitState(state.autoCommit, action)
|
|
261
|
+
if (!nextAutoCommit) {
|
|
262
|
+
// Stale result (hash mismatch or slice was cleared mid-flight): don't
|
|
263
|
+
// strand the modal in `generating` — flip back to edit so the user
|
|
264
|
+
// isn't stuck staring at a spinner that will never resolve.
|
|
265
|
+
return {
|
|
266
|
+
...state,
|
|
267
|
+
focusMode: 'command-edit',
|
|
268
|
+
modal: { ...state.modal, stage: 'edit' },
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
...state,
|
|
273
|
+
autoCommit: nextAutoCommit,
|
|
274
|
+
focusMode: 'command-edit',
|
|
275
|
+
modal: {
|
|
276
|
+
...state.modal,
|
|
277
|
+
activeField: 'title',
|
|
278
|
+
contentBuffer: action.body,
|
|
279
|
+
cursorPos: action.title.length,
|
|
280
|
+
editBuffer: action.title,
|
|
281
|
+
stage: 'confirm',
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
case 'git-commit-use-background-suggestion': {
|
|
286
|
+
if (state.modal.type !== 'git-commit' || state.modal.sessionTargetId !== action.sessionId) {
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
const suggestion = state.autoCommit.bySession[action.sessionId]
|
|
290
|
+
if (!suggestion || suggestion.kind !== 'ready') return null
|
|
291
|
+
return {
|
|
292
|
+
...state,
|
|
293
|
+
focusMode: 'command-edit',
|
|
294
|
+
modal: {
|
|
295
|
+
...state.modal,
|
|
296
|
+
activeField: 'title',
|
|
297
|
+
contentBuffer: suggestion.body,
|
|
298
|
+
cursorPos: suggestion.title.length,
|
|
299
|
+
editBuffer: suggestion.title,
|
|
300
|
+
stage: 'confirm',
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
case 'auto-commit-clear': {
|
|
305
|
+
if (
|
|
306
|
+
state.modal.type !== 'git-commit' ||
|
|
307
|
+
state.modal.stage !== 'generating' ||
|
|
308
|
+
state.modal.sessionTargetId !== action.sessionId
|
|
309
|
+
) {
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
const nextAutoCommit = reduceAutoCommitState(state.autoCommit, action)
|
|
313
|
+
return {
|
|
314
|
+
...state,
|
|
315
|
+
autoCommit: nextAutoCommit ?? state.autoCommit,
|
|
316
|
+
focusMode: 'command-edit',
|
|
317
|
+
modal: { ...state.modal, stage: 'edit' },
|
|
318
|
+
}
|
|
319
|
+
}
|
|
216
320
|
case 'set-help-entry-count': {
|
|
217
321
|
if (state.modal.type !== 'help') return state
|
|
218
322
|
if (state.modal.entryCount === action.count) {
|