diff --git a/README.md b/README.md index 499458e86f52..d857d0881682 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ This is a personal fork of [pingdotgg/t3code](https://github.com/pingdotgg/t3cod - **Provider usage meter** — live, server-owned Claude Code and Codex subscription-quota windows with local reset times, retained per configured account and shared across threads. The composer meter nests them: the outer ring stays the thread's context window, the ring inside it tracks the subscription session window (weekly for Codex), and Claude-served threads fill the centre with a Fable indicator for the available pooled account that would serve the next Fable turn. The web popover lists every non-disabled account for the thread's provider with freshness and on-demand refresh, scrolls its account list so a large pool stays fully readable, keeps cooldown accounts visible, and can mask account emails; mobile mirrors the account and Fable-next details with full emails in a scrollable bottom sheet that draws each window's bar, percentage, and reset. Both surfaces show one freshness line for the whole panel — the age of its oldest account — and both re-read on open when any listed account is more than a minute old. After automatic rate-limit failover, both clients follow the session's live account rather than the originally picked one. Claude reports its per-window percentages through the same data as `/usage`; Codex can read usage before a thread has a live provider session and renders whichever windows it reports. Optional approaching-limit warnings and warning/critical ring thresholds are configurable on web; reaching 100% stays visible in the meter without another toast. An instance routed through a CLIProxyAPI gateway can set a per-instance usage source (management URL + key, stored in the secret store): its meter lists the gateway's pooled Claude and Codex accounts instead of the local login, resolves the active model through the gateway catalog when available, and hides sibling direct accounts while a thread runs on the gateway (and vice versa). Opening the pool popover or the mobile sheet also reads the gateway's own session-affinity table for the active thread, so the account marked "current" is the one the thread's Claude session actually spends; the pool's priority pick, which only applies to new sessions, shows as "next". - **Gateway-aware usage attribution** — the Usage page can credit each model to the subscription it actually spends rather than to the transcript it was found in. A Claude Code session that a CLIProxyAPI gateway routed to an OpenAI model counts towards Codex, and a Codex session that reached an Anthropic model counts towards Claude Code, so one model no longer appears as two rows split between the providers. A "By subscription" / "By app" toggle in the page header switches between that pool view and grouping by the app whose transcripts recorded the usage (all Claude Code activity as one row); the choice is remembered per device. Spend and tokens regroup exactly either way — every response belongs to one row in each view — while per-row session counts appear only in the app view, because a single session can spend from both pools and cannot be split honestly. The correction is applied when the page merges each environment's answer, so it also covers environments running an older server. - **Extras settings** — a dedicated web/desktop settings page groups the fork's notification, sidebar, composer, accent-tint, and message-listening controls without changing mobile's established defaults. It also hosts the switch for automatic settling: leave it on to settle threads after inactivity or when their pull request is merged or closed, turn it off to settle only by hand. Mobile carries the same switch in Settings → General, where turning it off also hides the merged-thread toggle it governs. +- **Terminal close confirmation** — upstream asks before every individual terminal close. Settings → General adds a switch that turns the prompt off; it stays on by default, and bulk tab closes and auto-exit cleanup never prompted either way. ### Voice diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 7838ce83008a..bffbdd8e7a58 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -22,6 +22,7 @@ const clientSettings: ClientSettings = { browserDefaultAppearance: "dark", browserAutoShowFloatingPreview: false, confirmQuit: true, + confirmTerminalClose: true, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index eec3b15ccdbd..9288b3cbaf94 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -610,6 +610,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), + ...(settings.confirmTerminalClose !== DEFAULT_UNIFIED_SETTINGS.confirmTerminalClose + ? ["Terminal close confirmation"] + : []), ...(settings.enableTurnCompletionToasts !== DEFAULT_UNIFIED_SETTINGS.enableTurnCompletionToasts ? ["Completion toasts"] @@ -678,6 +681,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.appearanceContrast, settings.enableAgentBrowserAccess, settings.confirmQuit, + settings.confirmTerminalClose, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.enableTurnCompletionToasts, @@ -822,6 +826,7 @@ export function useSettingsRestore(onRestored?: () => void) { defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + confirmTerminalClose: DEFAULT_UNIFIED_SETTINGS.confirmTerminalClose, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, enableTurnCompletionToasts: DEFAULT_UNIFIED_SETTINGS.enableTurnCompletionToasts, @@ -2529,6 +2534,32 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + confirmTerminalClose: DEFAULT_UNIFIED_SETTINGS.confirmTerminalClose, + }) + } + /> + ) : null + } + control={ + + updateSettings({ confirmTerminalClose: Boolean(checked) }) + } + aria-label="Confirm terminal close" + /> + } + /> + {isElectron ? ( { - const confirmMock = vi.fn<(message: string, options?: unknown) => Promise>(); - const readLocalApiMock = vi.fn< - () => - | { - dialogs: { confirm: (message: string, options?: unknown) => Promise }; - } - | undefined - >(); - return { confirmMock, readLocalApiMock }; -}); +const { confirmMock, readLocalApiMock, getClientSettingsMock, ensureHydratedMock } = vi.hoisted( + () => { + const confirmMock = vi.fn<(message: string, options?: unknown) => Promise>(); + const readLocalApiMock = vi.fn< + () => + | { + dialogs: { confirm: (message: string, options?: unknown) => Promise }; + } + | undefined + >(); + const getClientSettingsMock = vi.fn<() => { confirmTerminalClose: boolean }>(); + const ensureHydratedMock = vi.fn<() => Promise>(); + return { confirmMock, readLocalApiMock, getClientSettingsMock, ensureHydratedMock }; + }, +); vi.mock("~/localApi", () => ({ readLocalApi: () => readLocalApiMock(), })); +vi.mock("~/hooks/useSettings", () => ({ + ensureClientSettingsHydrated: () => ensureHydratedMock(), + getClientSettings: () => getClientSettingsMock(), +})); + import { confirmTerminalClose, isTerminalCloseConfirmPending } from "./terminalCloseConfirm"; +/** Lets the helper get past its hydration await before assertions run. */ +async function flushHydration() { + await Promise.resolve(); + await Promise.resolve(); +} + describe("terminal close confirmation", () => { beforeEach(() => { confirmMock.mockReset(); readLocalApiMock.mockReset(); readLocalApiMock.mockReturnValue({ dialogs: { confirm: confirmMock } }); + getClientSettingsMock.mockReset(); + getClientSettingsMock.mockReturnValue({ confirmTerminalClose: true }); + ensureHydratedMock.mockReset(); + ensureHydratedMock.mockResolvedValue(undefined); }); it("tracks pending state until the confirmation settles", async () => { @@ -32,6 +51,7 @@ describe("terminal close confirmation", () => { expect(isTerminalCloseConfirmPending()).toBe(false); const confirmation = confirmTerminalClose(["Terminal 1"]); + await flushHydration(); expect(isTerminalCloseConfirmPending()).toBe(true); settle(true); @@ -49,6 +69,7 @@ describe("terminal close confirmation", () => { ); const confirmation = confirmTerminalClose(["Terminal 1"]); + await flushHydration(); expect(isTerminalCloseConfirmPending()).toBe(true); reject(new Error("dialog failed")); @@ -69,6 +90,35 @@ describe("terminal close confirmation", () => { ); }); + it("closes without prompting when the confirmation setting is off", async () => { + getClientSettingsMock.mockReturnValue({ confirmTerminalClose: false }); + + await expect(confirmTerminalClose(["Terminal 1"])).resolves.toBe(true); + expect(confirmMock).not.toHaveBeenCalled(); + expect(isTerminalCloseConfirmPending()).toBe(false); + }); + + it("reads the setting only after client settings hydrate", async () => { + // A cold start holds the schema default until the persisted value lands. + getClientSettingsMock.mockReturnValue({ confirmTerminalClose: true }); + let finishHydration: () => void = () => undefined; + ensureHydratedMock.mockImplementation( + () => + new Promise((resolve) => { + finishHydration = () => { + getClientSettingsMock.mockReturnValue({ confirmTerminalClose: false }); + resolve(); + }; + }), + ); + + const confirmation = confirmTerminalClose(["Terminal 1"]); + finishHydration(); + + await expect(confirmation).resolves.toBe(true); + expect(confirmMock).not.toHaveBeenCalled(); + }); + it("closes without prompting when no local API is available", async () => { readLocalApiMock.mockReturnValue(undefined); diff --git a/apps/web/src/lib/terminalCloseConfirm.ts b/apps/web/src/lib/terminalCloseConfirm.ts index b1ea97a64ba6..5731630d3fa3 100644 --- a/apps/web/src/lib/terminalCloseConfirm.ts +++ b/apps/web/src/lib/terminalCloseConfirm.ts @@ -1,3 +1,4 @@ +import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings"; import { readLocalApi } from "~/localApi"; let pendingConfirmations = 0; @@ -12,10 +13,17 @@ export function isTerminalCloseConfirmPending(): boolean { * buttons, the `terminal.close` keybinding, and closing a terminal surface from * the tab strip. Auto-exit cleanup and bulk tab closes skip this path and close * directly. + * + * The `confirmTerminalClose` setting turns the prompt off. The pre-hydration + * snapshot is the schema default, so a close early in a cold start would ask + * even though the user turned the prompt off; awaiting hydration reads the + * persisted answer instead. */ export async function confirmTerminalClose( labels: readonly [string, ...string[]], ): Promise { + await ensureClientSettingsHydrated(); + if (!getClientSettings().confirmTerminalClose) return true; const localApi = readLocalApi(); if (!localApi) return true; pendingConfirmations += 1; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index d1d5020d82de..426cb84cb304 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -338,6 +338,9 @@ export const ClientSettingsSchema = Schema.Struct({ // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the // app quits; a quick tap only shows a hint. Browser clients ignore it. confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // Whether closing a single terminal asks first. Auto-exit cleanup and bulk + // tab closes never prompt regardless. + confirmTerminalClose: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -1284,6 +1287,7 @@ export const ClientSettingsPatch = Schema.Struct({ browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference), browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean), confirmQuit: Schema.optionalKey(Schema.Boolean), + confirmTerminalClose: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),