Merge branch 'feat/disk-and-settings' into integration/round-1
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user