diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 27ec5cbd2..3da3c6030 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ - Added experimental strict JSON-schema constrained sampling for the default `read`, `bash`, `edit`, and `write` tools under `PI_EXPERIMENTAL=1`. - Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint. - Added the `defaultTools` setting for configuring the initial built-in tool selection globally or per project. +- Added `--use-theme ` to choose an initial per-run interactive theme without changing saved settings ([#7722](https://github.com/earendil-works/pi/pull/7722) by [@rwachtler](https://github.com/rwachtler)). ### Changed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index e4e76e296..e6e43c2eb 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -609,6 +609,7 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set | `--system-prompt ` | Replace default prompt (context files and skills still appended) | | `--append-system-prompt ` | Append to system prompt | | `--tui-mode ` | TUI mode: `regular` (default) or experimental `fullscreen` | +| `--use-theme ` | Set the initial interactive theme for this run without changing settings | | `--verbose` | Force verbose startup | | `-a`, `--approve` | Trust project-local files for this run | | `-na`, `--no-approve` | Ignore project-local files for this run | diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index f9855f87f..99a2b9496 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -39,6 +39,23 @@ Select a theme via `/settings` or in `settings.json`: On first run, pi detects your terminal background and defaults to `dark` or `light`. +### Initial Theme + +Start an interactive run with a theme without changing the saved setting: + +```bash +pi --use-theme light +``` + +To follow terminal appearance, use `lightTheme/darkTheme` syntax: + +```bash +pi --use-theme light/dark +``` + +The CLI value is the initial theme for that run. Choosing another theme later in `/settings` applies it immediately +and saves it normally. + ## Creating a Custom Theme 1. Create a theme file: diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 397ab2812..41a5c37d9 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -242,6 +242,7 @@ pi --no-extensions -e ./my-extension.ts | `--system-prompt ` | Replace default prompt; context files and skills are still appended | | `--append-system-prompt ` | Append to system prompt | | `--tui-mode ` | TUI mode: `regular` (default) or experimental `fullscreen` | +| `--use-theme ` | Set the initial interactive theme for this run without changing settings | | `--verbose` | Force verbose startup | | `-a`, `--approve` | Trust project-local files for this run | | `-na`, `--no-approve` | Ignore project-local files for this run | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 5ea1ee8fb..3c0e01b3c 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -42,6 +42,7 @@ export interface Args { promptTemplates?: string[]; noPromptTemplates?: boolean; themes?: string[]; + useTheme?: string; noThemes?: boolean; noContextFiles?: boolean; listModels?: string | true; @@ -162,6 +163,14 @@ export function parseArgs(args: string[]): Args { } else if (arg === "--theme" && i + 1 < args.length) { result.themes = result.themes ?? []; result.themes.push(args[++i]); + } else if (arg === "--use-theme") { + const themeName = args[i + 1]; + if (themeName === undefined || themeName.startsWith("-")) { + result.diagnostics.push({ type: "error", message: "--use-theme requires a theme name" }); + } else { + result.useTheme = themeName; + i++; + } } else if (arg === "--no-skills" || arg === "-ns") { result.noSkills = true; } else if (arg === "--no-prompt-templates" || arg === "-np") { @@ -283,6 +292,7 @@ ${chalk.bold("Options:")} --prompt-template Load a prompt template file or directory (can be used multiple times) --no-prompt-templates, -np Disable prompt template discovery and loading --theme Load a theme file or directory (can be used multiple times) + --use-theme Set the initial interactive theme for this run --no-themes Disable theme discovery and loading --no-context-files, -nc Disable AGENTS.md and CLAUDE.md discovery and loading --export Export session file to HTML and exit diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c4dd457e2..1a1228d00 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -3220,11 +3220,13 @@ export class AgentSession { /** * Export session to HTML. * @param outputPath Optional output path (defaults to session directory) + * @param options Optional export presentation settings * @returns Path to exported file */ - async exportToHtml(outputPath?: string): Promise { - const configuredThemeName = this.settingsManager.getTheme(); - const themeName = configuredThemeName && getThemeByName(configuredThemeName) ? configuredThemeName : undefined; + async exportToHtml(outputPath?: string, options: { themeName?: string } = {}): Promise { + const themeName = [options.themeName, this.settingsManager.getTheme()].find( + (candidate) => candidate !== undefined && getThemeByName(candidate) !== undefined, + ); // Create tool renderer if we have an extension runner (for custom tool HTML rendering) const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({ diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 8db96a670..5ca03f3d8 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -665,6 +665,10 @@ export async function main(args: string[], options?: MainOptions) { time("firstTimeSetup"); } + if (appMode === "interactive" && parsed.useTheme !== undefined) { + startupSettingsManager.applyOverrides({ theme: parsed.useTheme }); + } + // Decide the final runtime cwd before creating cwd-bound runtime services. // --session and --resume may select a session from another project, so project-local // settings, resources, provider registrations, and models must be resolved only after @@ -933,6 +937,7 @@ export async function main(args: string[], options?: MainOptions) { initialMessages: parsed.messages, verbose: parsed.verbose, tuiMode: parsed.tuiMode, + initialThemeSetting: parsed.useTheme, }); if (startupBenchmark) { await interactiveMode.init(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 03d015a5a..d5a1b9159 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -329,6 +329,8 @@ export interface InteractiveModeOptions { verbose?: boolean; /** TUI layout mode. */ tuiMode?: TuiMode; + /** Initial interactive theme setting for this invocation. */ + initialThemeSetting?: string; } interface InteractiveTuiOptions { @@ -538,6 +540,7 @@ export class InteractiveMode { }); this.runtimeHost.setRebindSession(async () => { await this.rebindCurrentSession({ renderBeforeBind: true }); + await this.themeController.applyFromSettings(); }); this.version = VERSION; this.renderer = createInteractiveTui({ @@ -582,12 +585,12 @@ export class InteractiveMode { // Register themes from resource loader and initialize setRegisteredThemes(this.session.resourceLoader.getThemes().themes); - this.themeController = new InteractiveThemeController( - this.ui, - this.settingsManager, - (message) => this.showError(message), - () => this.updateEditorBorderColor(), - ); + this.themeController = new InteractiveThemeController(this.ui, { + getSettingsManager: () => this.settingsManager, + showError: (message) => this.showError(message), + onChanged: () => this.updateEditorBorderColor(), + initialThemeSetting: options.initialThemeSetting, + }); } private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined { @@ -4393,7 +4396,7 @@ export class InteractiveMode { httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), thinkingLevel: this.session.thinkingLevel, availableThinkingLevels: this.session.getAvailableThinkingLevels(), - currentTheme: this.settingsManager.getThemeSetting() || "dark", + currentTheme: this.themeController.getThemeSelection() || "dark", terminalTheme: this.themeController.getTerminalTheme(), availableThemes: getAvailableThemes(), hideThinkingBlock: this.hideThinkingBlock, @@ -4469,7 +4472,7 @@ export class InteractiveMode { }, onThemeChange: (themeSetting) => { this.settingsManager.setTheme(themeSetting); - void this.themeController.applyFromSettings(); + void this.themeController.setThemeSetting(themeSetting); }, onThemePreview: (themeName) => this.themeController.preview(themeName), onHideThinkingBlockChange: (hidden) => { @@ -5778,7 +5781,9 @@ export class InteractiveMode { const filePath = this.session.exportToJsonl(outputPath); this.showStatus(`Session exported to: ${filePath}`); } else { - const filePath = await this.session.exportToHtml(outputPath); + const filePath = await this.session.exportToHtml(outputPath, { + themeName: theme.name, + }); this.showStatus(`Session exported to: ${filePath}`); } } catch (error: unknown) { @@ -5875,7 +5880,7 @@ export class InteractiveMode { // Export to a temp file const tmpFile = path.join(os.tmpdir(), "session.html"); try { - await this.session.exportToHtml(tmpFile); + await this.session.exportToHtml(tmpFile, { themeName: theme.name }); } catch (error: unknown) { this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); return; diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts index 6d99b0ded..0684ecaee 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts @@ -17,20 +17,33 @@ type ThemeResult = { success: boolean; error?: string }; export class InteractiveThemeController { private readonly ui: TUI; - private readonly settingsManager: SettingsManager; + private readonly getSettingsManager: () => SettingsManager; private readonly showError: (message: string) => void; private readonly onChanged: () => void; + private currentThemeSetting: string | undefined; private terminalTheme: TerminalTheme = detectTerminalBackgroundFromEnv().theme; private activeThemeName: string | undefined; private autoSyncEnabled = false; private terminalColorSchemeUnsubscribe: (() => void) | undefined; - constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void) { + constructor( + ui: TUI, + options: { + getSettingsManager: () => SettingsManager; + showError: (message: string) => void; + onChanged: () => void; + initialThemeSetting?: string; + }, + ) { this.ui = ui; - this.settingsManager = settingsManager; - this.showError = showError; - this.onChanged = onChanged; - this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme); + this.getSettingsManager = options.getSettingsManager; + this.showError = options.showError; + this.onChanged = options.onChanged; + this.currentThemeSetting = options.initialThemeSetting; + this.activeThemeName = resolveThemeSetting( + this.currentThemeSetting ?? this.getSettingsManager().getThemeSetting(), + this.terminalTheme, + ); initTheme(this.activeThemeName, true); this.bindTerminalColorSchemeListener(); } @@ -42,7 +55,8 @@ export class InteractiveThemeController { } async applyFromSettings(): Promise { - const themeSetting = this.settingsManager.getThemeSetting(); + const settingsManager = this.getSettingsManager(); + const themeSetting = this.currentThemeSetting ?? settingsManager.getThemeSetting(); const autoTheme = parseAutoThemeSetting(themeSetting); if (autoTheme) { this.terminalTheme = await detectTerminalThemeForAuto({ ui: this.ui, timeoutMs: 100 }); @@ -61,14 +75,27 @@ export class InteractiveThemeController { this.terminalTheme = detection.theme; if (!this.applyThemeName(detection.theme).success) return; if (detection.confidence === "high") { - this.settingsManager.setTheme(detection.theme); - await this.settingsManager.flush(); + settingsManager.setTheme(detection.theme); + await settingsManager.flush(); } } + getThemeSelection(): string | undefined { + return this.currentThemeSetting ?? this.getSettingsManager().getThemeSetting() ?? this.activeThemeName; + } + setThemeName(themeName: string, showError = false): ThemeResult { this.setAutoSync(false); - return this.applyThemeName(themeName, showError); + const result = this.applyThemeName(themeName, showError); + if (result.success) { + this.currentThemeSetting = themeName; + } + return result; + } + + async setThemeSetting(themeSetting: string): Promise { + this.currentThemeSetting = themeSetting; + await this.applyFromSettings(); } setThemeInstance(themeInstance: Theme): ThemeResult { @@ -126,7 +153,7 @@ export class InteractiveThemeController { private applyTerminalTheme(terminalTheme: TerminalTheme): void { if (!this.autoSyncEnabled) return; this.terminalTheme = terminalTheme; - const autoTheme = parseAutoThemeSetting(this.settingsManager.getThemeSetting()); + const autoTheme = parseAutoThemeSetting(this.currentThemeSetting ?? this.getSettingsManager().getThemeSetting()); if (!autoTheme) { this.setAutoSync(false); return; diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 575d3666e..a8be4f8eb 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -260,6 +260,20 @@ describe("parseArgs", () => { }); }); + describe("--use-theme flag", () => { + test("parses --use-theme", () => { + const result = parseArgs(["--use-theme", "light"]); + expect(result.useTheme).toBe("light"); + }); + + test("reports when the theme name value is missing", () => { + const result = parseArgs(["--use-theme", "--print"]); + expect(result.useTheme).toBeUndefined(); + expect(result.print).toBe(true); + expect(result.diagnostics).toEqual([{ type: "error", message: "--use-theme requires a theme name" }]); + }); + }); + describe("--no-skills flag", () => { test("parses --no-skills flag", () => { const result = parseArgs(["--no-skills"]); diff --git a/packages/coding-agent/test/theme-controller.test.ts b/packages/coding-agent/test/theme-controller.test.ts new file mode 100644 index 000000000..a20009d57 --- /dev/null +++ b/packages/coding-agent/test/theme-controller.test.ts @@ -0,0 +1,125 @@ +import type { TUI } from "@earendil-works/pi-tui"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { initTheme, type TerminalTheme, theme } from "../src/modes/interactive/theme/theme.ts"; +import { InteractiveThemeController } from "../src/modes/interactive/theme/theme-controller.ts"; + +function createUi() { + const queryTerminalBackgroundColor = vi.fn(); + const queryTerminalColorScheme = vi.fn(); + const setTerminalColorSchemeNotifications = vi.fn(); + let terminalColorSchemeListener: ((terminalTheme: TerminalTheme) => void) | undefined; + const ui = { + invalidate: vi.fn(), + requestRender: vi.fn(), + setTerminalColorSchemeNotifications, + onTerminalColorSchemeChange: vi.fn((listener: (terminalTheme: TerminalTheme) => void) => { + terminalColorSchemeListener = listener; + return vi.fn(); + }), + queryTerminalBackgroundColor, + queryTerminalColorScheme, + } as unknown as TUI; + return { + ui, + queryTerminalBackgroundColor, + queryTerminalColorScheme, + setTerminalColorSchemeNotifications, + emitTerminalColorScheme: (terminalTheme: TerminalTheme) => terminalColorSchemeListener?.(terminalTheme), + }; +} + +function createController(ui: TUI, getSettingsManager: () => SettingsManager, initialThemeSetting?: string) { + return new InteractiveThemeController(ui, { + getSettingsManager, + showError: vi.fn(), + onChanged: vi.fn(), + initialThemeSetting, + }); +} + +afterEach(() => { + initTheme("dark"); + vi.unstubAllEnvs(); +}); + +describe("InteractiveThemeController", () => { + it("uses the initial theme without persisting it", async () => { + const { ui, queryTerminalBackgroundColor } = createUi(); + const manager = SettingsManager.inMemory({ theme: "dark" }); + const setTheme = vi.spyOn(manager, "setTheme"); + const flush = vi.spyOn(manager, "flush"); + const controller = createController(ui, () => manager, "light"); + + expect(theme.name).toBe("light"); + expect(controller.getThemeSelection()).toBe("light"); + await controller.applyFromSettings(); + + expect(queryTerminalBackgroundColor).not.toHaveBeenCalled(); + expect(setTheme).not.toHaveBeenCalled(); + expect(flush).not.toHaveBeenCalled(); + }); + + it("resolves a theme pair and follows terminal appearance changes", async () => { + vi.stubEnv("COLORFGBG", "15;0"); + const { ui, queryTerminalColorScheme, setTerminalColorSchemeNotifications, emitTerminalColorScheme } = createUi(); + queryTerminalColorScheme.mockResolvedValue("light"); + const manager = SettingsManager.inMemory({ theme: "dark/light" }); + const controller = createController(ui, () => manager, "light/dark"); + + expect(theme.name).toBe("dark"); + await controller.applyFromSettings(); + expect(theme.name).toBe("light"); + expect(setTerminalColorSchemeNotifications).toHaveBeenCalledWith(true); + + emitTerminalColorScheme("dark"); + expect(theme.name).toBe("dark"); + }); + + it("detects the current terminal appearance when selecting a theme pair", async () => { + vi.stubEnv("COLORFGBG", ""); + const { ui, queryTerminalColorScheme } = createUi(); + queryTerminalColorScheme.mockResolvedValue("light"); + const manager = SettingsManager.inMemory({ theme: "dark" }); + const controller = createController(ui, () => manager); + + expect(theme.name).toBe("dark"); + await controller.setThemeSetting("light/dark"); + expect(theme.name).toBe("light"); + expect(queryTerminalColorScheme).toHaveBeenCalledOnce(); + }); + + it("lets an explicit selection replace the initial theme", async () => { + const { ui } = createUi(); + const firstManager = SettingsManager.inMemory({ theme: "dark" }); + const secondManager = SettingsManager.inMemory({ theme: "light" }); + let manager = firstManager; + const controller = createController(ui, () => manager, "light"); + await controller.applyFromSettings(); + + expect(controller.setThemeName("dark")).toEqual({ success: true }); + manager = secondManager; + await controller.applyFromSettings(); + + expect(controller.getThemeSelection()).toBe("dark"); + expect(theme.name).toBe("dark"); + }); + + it("reloads theme settings when no initial theme was supplied", async () => { + const { ui } = createUi(); + const firstManager = SettingsManager.inMemory({ theme: "dark" }); + const secondManager = SettingsManager.inMemory({ theme: "light" }); + let manager = firstManager; + const controller = createController(ui, () => manager); + await controller.applyFromSettings(); + + firstManager.applyOverrides({ theme: "light" }); + await controller.applyFromSettings(); + expect(theme.name).toBe("light"); + + secondManager.applyOverrides({ theme: "dark" }); + manager = secondManager; + await controller.applyFromSettings(); + expect(theme.name).toBe("dark"); + }); +});