Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const clientSettings: ClientSettings = {
browserDefaultAppearance: "dark",
browserAutoShowFloatingPreview: false,
confirmQuit: true,
confirmTerminalClose: true,
confirmThreadArchive: true,
confirmThreadDelete: false,
dismissedProviderUpdateNotificationKeys: [],
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -678,6 +681,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.appearanceContrast,
settings.enableAgentBrowserAccess,
settings.confirmQuit,
settings.confirmTerminalClose,
settings.confirmThreadArchive,
settings.confirmThreadDelete,
settings.enableTurnCompletionToasts,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2529,6 +2534,32 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
{...searchableSetting("terminal-close-confirmation")}
description="Ask before closing a terminal, which stops its running process and clears its history."
resetAction={
settings.confirmTerminalClose !== DEFAULT_UNIFIED_SETTINGS.confirmTerminalClose ? (
<SettingResetButton
label="terminal close confirmation"
onClick={() =>
updateSettings({
confirmTerminalClose: DEFAULT_UNIFIED_SETTINGS.confirmTerminalClose,
})
}
/>
) : null
}
control={
<Switch
checked={settings.confirmTerminalClose}
onCheckedChange={(checked) =>
updateSettings({ confirmTerminalClose: Boolean(checked) })
}
aria-label="Confirm terminal close"
/>
}
/>

{isElectron ? (
<SettingsRow
{...searchableSetting("quit-confirmation")}
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Delete confirmation",
to: "/settings/general",
},
{
id: "terminal-close-confirmation",
title: "Terminal close confirmation",
to: "/settings/general",
},
{
id: "quit-confirmation",
title: "Hold to quit",
Expand Down
72 changes: 61 additions & 11 deletions apps/web/src/lib/terminalCloseConfirm.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,47 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

const { confirmMock, readLocalApiMock } = vi.hoisted(() => {
const confirmMock = vi.fn<(message: string, options?: unknown) => Promise<boolean>>();
const readLocalApiMock = vi.fn<
() =>
| {
dialogs: { confirm: (message: string, options?: unknown) => Promise<boolean> };
}
| undefined
>();
return { confirmMock, readLocalApiMock };
});
const { confirmMock, readLocalApiMock, getClientSettingsMock, ensureHydratedMock } = vi.hoisted(
() => {
const confirmMock = vi.fn<(message: string, options?: unknown) => Promise<boolean>>();
const readLocalApiMock = vi.fn<
() =>
| {
dialogs: { confirm: (message: string, options?: unknown) => Promise<boolean> };
}
| undefined
>();
const getClientSettingsMock = vi.fn<() => { confirmTerminalClose: boolean }>();
const ensureHydratedMock = vi.fn<() => Promise<void>>();
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 () => {
Expand All @@ -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);
Expand All @@ -49,6 +69,7 @@ describe("terminal close confirmation", () => {
);

const confirmation = confirmTerminalClose(["Terminal 1"]);
await flushHydration();
expect(isTerminalCloseConfirmPending()).toBe(true);

reject(new Error("dialog failed"));
Expand All @@ -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<void>((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);

Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/lib/terminalCloseConfirm.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings";
import { readLocalApi } from "~/localApi";

let pendingConfirmations = 0;
Expand All @@ -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<boolean> {
await ensureClientSettingsHydrated();
if (!getClientSettings().confirmTerminalClose) return true;
const localApi = readLocalApi();
if (!localApi) return true;
pendingConfirmations += 1;
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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),
Expand Down