Settings: add Fast Mode toggle for codex/claude + tidy account row alignment (#46)

Fast mode controls
- Codex: writes [features].fast_mode = true|false in ~/.codex/config.toml.
  This matches the in-TUI /fast toggle ("toggle Fast mode to enable
  fastest inference with increased plan usage").
- Claude: writes fastMode: bool to ~/.claude/settings.json. Mirrors the
  /fast slash command preference (effective on opus-4-6).

UX polish
- Account rows switch from flex-wrap to a fixed grid (tag · email · plan ·
  expiry · action) so emails of varying lengths don't cause ragged
  baselines between rows.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Eyejoker
2026-04-27 22:06:56 +09:00
committed by GitHub
parent 5883e57d73
commit 7047caa608
5 changed files with 290 additions and 5 deletions

View File

@@ -24,6 +24,7 @@ import {
type DashboardTaskAction, type DashboardTaskAction,
type DashboardOverview, type DashboardOverview,
type DashboardTask, type DashboardTask,
type FastModeSnapshot,
type ModelConfigSnapshot, type ModelConfigSnapshot,
type ModelRoleConfig, type ModelRoleConfig,
type UpdateScheduledTaskInput, type UpdateScheduledTaskInput,
@@ -33,11 +34,13 @@ import {
deleteAccount, deleteAccount,
fetchAccounts, fetchAccounts,
fetchDashboardData, fetchDashboardData,
fetchFastMode,
fetchModelConfig, fetchModelConfig,
runInboxAction, runInboxAction,
runServiceAction, runServiceAction,
runScheduledTaskAction, runScheduledTaskAction,
sendRoomMessage, sendRoomMessage,
updateFastMode,
updateModels, updateModels,
updateScheduledTask, updateScheduledTask,
} from './api'; } from './api';
@@ -1098,6 +1101,8 @@ function SettingsPanel({
<ModelSettings onRestartStack={onRestartStack} /> <ModelSettings onRestartStack={onRestartStack} />
<FastModeSettings />
<AccountSettings onRestartStack={onRestartStack} /> <AccountSettings onRestartStack={onRestartStack} />
</div> </div>
); );
@@ -1234,6 +1239,90 @@ function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) {
); );
} }
function FastModeSettings() {
const [state, setState] = useState<FastModeSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchFastMode()
.then((s) => {
if (cancelled) return;
setState(s);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
};
}, []);
async function toggle(provider: keyof FastModeSnapshot) {
if (!state) return;
const next = !state[provider];
const optimistic = { ...state, [provider]: next };
setState(optimistic);
setBusy(true);
setError(null);
try {
const fresh = await updateFastMode({ [provider]: next });
setState(fresh);
} catch (err) {
setState(state);
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<section className="settings-section">
<h3> </h3>
{error ? <p className="settings-error">{error}</p> : null}
{!state ? (
<p className="settings-hint"> </p>
) : (
<>
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">Codex (GPT)</span>
<small className="settings-hint">
~/.codex/config.toml [features].fast_mode
.
</small>
</span>
<input
checked={state.codex}
disabled={busy}
onChange={() => void toggle('codex')}
type="checkbox"
/>
</label>
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">Claude</span>
<small className="settings-hint">
~/.claude/settings.json fastMode /fast
. opus-4-6 .
</small>
</span>
<input
checked={state.claude}
disabled={busy}
onChange={() => void toggle('claude')}
type="checkbox"
/>
</label>
</>
)}
</section>
);
}
function formatExpiry( function formatExpiry(
iso: string | null, iso: string | null,
): { label: string; cls: string } | null { ): { label: string; cls: string } | null {

View File

@@ -373,6 +373,11 @@ export interface ModelConfigSnapshot {
arbiter: ModelRoleConfig; arbiter: ModelRoleConfig;
} }
export interface FastModeSnapshot {
codex: boolean;
claude: boolean;
}
export async function fetchAccounts(): Promise<{ export async function fetchAccounts(): Promise<{
claude: ClaudeAccountSummary[]; claude: ClaudeAccountSummary[];
codex: CodexAccountSummary[]; codex: CodexAccountSummary[];
@@ -412,6 +417,34 @@ export async function updateModels(
return (await response.json()) as ModelConfigSnapshot; return (await response.json()) as ModelConfigSnapshot;
} }
export async function fetchFastMode(): Promise<FastModeSnapshot> {
return fetchJson('/api/settings/fast-mode');
}
export async function updateFastMode(
input: Partial<FastModeSnapshot>,
): Promise<FastModeSnapshot> {
const response = await fetch('/api/settings/fast-mode', {
method: 'PUT',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
let msg = `update fast mode failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
return (await response.json()) as FastModeSnapshot;
}
export async function deleteAccount( export async function deleteAccount(
provider: 'claude' | 'codex', provider: 'claude' | 'codex',
index: number, index: number,

View File

@@ -814,6 +814,40 @@ button:disabled {
margin-top: 8px; margin-top: 8px;
} }
.settings-toggle-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
}
.settings-toggle-row + .settings-toggle-row {
margin-top: 6px;
}
.settings-toggle-label {
display: grid;
gap: 4px;
min-width: 0;
}
.settings-toggle-title {
font-size: 13px;
font-weight: 600;
color: var(--bg-ink);
}
.settings-toggle-row input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--accent);
}
.settings-save, .settings-save,
.settings-restart { .settings-restart {
min-height: 32px; min-height: 32px;
@@ -874,18 +908,18 @@ button:disabled {
} }
.settings-account-main { .settings-account-main {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: 28px minmax(0, 1fr) auto auto;
gap: 6px 10px; gap: 10px;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
} }
.settings-account-tag { .settings-account-tag {
flex: none;
font-family: 'JetBrains Mono', ui-monospace, monospace; font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px; font-size: 11px;
color: var(--muted); color: var(--muted);
text-align: right;
} }
.settings-account-email { .settings-account-email {
@@ -895,7 +929,7 @@ button:disabled {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 100%; min-width: 0;
} }
.settings-account-plan { .settings-account-plan {

View File

@@ -45,6 +45,11 @@ export interface ModelConfigSnapshot {
arbiter: ModelRoleConfig; arbiter: ModelRoleConfig;
} }
export interface FastModeSnapshot {
codex: boolean;
claude: boolean;
}
const ROLE_KEYS = ['OWNER', 'REVIEWER', 'ARBITER'] as const; const ROLE_KEYS = ['OWNER', 'REVIEWER', 'ARBITER'] as const;
type RoleKey = (typeof ROLE_KEYS)[number]; type RoleKey = (typeof ROLE_KEYS)[number];
@@ -272,6 +277,99 @@ export function updateModelConfig(
return getModelConfig(); return getModelConfig();
} }
function codexConfigPath(): string {
return path.join(os.homedir(), '.codex', 'config.toml');
}
function claudeSettingsPath(): string {
return path.join(os.homedir(), '.claude', 'settings.json');
}
function readCodexFastMode(): boolean {
const file = codexConfigPath();
if (!fs.existsSync(file)) return false;
const content = fs.readFileSync(file, 'utf-8');
// [features] section, look for fast_mode = true|false
const featuresMatch = content.match(/\[features\][\s\S]*?(?=^\[|\Z)/m);
const block = featuresMatch ? featuresMatch[0] : content;
const m = block.match(/^\s*fast_mode\s*=\s*(true|false)\s*$/m);
return m ? m[1] === 'true' : false;
}
function writeCodexFastMode(value: boolean): void {
const file = codexConfigPath();
let content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
if (/^\s*fast_mode\s*=\s*(true|false)\s*$/m.test(content)) {
content = content.replace(
/^\s*fast_mode\s*=\s*(true|false)\s*$/m,
`fast_mode = ${value}`,
);
} else if (/^\[features\]/m.test(content)) {
content = content.replace(
/^\[features\]\s*$/m,
`[features]\nfast_mode = ${value}`,
);
} else {
const trimmed = content.replace(/\s*$/, '');
content = `${trimmed}\n\n[features]\nfast_mode = ${value}\n`;
}
const tempPath = `${file}.tmp`;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(tempPath, content, { mode: 0o600 });
fs.renameSync(tempPath, file);
}
function readClaudeFastMode(): boolean {
const file = claudeSettingsPath();
if (!fs.existsSync(file)) return false;
try {
const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<
string,
unknown
>;
return data.fastMode === true;
} catch {
return false;
}
}
function writeClaudeFastMode(value: boolean): void {
const file = claudeSettingsPath();
fs.mkdirSync(path.dirname(file), { recursive: true });
let data: Record<string, unknown> = {};
if (fs.existsSync(file)) {
try {
data = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<
string,
unknown
>;
} catch {
data = {};
}
}
data.fastMode = value;
const tempPath = `${file}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, {
mode: 0o600,
});
fs.renameSync(tempPath, file);
}
export function getFastMode(): FastModeSnapshot {
return {
codex: readCodexFastMode(),
claude: readClaudeFastMode(),
};
}
export function updateFastMode(
input: Partial<FastModeSnapshot>,
): FastModeSnapshot {
if (typeof input.codex === 'boolean') writeCodexFastMode(input.codex);
if (typeof input.claude === 'boolean') writeClaudeFastMode(input.claude);
return getFastMode();
}
export function removeAccountDirectory( export function removeAccountDirectory(
provider: 'claude' | 'codex', provider: 'claude' | 'codex',
index: number, index: number,

View File

@@ -48,10 +48,12 @@ import {
} from './web-dashboard-data.js'; } from './web-dashboard-data.js';
import { import {
addClaudeAccountFromToken, addClaudeAccountFromToken,
getFastMode,
getModelConfig, getModelConfig,
listClaudeAccounts, listClaudeAccounts,
listCodexAccounts, listCodexAccounts,
removeAccountDirectory, removeAccountDirectory,
updateFastMode,
updateModelConfig, updateModelConfig,
} from './settings-store.js'; } from './settings-store.js';
@@ -1271,6 +1273,31 @@ export function createWebDashboardHandler(
} }
} }
if (
url.pathname === '/api/settings/fast-mode' &&
(request.method === 'PUT' || request.method === 'PATCH')
) {
let body: unknown = null;
try {
body = await request.json();
} catch {
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
}
if (!body || typeof body !== 'object') {
return jsonResponse(
{ error: 'Body must be a JSON object' },
{ status: 400 },
);
}
try {
const next = updateFastMode(body as Record<string, unknown>);
return jsonResponse(next);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse({ error: message }, { status: 500 });
}
}
{ {
const accountAddMatch = url.pathname.match( const accountAddMatch = url.pathname.match(
/^\/api\/settings\/accounts\/(claude)$/, /^\/api\/settings\/accounts\/(claude)$/,
@@ -1518,6 +1545,10 @@ export function createWebDashboardHandler(
return jsonResponse(getModelConfig()); return jsonResponse(getModelConfig());
} }
if (url.pathname === '/api/settings/fast-mode') {
return jsonResponse(getFastMode());
}
if (url.pathname === '/api/stream') { if (url.pathname === '/api/stream') {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const stream = new ReadableStream({ const stream = new ReadableStream({