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:
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user