Merge branch 'feat/disk-and-settings' into integration/round-1

This commit is contained in:
2026-08-23 08:35:51 -07:00
19 changed files with 1469 additions and 112 deletions
@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import ClaudeCodeSettingsEditor, { CLAUDE_CODE_DEFAULTS } from "./ClaudeCodeSettingsEditor";
import type { ClaudeCodeSettings } from "../../lib/types";
function renderEditor(settings: ClaudeCodeSettings | null) {
const onSave = vi.fn().mockResolvedValue(undefined);
render(
<ClaudeCodeSettingsEditor settings={settings} disabled={false} onSave={onSave} />,
);
return onSave;
}
describe("ClaudeCodeSettingsEditor", () => {
it("shows the two default-on settings as on for a project that never touched them", () => {
// Claude Code's session recap and fullscreen auto-scroll are both on by
// default, and the fields behind them store the *disabled* sense. A toggle
// rendered straight from the field would tell every existing user their
// recap is off.
renderEditor(null);
expect(screen.getByRole("switch", { name: "Session recap" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Auto-scroll" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Focus mode" })).not.toBeChecked();
});
it("stores the disabled sense when an inverted toggle is switched off", () => {
const onSave = renderEditor(null);
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ session_recap_disabled: true }),
);
});
it("collapses back to null once every setting is at its default again", () => {
// `null` is what tells the backend this project adds nothing over the
// global settings, so the round trip has to land exactly back on it.
const onSave = renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true });
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
expect(onSave).toHaveBeenCalledWith(null);
});
it("offers the classic renderer as a choice distinct from automatic", () => {
// Leaving `tui` unset lets Claude Code pick; pinning "default" is a
// different, and previously unreachable, instruction.
const onSave = renderEditor(null);
const tui = screen.getByLabelText("TUI mode");
expect(
Array.from(tui.querySelectorAll("option")).map((o) => o.getAttribute("value")),
).toEqual(["", "default", "fullscreen"]);
fireEvent.change(tui, { target: { value: "default" } });
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tui_mode: "default" }));
});
it("offers every effort level Claude Code accepts", () => {
renderEditor(null);
expect(
Array.from(
screen.getByLabelText("Effort level").querySelectorAll("option"),
).map((o) => o.getAttribute("value")),
).toEqual(["", "low", "medium", "high", "xhigh"]);
});
});
@@ -16,7 +16,7 @@ export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
auto_scroll_disabled: false,
focus_mode: false,
show_thinking_summaries: false,
enable_session_recap: false,
session_recap_disabled: false,
env_scrub: false,
prompt_caching_1h: false,
};
@@ -28,16 +28,25 @@ function isAllDefaults(s: ClaudeCodeSettings): boolean {
s.auto_scroll_disabled === false &&
s.focus_mode === false &&
s.show_thinking_summaries === false &&
s.enable_session_recap === false &&
s.session_recap_disabled === false &&
s.env_scrub === false &&
s.prompt_caching_1h === false
);
}
/**
* Two of Claude Code's settings are **on by default**, so the field behind them
* stores the *disabled* sense (`auto_scroll_disabled`, `session_recap_disabled`)
* — that is what makes an untouched project mean "leave Claude Code alone"
* rather than "the user turned this off". `invert` is what lets those still
* read as an ordinary on/off switch here: the toggle shows the feature's state,
* the field stores the deviation from the default.
*/
const BOOLEAN_FIELDS: {
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
label: string;
hint: string;
invert?: boolean;
}[] = [
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
{
@@ -46,14 +55,16 @@ const BOOLEAN_FIELDS: {
hint: "Shows Claude's thinking process as summaries.",
},
{
key: "enable_session_recap",
key: "session_recap_disabled",
label: "Session recap",
hint: "Provides context when returning to a session.",
hint: "Shows a one-line recap when you return to the terminal after a few minutes away.",
invert: true,
},
{
key: "auto_scroll_disabled",
label: "Auto-scroll disabled",
hint: "Disables auto-scroll when in fullscreen TUI mode.",
label: "Auto-scroll",
hint: "Follows new output to the bottom in fullscreen rendering.",
invert: true,
},
{
key: "env_scrub",
@@ -95,9 +106,16 @@ export default function ClaudeCodeSettingsEditor({
</p>
)}
{/*
Three states, not two. Leaving `tui` unset is what lets Claude Code pick
the renderer for itself, which is not the same as pinning the classic
one — and the key is now always written (or explicitly deleted), so
"Automatic" has to be selectable rather than merely being what you get
when nothing is emitted.
*/}
<SwitchRow
label="TUI mode"
hint="Enables flicker-free alt-screen rendering."
hint="Classic renders in your terminal's scrollback; fullscreen is the flicker-free alt-screen."
control={
<select
value={local.tui_mode ?? ""}
@@ -106,7 +124,8 @@ export default function ClaudeCodeSettingsEditor({
disabled={disabled}
className={selectClass}
>
<option value="">Default</option>
<option value="">Automatic</option>
<option value="default">Classic</option>
<option value="fullscreen">Fullscreen</option>
</select>
}
@@ -127,11 +146,12 @@ export default function ClaudeCodeSettingsEditor({
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">Extra high</option>
</select>
}
/>
{BOOLEAN_FIELDS.map(({ key, label, hint }) => (
{BOOLEAN_FIELDS.map(({ key, label, hint, invert }) => (
<SwitchRow
key={key}
label={label}
@@ -139,9 +159,11 @@ export default function ClaudeCodeSettingsEditor({
control={
<Toggle
label={label}
checked={local[key]}
checked={invert ? !local[key] : local[key]}
disabled={disabled}
onChange={(v) => apply({ [key]: v } as Partial<ClaudeCodeSettings>)}
onChange={(v) =>
apply({ [key]: invert ? !v : v } as Partial<ClaudeCodeSettings>)
}
/>
}
/>
@@ -28,10 +28,24 @@ export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }:
</>
}
>
{/*
Everything remove_project() destroys, named. It removes the container,
*both* named volumes (triple-c-home-{id} and triple-c-claude-config-{id}),
the triple-c-snapshot-{id} image and the project's keychain secrets — so
an accurate warning has to reach past "the config volume". The last
sentence is the reassuring half and matters just as much: project folders
are bind mounts from the host and nothing here touches them.
*/}
<p className="text-[13px] text-[var(--text-secondary)]">
Are you sure you want to remove{" "}
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This will
delete the container, config volume, and stored credentials.
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This deletes
its container, both of its volumes and its saved container image so the home
directory, the Claude login and config, installed skills, session transcripts,
scheduled tasks and any stored credentials all go with it.
</p>
<p className="mt-2 text-[13px] text-[var(--text-secondary)]">
Your project folders on this machine are mounted in, not copied, and are left
untouched.
</p>
</Modal>
);
@@ -311,10 +311,22 @@ export default function TaskEditorModal({ project, task, onClose, onSaved }: Pro
</div>
{task && (
/*
An edit is `add` then `remove` (see `update_scheduled_task`), and
`triple-c-scheduler`'s remove now reaps the task's log directory —
so on a current container the old logs are gone, not merely filed
under the old id, which is what this used to promise.
It is deliberately not stated as a certainty. `/usr/local/bin` only
changes on base-image migration or Reset, so a project still running
an older base image carries the older scheduler, whose remove leaves
the log directory behind. "Assume they go with it" is true in both
worlds and spares the user a paragraph about which one they are in.
*/
<p className="text-xs text-[var(--text-secondary)]">
The scheduler has no edit command, so saving re-creates this task under a new id and
removes <code className="font-mono">{task.id}</code>. Its previous run logs stay under
the old id.
removes <code className="font-mono">{task.id}</code>. Assume its earlier run logs go
with it.
</p>
)}
+8 -1
View File
@@ -134,12 +134,19 @@ export interface OpenAiCompatibleConfig {
}
export interface ClaudeCodeSettings {
/** `null` = let Claude Code choose the renderer; `"default"` = classic, `"fullscreen"` = alt-screen. */
tui_mode: string | null;
/** `null` = unset, else `"low" | "medium" | "high" | "xhigh"`. Written as `effortLevel`. */
effort: string | null;
auto_scroll_disabled: boolean;
/** Written as `viewMode: "focus"`. */
focus_mode: boolean;
show_thinking_summaries: boolean;
enable_session_recap: boolean;
/**
* Turns the session recap **off**. Held in the disabled sense because Claude
* Code's recap is on by default — see the Rust doc on `ClaudeCodeSettings`.
*/
session_recap_disabled: boolean;
env_scrub: boolean;
prompt_caching_1h: boolean;
}