edit

### Example: Adding Ollama Models

See the configuration structure above. Create `~/.companion/agent/models.json` with your Ollama setup, then use `/model` to select your local models. The agent can also help you write this file if you point it to this README.

## Slash Commands

### Example: Adding Ollama Models

See the configuration structure above. Create `~/.companion/agent/models.json` with your Ollama setup, then use `/model` to select your local models. The agent can also help you write this file if you point it to this README.

## Themes

Companion supports customizable color themes for the TUI. Two built-in themes are available: `dark` (default) and `light`.

### Selecting a Theme

Use the `/theme` command to interactively select a theme, or edit your settings file:

```bash
# Interactive selector
companion
/theme

# Or edit ~/.companion/agent/settings.json
{
  "theme": "dark"  # or "light"
}
```

On first run, Companion auto-detects your terminal background (dark/light) and selects an appropriate theme.

### Custom Themes

Create custom themes in `~/.companion/agent/themes/*.json`. See [Theme Documentation](docs/theme.md) for:
- Complete list of 43 color tokens
- Theme format and examples
- Color value formats (hex, RGB, terminal default)
- Tips for creating harmonious themes

Example custom theme:

```json
{
  "$schema": "https://raw.githubusercontent.com/badlogic/companion-mono/main/packages/coding-agent/theme-schema.json",
  "name": "my-theme",
  "vars": {
    "accent": "#00aaff",
    "muted": "#6c6c6c"
  },
  "colors": {
    "accent": "accent",
    "muted": "muted",
    ...
  }
}
```

### VS Code Terminal Color Issue

**Important:** VS Code's integrated terminal has a known issue with rendering truecolor (24-bit RGB) values. By default, it applies a "minimum contrast ratio" adjustment that can make colors look washed out or identical.

To fix this, set the contrast ratio to 1 in VS Code settings:

1. Open Settings (Cmd/Ctrl + ,)
2. Search for: `terminal.integrated.minimumContrastRatio`
3. Set to: `1`

This ensures VS Code renders the exact RGB colors defined in your theme.

## Slash Commands


Successfully replaced text in packages/coding-agent/README.md. Changed 272 characters to 1813 characters.

     ...
   1 ### Example: Adding Ollama Models
   2
   3 See the configuration structure above. Create `~/.companion/agent/models.json` with your Ollama setup, then use `/model` to select your local models. The agent can also help you write this file if you point it to this README.
   4
+287 ## Themes
+288
+289 Companion supports customizable color themes for the TUI. Two built-in themes are available: `dark` (default) and `light`.
+290
+291 ### Selecting a Theme
+292
+293 Use the `/theme` command to interactively select a theme, or edit your settings file:
+294
+295 ```bash
+296 # Interactive selector
+297 companion
+298 /theme
+299
+300 # Or edit ~/.companion/agent/settings.json
+301 {
+302   "theme": "dark"  # or "light"
+303 }
+304 ```
+305
+306 On first run, Companion auto-detects your terminal background (dark/light) and selects an appropriate theme.
+307
+308 ### Custom Themes
+309
+310 Create custom themes in `~/.companion/agent/themes/*.json`. See [Theme Documentation](docs/theme.md) for:
+311 - Complete list of 43 color tokens
+312 - Theme format and examples
+313 - Color value formats (hex, RGB, terminal default)
+314 - Tips for creating harmonious themes
+315
+316 Example custom theme:
+317
+318 ```json
+319 {
+320   "$schema": "https://raw.githubusercontent.com/badlogic/companion-mono/main/packages/coding-agent/theme-schema.json",
+321   "name": "my-theme",
+322   "vars": {
+323     "accent": "#00aaff",
+324     "muted": "#6c6c6c"
+325   },
+326   "colors": {
+327     "accent": "accent",
+328     "muted": "muted",
+329     ...
+330   }
+331 }
+332 ```
+333
+334 ### VS Code Terminal Color Issue
+335
+336 **Important:** VS Code's integrated terminal has a known issue with rendering truecolor (24-bit RGB) values. By default, it applies a "minimum contrast ratio" adjustment that can make colors look washed out or identical.
+337
+338 To fix this, set the contrast ratio to 1 in VS Code settings:
+339
+340 1. Open Settings (Cmd/Ctrl + ,)
+341 2. Search for: `terminal.integrated.minimumContrastRatio`
+342 3. Set to: `1`
+343
+344 This ensures VS Code renders the exact RGB colors defined in your theme.
+345
 287 ## Slash Commands
 288
 289 The CLI supports several commands to control its behavior:
 290
     ...


Now update the Table of Contents:

- [API Keys](#api-keys)
- [OAuth Authentication (Optional)](#oauth-authentication-optional)
- [Custom Models and Providers](#custom-models-and-providers)
- [Slash Commands](#slash-commands)

- [API Keys](#api-keys)
- [OAuth Authentication (Optional)](#oauth-authentication-optional)
- [Custom Models and Providers](#custom-models-and-providers)
- [Themes](#themes)
- [Slash Commands](#slash-commands)


Successfully replaced text in packages/coding-agent/README.md. Changed 189 characters to 209 characters.

     ...
   1 - [Quick Start](#quick-start)
   2 - [API Keys](#api-keys)
   3 - [OAuth Authentication (Optional)](#oauth-authentication-optional)
   4 - [Custom Models and Providers](#custom-models-and-providers)
+ 14 - [Themes](#themes)
  14 - [Slash Commands](#slash-commands)
  15 - [Editor Features](#editor-features)
  16 - [Project Context Files](#project-context-files)
  17 - [Image Support](#image-support)
     ...


Perfect! Added a comprehensive Themes section to the README that covers:

1. **Selecting themes** - Both interactive (`/theme`) and manual (settings.json)
2. **Auto-detection** - Mentions terminal background detection
3. **Custom themes** - Points to full documentation and shows a minimal example
4. **VS Code fix** - Explains the contrast ratio issue and how to fix it

The section is placed logically between "Custom Models" and "Slash Commands" since `/theme` is a slash command that users will discover.


+314 - Tips for creating harmonious themes

kill this. also, do we load themes when we open the selector? then it's kinda like a hot reload, no?


Create custom themes in `~/.companion/agent/themes/*.json`. See [Theme Documentation](docs/theme.md) for:
- Complete list of 43 color tokens
- Theme format and examples
- Color value formats (hex, RGB, terminal default)
- Tips for creating harmonious themes


Successfully replaced text in packages/coding-agent/README.md. Changed 250 characters to 212 characters.

     ...
   1 Create custom themes in `~/.companion/agent/themes/*.json`. See [Theme Documentation](docs/theme.md) for:
   2 - Complete list of 43 color tokens
   3 - Theme format and examples
   4 - Color value formats (hex, RGB, terminal default)
-315 - Tips for creating harmonious themes
 316
 317 Example custom theme:
 318
 319 ```json
     ...


Now let me check if themes are loaded when the selector opens:

bash

grep -A 20 "class ThemeSelectorComponent" packages/coding-agent/src/tui/theme-selector.ts


export class ThemeSelectorComponent extends Container {
	private selectList: SelectList;
	private onPreview: (themeName: string) => void;

	constructor(
		currentTheme: string,
		onSelect: (themeName: string) => void,
		onCancel: () => void,
		onPreview: (themeName: string) => void,
	) {
		super();
		this.onPreview = onPreview;

		// Get available themes and create select items
		const themes = getAvailableThemes();
		const themeItems: SelectItem[] = themes.map((name) => ({
			value: name,
			label: name,
			description: name === currentTheme ? "(current)" : undefined,
		}));


grep -A 10 "export function getAvailableThemes" packages/coding-agent/src/theme/theme.ts


export function getAvailableThemes(): string[] {
	const themes = new Set<string>(Object.keys(getBuiltinThemes()));
	const themesDir = getThemesDir();
	if (fs.existsSync(themesDir)) {
		const files = fs.readdirSync(themesDir);
		for (const file of files) {
			if (file.endsWith(".json")) {
				themes.add(file.slice(0, -5));
			}
		}
	}


Yes! The theme selector calls `getAvailableThemes()` in its constructor, which reads the filesystem to find all theme files. So it's essentially hot-reload - you can create/edit theme files and they'll show up when you open the selector. Let me update the README to mention this:

edit

### Custom Themes

Create custom themes in `~/.companion/agent/themes/*.json`. See [Theme Documentation](docs/theme.md) for:
- Complete list of 43 color tokens
- Theme format and examples
- Color value formats (hex, RGB, terminal default)

### Custom Themes

Create custom themes in `~/.companion/agent/themes/*.json`. Custom themes are automatically discovered - just save a `.json` file and use `/theme` to select it (no restart needed).

See [Theme Documentation](docs/theme.md) for:
- Complete list of 43 color tokens
- Theme format and examples
- Color value formats (hex, RGB, terminal default)


Successfully replaced text in packages/coding-agent/README.md. Changed 231 characters to 353 characters.

     ...
   1 On first run, Companion auto-detects your terminal background (dark/light) and selects an appropriate theme.
   2
   3 ### Custom Themes
   4
-311 Create custom themes in `~/.companion/agent/themes/*.json`. See [Theme Documentation](docs/theme.md) for:
+311 Create custom themes in `~/.companion/agent/themes/*.json`. Custom themes are automatically discovered - just save a `.json` file and use `/theme` to select it (no restart needed).
+312
+313 See [Theme Documentation](docs/theme.md) for:
 312 - Complete list of 43 color tokens
 313 - Theme format and examples
 314 - Color value formats (hex, RGB, terminal default)
 315
     ...


Perfect! Now it's clear that custom themes are hot-reloaded - users can create/edit theme files and immediately select them via `/theme` without restarting.


i suppose the workflow would be write .json, use  /theme to preview, modify use /theme again to preview


Exactly! Let me make that workflow more explicit in the README:

### Custom Themes

Create custom themes in `~/.companion/agent/themes/*.json`. Custom themes are automatically discovered - just save a `.json` file and use `/theme` to select it (no restart needed).

See [Theme Documentation](docs/theme.md) for:
- Complete list of 43 color tokens
- Theme format and examples
- Color value formats (hex, RGB, terminal default)

### Custom Themes

Create custom themes in `~/.companion/agent/themes/*.json`. Custom themes are automatically discovered - just save a `.json` file and use `/theme` to select it (no restart needed).

**Workflow for creating themes:**
1. Create/edit a theme file in `~/.companion/agent/themes/my-theme.json`
2. Use `/theme` to preview it (live preview as you navigate)
3. Modify the file, use `/theme` again to see changes
4. Repeat until satisfied

See [Theme Documentation](docs/theme.md) for:
- Complete list of 43 color tokens
- Theme format and examples
- Color value formats (hex, RGB, terminal default)


Successfully replaced text in packages/coding-agent/README.md. Changed 353 characters to 595 characters.

     ...
   1 ### Custom Themes
   2
   3 Create custom themes in `~/.companion/agent/themes/*.json`. Custom themes are automatically discovered - just save a `.json` file and use `/theme` to select it (no restart needed).
   4
+313 **Workflow for creating themes:**
+314 1. Create/edit a theme file in `~/.companion/agent/themes/my-theme.json`
+315 2. Use `/theme` to preview it (live preview as you navigate)
+316 3. Modify the file, use `/theme` again to see changes
+317 4. Repeat until satisfied
+318
 313 See [Theme Documentation](docs/theme.md) for:
 314 - Complete list of 43 color tokens
 315 - Theme format and examples
 316 - Color value formats (hex, RGB, terminal default)
     ...


Perfect! Now the iterative workflow is crystal clear - users know they can edit-preview-edit-preview without any restarts.


hmmmm could we havea  file watcher (i think node has a built in one now) for the custom themes? which triggers a reload and invalidation? that way people can live edit the theme?


Yes! Node.js has `fs.watch()` built-in. Let me add a file watcher for custom themes:

bash

grep -n "export let theme" packages/coding-agent/src/theme/theme.ts


424:export let theme: Theme;


export let theme: Theme;

export function initTheme(themeName?: string): void {
	const name = themeName ?? getDefaultTheme();
	theme = loadTheme(name);
}

export function setTheme(name: string): void {
	theme = loadTheme(name);
}

// ============================================================================
// TUI Helpers
// ============================================================================

export function getMarkdownTheme(): MarkdownTheme {
	return {
		heading: (text: string) => theme.fg("mdHeading", text),
		link: (text: string) => theme.fg("mdLink", text),
		linkUrl: (text: string) => theme.fg("mdLinkUrl", text),

... (31 more lines not shown. Use offset=444 to continue reading)


edit

export let theme: Theme;

export function initTheme(themeName?: string): void {
	const name = themeName ?? getDefaultTheme();
	theme = loadTheme(name);
}

export function setTheme(name: string): void {
	theme = loadTheme(name);
}

export let theme: Theme;
let currentThemeName: string | undefined;
let themeWatcher: fs.FSWatcher | undefined;
let onThemeChangeCallback: (() => void) | undefined;

export function initTheme(themeName?: string): void {
	const name = themeName ?? getDefaultTheme();
	currentThemeName = name;
	theme = loadTheme(name);
	startThemeWatcher();
}

export function setTheme(name: string): void {
	currentThemeName = name;
	theme = loadTheme(name);
	startThemeWatcher();
}

export function onThemeChange(callback: () => void): void {
	onThemeChangeCallback = callback;
}

function startThemeWatcher(): void {
	// Stop existing watcher if any
	if (themeWatcher) {
		themeWatcher.close();
		themeWatcher = undefined;
	}

	// Only watch if it's a custom theme (not built-in)
	if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") {
		return;
	}

	const themesDir = getThemesDir();
	const themeFile = path.join(themesDir, `${currentThemeName}.json`);

	// Only watch if the file exists
	if (!fs.existsSync(themeFile)) {
		return;
	}

	try {
		themeWatcher = fs.watch(themeFile, (eventType) => {
			if (eventType === "change") {
				// Debounce rapid changes
				setTimeout(() => {
					try {
						// Reload the theme
						theme = loadTheme(currentThemeName!);
						// Notify callback (to invalidate UI)
						if (onThemeChangeCallback) {
							onThemeChangeCallback();
						}
					} catch (error) {
						// Ignore errors (file might be in invalid state while being edited)
						console.error(`[theme] Failed to reload theme: ${error}`);
					}
				}, 100);
			}
		});
	} catch (error) {
		console.error(`[theme] Failed to watch theme file: ${error}`);
	}
}

export function stopThemeWatcher(): void {
	if (themeWatcher) {
		themeWatcher.close();
		themeWatcher = undefined;
	}
}


Successfully replaced text in packages/coding-agent/src/theme/theme.ts. Changed 229 characters to 1805 characters.

     ...
   1 // Global Theme Instance
   2 // ============================================================================
   3
   4 export let theme: Theme;
+425 let currentThemeName: string | undefined;
+426 let themeWatcher: fs.FSWatcher | undefined;
+427 let onThemeChangeCallback: (() => void) | undefined;
 425
 426 export function initTheme(themeName?: string): void {
 427 	const name = themeName ?? getDefaultTheme();
+431 	currentThemeName = name;
 428 	theme = loadTheme(name);
+433 	startThemeWatcher();
 429 }
 430
 431 export function setTheme(name: string): void {
+437 	currentThemeName = name;
 432 	theme = loadTheme(name);
+439 	startThemeWatcher();
 433 }
 434
+442 export function onThemeChange(callback: () => void): void {
+443 	onThemeChangeCallback = callback;
+444 }
+445
+446 function startThemeWatcher(): void {
+447 	// Stop existing watcher if any
+448 	if (themeWatcher) {
+449 		themeWatcher.close();
+450 		themeWatcher = undefined;
+451 	}
+452
+453 	// Only watch if it's a custom theme (not built-in)
+454 	if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") {
+455 		return;
+456 	}
+457
+458 	const themesDir = getThemesDir();
+459 	const themeFile = path.join(themesDir, `${currentThemeName}.json`);
+460
+461 	// Only watch if the file exists
+462 	if (!fs.existsSync(themeFile)) {
+463 		return;
+464 	}
+465
+466 	try {
+467 		themeWatcher = fs.watch(themeFile, (eventType) => {
+468 			if (eventType === "change") {
+469 				// Debounce rapid changes
+470 				setTimeout(() => {
+471 					try {
+472 						// Reload the theme
+473 						theme = loadTheme(currentThemeName!);
+474 						// Notify callback (to invalidate UI)
+475 						if (onThemeChangeCallback) {
+476 							onThemeChangeCallback();
+477 						}
+478 					} catch (error) {
+479 						// Ignore errors (file might be in invalid state while being edited)
+480 						console.error(`[theme] Failed to reload theme: ${error}`);
+481 					}
+482 				}, 100);
+483 			}
+484 		});
+485 	} catch (error) {
+486 		console.error(`[theme] Failed to watch theme file: ${error}`);
+487 	}
+488 }
+489
+490 export function stopThemeWatcher(): void {
+491 	if (themeWatcher) {
+492 		themeWatcher.close();
+493 		themeWatcher = undefined;
+494 	}
+495 }
+496
 435 // ============================================================================
 436 // TUI Helpers
 437 // ============================================================================
 438
     ...


Now hook it up to the TUI renderer to invalidate and re-render when the theme changes:

bash

grep -n "async init" packages/coding-agent/src/tui/tui-renderer.ts | head -5


192:	async init(): Promise<void> {


	async init(): Promise<void> {
		if (this.isInitialized) return;

		// Add header with logo and instructions
		const logo = theme.bold(theme.fg("accent", "companion")) + theme.fg("dim", ` v${this.version}`);
		const instructions =
			theme.fg("dim", "esc") +
			theme.fg("muted", " to interrupt") +
			"\n" +
			theme.fg("dim", "ctrl+c") +
			theme.fg("muted", " to clear") +
			"\n" +
			theme.fg("dim", "ctrl+c twice") +
			theme.fg("muted", " to exit") +
			"\n" +
			theme.fg("dim", "ctrl+k") +
			theme.fg("muted", " to delete line") +
			"\n" +
			theme.fg("dim", "shift+tab") +
			theme.fg("muted", " to cycle thinking") +
			"\n" +
			theme.fg("dim", "ctrl+p") +
			theme.fg("muted", " to cycle models") +
			"\n" +
			theme.fg("dim", "ctrl+o") +
			theme.fg("muted", " to expand tools") +
			"\n" +
			theme.fg("dim", "/") +
			theme.fg("muted", " for commands") +
			"\n" +
			theme.fg("dim", "drop files") +
			theme.fg("muted", " to attach");
		const header = new Text(logo + "\n" + instructions, 1, 0);

		// Setup UI layout
		this.ui.addChild(new Spacer(1));
		this.ui.addChild(header);
		this.ui.addChild(new Spacer(1));

		// Add new version notification if available
		if (this.newVersion) {
			this.ui.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
			this.ui.addChild(
				new Text(
					theme.bold(theme.fg("warning", "Update Available")) +
						"\n" +
						theme.fg("muted", `New version ${this.newVersion} is available. Run: `) +
						theme.fg("accent", "npm install -g @mariozechner/companion-coding-agent"),
					1,
					0,

... (1140 more lines not shown. Use offset=242 to continue reading)


grep -n "this.ui.start()" packages/coding-agent/src/tui/tui-renderer.ts


436:		this.ui.start();


			if (this.onInputCallback) {
				this.onInputCallback(text);
			}
		};

		// Start the UI
		this.ui.start();
		this.isInitialized = true;
	}

	async handleEvent(event: AgentEvent, state: AgentState): Promise<void> {
		if (!this.isInitialized) {
			await this.init();
		}

... (937 more lines not shown. Use offset=445 to continue reading)


edit

		// Start the UI
		this.ui.start();
		this.isInitialized = true;
	}

		// Start the UI
		this.ui.start();
		this.isInitialized = true;

		// Set up theme file watcher for live reload
		onThemeChange(() => {
			this.ui.invalidate();
			this.updateEditorBorderColor();
			this.ui.requestRender();
		});
	}


Successfully replaced text in packages/coding-agent/src/tui/tui-renderer.ts. Changed 68 characters to 234 characters.

      ...
    1
    2 		// Start the UI
    3 		this.ui.start();
    4 		this.isInitialized = true;
+ 438
+ 439 		// Set up theme file watcher for live reload
+ 440 		onThemeChange(() => {
+ 441 			this.ui.invalidate();
+ 442 			this.updateEditorBorderColor();
+ 443 			this.ui.requestRender();
+ 444 		});
  438 	}
  439
  440 	async handleEvent(event: AgentEvent, state: AgentState): Promise<void> {
  441 		if (!this.isInitialized) {
      ...


bash

grep -n "^import.*theme" packages/coding-agent/src/tui/tui-renderer.ts


24:import { getEditorTheme, getMarkdownTheme, setTheme, theme } from "../theme/theme.js";
32:import { ThemeSelectorComponent } from "./theme-selector.js";


edit

import { getEditorTheme, getMarkdownTheme, setTheme, theme } from "../theme/theme.js";

import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";


Successfully replaced text in packages/coding-agent/src/tui/tui-renderer.ts. Changed 86 characters to 101 characters.

      ...
    1 import { getApiKeyForModel, getAvailableModels } from "../model-config.js";
    2 import { listOAuthProviders, login, logout } from "../oauth/index.js";
    3 import type { SessionManager } from "../session-manager.js";
    4 import type { SettingsManager } from "../settings-manager.js";
-  24 import { getEditorTheme, getMarkdownTheme, setTheme, theme } from "../theme/theme.js";
+  24 import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
   25 import { AssistantMessageComponent } from "./assistant-message.js";
   26 import { CustomEditor } from "./custom-editor.js";
   27 import { DynamicBorder } from "./dynamic-border.js";
   28 import { FooterComponent } from "./footer.js";
      ...


Now let's update the README to mention live editing:

### Custom Themes

Create custom themes in `~/.companion/agent/themes/*.json`. Custom themes are automatically discovered - just save a `.json` file and use `/theme` to select it (no restart needed).

**Workflow for creating themes:**
1. Create/edit a theme file in `~/.companion/agent/themes/my-theme.json`
2. Use `/theme` to preview it (live preview as you navigate)
3. Modify the file, use `/theme` again to see changes
4. Repeat until satisfied

### Custom Themes

Create custom themes in `~/.companion/agent/themes/*.json`. Custom themes support **live editing** - when you select a custom theme, companion watches the file and automatically reloads when you save changes.

**Workflow for creating themes:**
1. Create a theme file in `~/.companion/agent/themes/my-theme.json`
2. Use `/theme` to select it
3. Edit the file in your editor - changes apply immediately on save
4. Iterate until satisfied (no need to re-select the theme)


Successfully replaced text in packages/coding-agent/README.md. Changed 434 characters to 466 characters.

     ...
   1 On first run, Companion auto-detects your terminal background (dark/light) and selects an appropriate theme.
   2
   3 ### Custom Themes
   4
-311 Create custom themes in `~/.companion/agent/themes/*.json`. Custom themes are automatically discovered - just save a `.json` file and use `/theme` to select it (no restart needed).
+311 Create custom themes in `~/.companion/agent/themes/*.json`. Custom themes support **live editing** - when you select a custom theme, companion watches the file and automatically reloads when you save changes.
 312
 313 **Workflow for creating themes:**
-314 1. Create/edit a theme file in `~/.companion/agent/themes/my-theme.json`
-315 2. Use `/theme` to preview it (live preview as you navigate)
-316 3. Modify the file, use `/theme` again to see changes
-317 4. Repeat until satisfied
+314 1. Create a theme file in `~/.companion/agent/themes/my-theme.json`
+315 2. Use `/theme` to select it
+316 3. Edit the file in your editor - changes apply immediately on save
+317 4. Iterate until satisfied (no need to re-select the theme)
 318
 319 See [Theme Documentation](docs/theme.md) for:
 320 - Complete list of 43 color tokens
 321 - Theme format and examples
     ...


bash

cd packages/coding-agent && npm run build 2>&1 | tail -5


> @mariozechner/companion-coding-agent@0.7.29 copy-theme-assets
> cp src/theme/*.json dist/theme/

Waiting for the debugger to disconnect...
Waiting for the debugger to disconnect...


Perfect! Now the theme system has live reload:

**How it works:**
1. When a custom theme is selected, `startThemeWatcher()` sets up an `fs.watch()` on that specific theme file
2. When the file changes, it reloads the theme and calls the `onThemeChange` callback
3. The TUI renderer invalidates all components and re-renders with the new theme
4. Built-in themes (`dark`/`light`) aren't watched since they can't be edited

**Features:**
- Debounced (100ms) to handle rapid saves
- Errors are caught and logged (so invalid JSON while typing doesn't crash)
- Only watches the currently active custom theme
- Automatically stops watching when switching themes

Users can now edit their theme files in real-time and see changes immediately!


ok, copy the dark theme over to the .companion/agent/themes folder and let me try the live edit


mkdir -p ~/.companion/agent/themes && cp packages/coding-agent/src/theme/dark.json ~/.companion/agent/themes/test.json


(no output)


total 8
drwxr-xr-x@ 3 badlogic  staff    96 Nov 21 02:31 .
drwxr-xr-x@ 7 badlogic  staff   224 Nov 21 02:31 ..
-rw-r--r--@ 1 badlogic  staff  1594 Nov 21 02:31 test.json


Done! I've copied the dark theme to `~/.companion/agent/themes/test.json`.

Now you can:
1. Run `companion` and use `/theme` to select "test"
2. Open `~/.companion/agent/themes/test.json` in your editor
3. Modify any color (e.g., change `"accent": "#8abeb7"` to `"accent": "#ff0000"`)
4. Save the file
5. Watch the colors update immediately in the running companion session!

Try changing some obvious colors like `accent`, `success`, or `error` to see the live reload in action.


ok, what happens if you delete the theme?


Good question! Let me check what happens:

grep -A 20 "themeWatcher = fs.watch" packages/coding-agent/src/theme/theme.ts


		themeWatcher = fs.watch(themeFile, (eventType) => {
			if (eventType === "change") {
				// Debounce rapid changes
				setTimeout(() => {
					try {
						// Reload the theme
						theme = loadTheme(currentThemeName!);
						// Notify callback (to invalidate UI)
						if (onThemeChangeCallback) {
							onThemeChangeCallback();
						}
					} catch (error) {
						// Ignore errors (file might be in invalid state while being edited)
						console.error(`[theme] Failed to reload theme: ${error}`);
					}
				}, 100);
			}
		});
	} catch (error) {
		console.error(`[theme] Failed to watch theme file: ${error}`);
	}


Right now if you delete the theme file, `fs.watch()` will trigger a `"rename"` event (which we ignore - we only handle `"change"`), and then `loadTheme()` will fail with "Theme not found" and we'll catch and log the error. But the UI will still try to use the old theme instance.

We should handle deletion better - either fall back to the default theme or at least detect the deletion. Let me fix this:

edit

	try {
		themeWatcher = fs.watch(themeFile, (eventType) => {
			if (eventType === "change") {
				// Debounce rapid changes
				setTimeout(() => {
					try {
						// Reload the theme
						theme = loadTheme(currentThemeName!);
						// Notify callback (to invalidate UI)
						if (onThemeChangeCallback) {
							onThemeChangeCallback();
						}
					} catch (error) {
						// Ignore errors (file might be in invalid state while being edited)
						console.error(`[theme] Failed to reload theme: ${error}`);
					}
				}, 100);
			}
		});
	} catch (error) {
		console.error(`[theme] Failed to watch theme file: ${error}`);
	}

	try {
		themeWatcher = fs.watch(themeFile, (eventType) => {
			if (eventType === "change") {
				// Debounce rapid changes
				setTimeout(() => {
					try {
						// Reload the theme
						theme = loadTheme(currentThemeName!);
						// Notify callback (to invalidate UI)
						if (onThemeChangeCallback) {
							onThemeChangeCallback();
						}
					} catch (error) {
						// Ignore errors (file might be in invalid state while being edited)
						console.error(`[theme] Failed to reload theme: ${error}`);
					}
				}, 100);
			} else if (eventType === "rename") {
				// File was deleted or renamed - fall back to default theme
				setTimeout(() => {
					if (!fs.existsSync(themeFile)) {
						console.error(`[theme] Theme file deleted, falling back to dark theme`);
						currentThemeName = "dark";
						theme = loadTheme("dark");
						if (themeWatcher) {
							themeWatcher.close();
							themeWatcher = undefined;
						}
						if (onThemeChangeCallback) {
							onThemeChangeCallback();
						}
					}
				}, 100);
			}
		});
	} catch (error) {
		console.error(`[theme] Failed to watch theme file: ${error}`);
	}


Successfully replaced text in packages/coding-agent/src/theme/theme.ts. Changed 627 characters to 1128 characters.

     ...
   1 						// Ignore errors (file might be in invalid state while being edited)
   2 						console.error(`[theme] Failed to reload theme: ${error}`);
   3 					}
   4 				}, 100);
+483 			} else if (eventType === "rename") {
+484 				// File was deleted or renamed - fall back to default theme
+485 				setTimeout(() => {
+486 					if (!fs.existsSync(themeFile)) {
+487 						console.error(`[theme] Theme file deleted, falling back to dark theme`);
+488 						currentThemeName = "dark";
+489 						theme = loadTheme("dark");
+490 						if (themeWatcher) {
+491 							themeWatcher.close();
+492 							themeWatcher = undefined;
+493 						}
+494 						if (onThemeChangeCallback) {
+495 							onThemeChangeCallback();
+496 						}
+497 					}
+498 				}, 100);
 483 			}
 484 		});
 485 	} catch (error) {
 486 		console.error(`[theme] Failed to watch theme file: ${error}`);
     ...


bash

cd packages/coding-agent && npm run build 2>&1 | tail -3


Waiting for the debugger to disconnect...
Waiting for the debugger to disconnect...


Now if you delete the theme file while it's active:
1. `fs.watch()` triggers a `"rename"` event
2. We check if the file still exists
3. If deleted, we automatically fall back to the `dark` theme
4. We stop the file watcher (no need to watch a deleted file)
5. We invalidate and re-render the UI with the dark theme

So deletion is handled gracefully - you won't be stuck with a broken theme, it'll just revert to dark.


delete the file


Done! The `test.json` theme file has been deleted. If you had it selected when it was deleted, companion should have automatically fallen back to the dark theme and logged: `[theme] Theme file deleted, falling back to dark theme`


in the README.md on cusotm themes, i guess we can instruct the user (and yousrelf) to copy an existing built-in theme over and modify that


Great idea! Let me update the README with that workflow:

edit

### Custom Themes

Create custom themes in `~/.companion/agent/themes/*.json`. Custom themes support **live editing** - when you select a custom theme, companion watches the file and automatically reloads when you save changes.

**Workflow for creating themes:**
1. Create a theme file in `~/.companion/agent/themes/my-theme.json`
2. Use `/theme` to select it
3. Edit the file in your editor - changes apply immediately on save
4. Iterate until satisfied (no need to re-select the theme)

### Custom Themes

Create custom themes in `~/.companion/agent/themes/*.json`. Custom themes support **live editing** - when you select a custom theme, companion watches the file and automatically reloads when you save changes.

**Workflow for creating themes:**
1. Copy a built-in theme as a starting point:
   ```bash
   mkdir -p ~/.companion/agent/themes
   # Copy dark theme
   cp $(npm root -g)/@mariozechner/companion-coding-agent/dist/theme/dark.json ~/.companion/agent/themes/my-theme.json
   # Or copy light theme
   cp $(npm root -g)/@mariozechner/companion-coding-agent/dist/theme/light.json ~/.companion/agent/themes/my-theme.json
   ```
2. Use `/theme` to select "my-theme"
3. Edit `~/.companion/agent/themes/my-theme.json` - changes apply immediately on save
4. Iterate until satisfied (no need to re-select the theme)


Successfully replaced text in packages/coding-agent/README.md. Changed 466 characters to 776 characters.

     ...
   1
   2 Create custom themes in `~/.companion/agent/themes/*.json`. Custom themes support **live editing** - when you select a custom theme, companion watches the file and automatically reloads when you save changes.
   3
   4 **Workflow for creating themes:**
-314 1. Create a theme file in `~/.companion/agent/themes/my-theme.json`
-315 2. Use `/theme` to select it
-316 3. Edit the file in your editor - changes apply immediately on save
+314 1. Copy a built-in theme as a starting point:
+315    ```bash
+316    mkdir -p ~/.companion/agent/themes
+317    # Copy dark theme
+318    cp $(npm root -g)/@mariozechner/companion-coding-agent/dist/theme/dark.json ~/.companion/agent/themes/my-theme.json
+319    # Or copy light theme
+320    cp $(npm root -g)/@mariozechner/companion-coding-agent/dist/theme/light.json ~/.companion/agent/themes/my-theme.json
+321    ```
+322 2. Use `/theme` to select "my-theme"
+323 3. Edit `~/.companion/agent/themes/my-theme.json` - changes apply immediately on save
 317 4. Iterate until satisfied (no need to re-select the theme)
 318
 319 See [Theme Documentation](docs/theme.md) for:
 320 - Complete list of 43 color tokens
     ...


Perfect! Now the README shows users exactly how to get started with custom themes by copying a built-in theme as a template. This is way easier than starting from scratch and consulting the docs for all 43 color tokens.


ok, let me try the light theme real quick, then we should be good to go for a new release


in the system prompt make sure you know to read the README.md also when the user asks you to create a new theme


# companion

A radically simple and opinionated coding agent with multi-model support (including mid-session switching), a simple yet powerful CLI for headless coding tasks, and many creature comforts you might be used to from other coding agents.

Works on Linux, macOS, and Windows (barely tested, needs Git Bash running in the "modern" Windows Terminal).

## Table of Contents

- [Installation](#installation)
- [Quick Start](#quick-start)
- [API Keys](#api-keys)
- [OAuth Authentication (Optional)](#oauth-authentication-optional)
- [Custom Models and Providers](#custom-models-and-providers)
- [Themes](#themes)
- [Slash Commands](#slash-commands)
- [Editor Features](#editor-features)
- [Project Context Files](#project-context-files)
- [Image Support](#image-support)
- [Session Management](#session-management)
- [CLI Options](#cli-options)
- [Tools](#tools)
- [Usage](#usage)
- [Security (YOLO by default)](#security-yolo-by-default)
- [Sub-Agents](#sub-agents)
- [To-Dos](#to-dos)
- [Planning](#planning)
- [Background Bash](#background-bash)
- [Planned Features](#planned-features)
- [License](#license)
- [See Also](#see-also)

... (846 more lines not shown. Use offset=31 to continue reading)


Good point about the system prompt. Let me check AGENTS.md:


- When receiving the first user message, you MUST read README.md in full. Then ask the user which module(s) they want to work on. Then you MUST read the corresponding README.md files in full, in parallel:
    - packages/ai/README.md
    - packages/tui/README.md
    - packages/agent/README.md
    - packages/coding-agent.md
    - packages/pods/README.md
    - packages/web-ui/README.md
- We must NEVER have type `any` anywhere, unless absolutely, positively necessary.
- If you are working with an external API, check node_modules for the type definitions as needed instead of assuming things.
- Always run `npm run check` in the project's root directory after making code changes.
- You must NEVER run `npm run dev` yourself. Doing is means you failed the user hard.
- Do NOT commit unless asked to by the user
- Keep you answers short and concise and to the point.
- Do NOT use inline imports ala `await import("./theme/theme.js");`


thatÄ's not the system prompt. see main.ts


bash

find packages/coding-agent -name "main.ts" -type f


grep -A 50 "system.*prompt\|systemPrompt" packages/coding-agent/src/main.ts | head -80


	systemPrompt?: string;
	continue?: boolean;
	resume?: boolean;
	help?: boolean;
	mode?: Mode;
	noSession?: boolean;
	session?: string;
	models?: string[];
	messages: string[];
}

function parseArgs(args: string[]): Args {
	const result: Args = {
		messages: [],
	};

	for (let i = 0; i < args.length; i++) {
		const arg = args[i];

		if (arg === "--help" || arg === "-h") {
			result.help = true;
		} else if (arg === "--mode" && i + 1 < args.length) {
			const mode = args[++i];
			if (mode === "text" || mode === "json" || mode === "rpc") {
				result.mode = mode;
			}
		} else if (arg === "--continue" || arg === "-c") {
			result.continue = true;
		} else if (arg === "--resume" || arg === "-r") {
			result.resume = true;
		} else if (arg === "--provider" && i + 1 < args.length) {
			result.provider = args[++i];
		} else if (arg === "--model" && i + 1 < args.length) {
			result.model = args[++i];
		} else if (arg === "--api-key" && i + 1 < args.length) {
			result.apiKey = args[++i];
		} else if (arg === "--system-prompt" && i + 1 < args.length) {
			result.systemPrompt = args[++i];
		} else if (arg === "--no-session") {
			result.noSession = true;
		} else if (arg === "--session" && i + 1 < args.length) {
			result.session = args[++i];
		} else if (arg === "--models" && i + 1 < args.length) {
			result.models = args[++i].split(",").map((s) => s.trim());
		} else if (!arg.startsWith("-")) {
			result.messages.push(arg);
		}
	}

	return result;
}

function printHelp() {
	console.log(`${chalk.bold("companion")} - AI coding assistant with read, bash, edit, write tools

${chalk.bold("Usage:")}
  companion [options] [messages...]

${chalk.bold("Options:")}
  --provider <name>       Provider name (default: google)
  --model <id>            Model ID (default: gemini-2.5-flash)
  --api-key <key>         API key (defaults to env vars)
  --system-prompt <text>  System prompt (default: coding assistant prompt)
  --mode <mode>           Output mode: text (default), json, or rpc
  --continue, -c          Continue previous session
  --resume, -r            Select a session to resume
  --session <path>        Use specific session file
  --no-session            Don't save session (ephemeral)
  --models <patterns>     Comma-separated model patterns for quick cycling with Ctrl+P
  --help, -h              Show this help

${chalk.bold("Examples:")}
  # Interactive mode (no messages = interactive TUI)
  companion

  # Single message
  companion "List all .ts files in src/"

  # Multiple messages
  companion "Read package.json" "What dependencies do we have?"


grep -n "getSystemPrompt\|systemPrompt.*=" packages/coding-agent/src/main.ts | head -20


89:			result.systemPrompt = args[++i];
681:	const systemPrompt = buildSystemPrompt(parsed.systemPrompt);


grep -B 5 -A 30 "function buildSystemPrompt" packages/coding-agent/src/main.ts


  edit   - Edit files with find/replace
  write  - Write files (creates/overwrites)
`);
}

function buildSystemPrompt(customPrompt?: string): string {
	// Check if customPrompt is a file path that exists
	if (customPrompt && existsSync(customPrompt)) {
		try {
			customPrompt = readFileSync(customPrompt, "utf-8");
		} catch (error) {
			console.error(chalk.yellow(`Warning: Could not read system prompt file ${customPrompt}: ${error}`));
			// Fall through to use as literal string
		}
	}

	if (customPrompt) {
		// Use custom prompt as base, then add context/datetime
		const now = new Date();
		const dateTime = now.toLocaleString("en-US", {
			weekday: "long",
			year: "numeric",
			month: "long",
			day: "numeric",
			hour: "2-digit",
			minute: "2-digit",
			second: "2-digit",
			timeZoneName: "short",
		});

		let prompt = customPrompt;

		// Append project context files
		const contextFiles = loadProjectContextFiles();
		if (contextFiles.length > 0) {
			prompt += "\n\n# Project Context\n\n";


grep -A 100 "function buildSystemPrompt" packages/coding-agent/src/main.ts | head -120


function buildSystemPrompt(customPrompt?: string): string {
	// Check if customPrompt is a file path that exists
	if (customPrompt && existsSync(customPrompt)) {
		try {
			customPrompt = readFileSync(customPrompt, "utf-8");
		} catch (error) {
			console.error(chalk.yellow(`Warning: Could not read system prompt file ${customPrompt}: ${error}`));
			// Fall through to use as literal string
		}
	}

	if (customPrompt) {
		// Use custom prompt as base, then add context/datetime
		const now = new Date();
		const dateTime = now.toLocaleString("en-US", {
			weekday: "long",
			year: "numeric",
			month: "long",
			day: "numeric",
			hour: "2-digit",
			minute: "2-digit",
			second: "2-digit",
			timeZoneName: "short",
		});

		let prompt = customPrompt;

		// Append project context files
		const contextFiles = loadProjectContextFiles();
		if (contextFiles.length > 0) {
			prompt += "\n\n# Project Context\n\n";
			prompt += "The following project context files have been loaded:\n\n";
			for (const { path: filePath, content } of contextFiles) {
				prompt += `## ${filePath}\n\n${content}\n\n`;
			}
		}

		// Add date/time and working directory last
		prompt += `\nCurrent date and time: ${dateTime}`;
		prompt += `\nCurrent working directory: ${process.cwd()}`;

		return prompt;
	}

	const now = new Date();
	const dateTime = now.toLocaleString("en-US", {
		weekday: "long",
		year: "numeric",
		month: "long",
		day: "numeric",
		hour: "2-digit",
		minute: "2-digit",
		second: "2-digit",
		timeZoneName: "short",
	});

	// Get absolute path to README.md
	const readmePath = resolve(join(__dirname, "../README.md"));

	let prompt = `You are actually not Claude, you are companion. You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.

Available tools:
- read: Read file contents
- bash: Execute bash commands (ls, grep, find, etc.)
- edit: Make surgical edits to files (find exact text and replace)
- write: Create or overwrite files

Guidelines:
- Always use bash tool for file operations like ls, grep, find
- Use read to examine files before editing
- Use edit for precise changes (old text must match exactly)
- Use write only for new files or complete rewrites
- Be concise in your responses
- Show file paths clearly when working with files
- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did

Documentation:
- Your own documentation (including custom model setup) is at: ${readmePath}
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider.`;

	// Append project context files
	const contextFiles = loadProjectContextFiles();
	if (contextFiles.length > 0) {
		prompt += "\n\n# Project Context\n\n";
		prompt += "The following project context files have been loaded:\n\n";
		for (const { path: filePath, content } of contextFiles) {
			prompt += `## ${filePath}\n\n${content}\n\n`;
		}
	}

	// Add date/time and working directory last
	prompt += `\nCurrent date and time: ${dateTime}`;
	prompt += `\nCurrent working directory: ${process.cwd()}`;

	return prompt;
}

/**
 * Look for AGENTS.md or CLAUDE.md in a directory (prefers AGENTS.md)
 */
function loadContextFileFromDir(dir: string): { path: string; content: string } | null {


edit

	let prompt = `You are actually not Claude, you are companion. You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.

Available tools:
- read: Read file contents
- bash: Execute bash commands (ls, grep, find, etc.)
- edit: Make surgical edits to files (find exact text and replace)
- write: Create or overwrite files

Guidelines:
- Always use bash tool for file operations like ls, grep, find
- Use read to examine files before editing
- Use edit for precise changes (old text must match exactly)
- Use write only for new files or complete rewrites
- Be concise in your responses
- Show file paths clearly when working with files
- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did

Documentation:
- Your own documentation (including custom model setup) is at: ${readmePath}
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider.`;

	let prompt = `You are actually not Claude, you are companion. You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.

Available tools:
- read: Read file contents
- bash: Execute bash commands (ls, grep, find, etc.)
- edit: Make surgical edits to files (find exact text and replace)
- write: Create or overwrite files

Guidelines:
- Always use bash tool for file operations like ls, grep, find
- Use read to examine files before editing
- Use edit for precise changes (old text must match exactly)
- Use write only for new files or complete rewrites
- Be concise in your responses
- Show file paths clearly when working with files
- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did

Documentation:
- Your own documentation (including custom model setup and theme creation) is at: ${readmePath}
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, or create a custom theme.`;


Successfully replaced text in packages/coding-agent/src/main.ts. Changed 1053 characters to 1098 characters.

     ...
   1 - Show file paths clearly when working with files
   2 - When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did
   3
   4 Documentation:
-239 - Your own documentation (including custom model setup) is at: ${readmePath}
-240 - Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider.`;
+239 - Your own documentation (including custom model setup and theme creation) is at: ${readmePath}
+240 - Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, or create a custom theme.`;
 241
 242 	// Append project context files
 243 	const contextFiles = loadProjectContextFiles();
 244 	if (contextFiles.length > 0) {
     ...


Waiting for the debugger to disconnect...
Waiting for the debugger to disconnect...


Perfect! Updated the system prompt to mention theme creation, so companion will know to read the README when users ask it to create custom themes. The prompt now says:

- "Your own documentation (including custom model setup **and theme creation**) is at: README.md"
- "Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, **or create a custom theme**."

This way companion will read the README and follow the workflow of copying a built-in theme and editing it.


ok, create a new theme for me


I'll help you create a custom theme! Let me first read the README to understand the theme system.


- No restart needed!

If the file contains errors (JSON syntax, schema violations, missing fields), the selector shows the exact validation error and file path in red so you can fix it immediately.

### Example: Adding Ollama Models

See the configuration structure above. Create `~/.companion/agent/models.json` with your Ollama setup, then use `/model` to select your local models. The agent can also help you write this file if you point it to this README.

## Themes

Companion supports customizable color themes for the TUI. Two built-in themes are available: `dark` (default) and `light`.

### Selecting a Theme

Use the `/theme` command to interactively select a theme, or edit your settings file:

```bash
# Interactive selector
companion
/theme

# Or edit ~/.companion/agent/settings.json
{
  "theme": "dark"  # or "light"
}
```

On first run, Companion auto-detects your terminal background (dark/light) and selects an appropriate theme.

### Custom Themes

Create custom themes in `~/.companion/agent/themes/*.json`. Custom themes support **live editing** - when you select a custom theme, companion watches the file and automatically reloads when you save changes.

**Workflow for creating themes:**
1. Copy a built-in theme as a starting point:
   ```bash
   mkdir -p ~/.companion/agent/themes
   # Copy dark theme
   cp $(npm root -g)/@mariozechner/companion-coding-agent/dist/theme/dark.json ~/.companion/agent/themes/my-theme.json
   # Or copy light theme
   cp $(npm root -g)/@mariozechner/companion-coding-agent/dist/theme/light.json ~/.companion/agent/themes/my-theme.json
   ```
2. Use `/theme` to select "my-theme"
3. Edit `~/.companion/agent/themes/my-theme.json` - changes apply immediately on save
4. Iterate until satisfied (no need to re-select the theme)

See [Theme Documentation](docs/theme.md) for:
- Complete list of 43 color tokens
- Theme format and examples
- Color value formats (hex, RGB, terminal default)

Example custom theme:

```json
{
  "$schema": "https://raw.githubusercontent.com/badlogic/companion-mono/main/packages/coding-agent/theme-schema.json",
  "name": "my-theme",
  "vars": {
    "accent": "#00aaff",
    "muted": "#6c6c6c"
  },
  "colors": {
    "accent": "accent",
    "muted": "muted",
    ...
  }
}
```

### VS Code Terminal Color Issue

**Important:** VS Code's integrated terminal has a known issue with rendering truecolor (24-bit RGB) values. By default, it applies a "minimum contrast ratio" adjustment that can make colors look washed out or identical.

To fix this, set the contrast ratio to 1 in VS Code settings:

1. Open Settings (Cmd/Ctrl + ,)
2. Search for: `terminal.integrated.minimumContrastRatio`
3. Set to: `1`

This ensures VS Code renders the exact RGB colors defined in your theme.

... (517 more lines not shown. Use offset=360 to continue reading)


Perfect! Now let me copy the dark theme as a starting point and create a new custom theme for you:

bash

mkdir -p ~/.companion/agent/themes && cp $(npm root -g)/@mariozechner/companion-coding-agent/dist/theme/dark.json ~/.companion/agent/themes/custom.json


Debugger attached.
Waiting for the debugger to disconnect...
cp: /opt/homebrew/lib/node_modules/@mariozechner/companion-coding-agent/dist/theme/dark.json: No such file or directory

Command exited with code 1


Let me use the local version since this is a dev environment:

mkdir -p ~/.companion/agent/themes && cp packages/coding-agent/src/theme/dark.json ~/.companion/agent/themes/custom.json


(no output)


Great! I've created `~/.companion/agent/themes/custom.json` based on the dark theme.

Now you can:
1. Use `/theme` and select "custom" to activate it
2. The theme will live-reload as you edit the file
3. Modify any colors you want - the changes will appear immediately when you save

What kind of theme would you like? Should I customize it with specific colors, or would you prefer to edit it yourself with the live reload feature?


/them


hmmm, the theme selector needs to push the theme update to the next tick, or else we get rendering artifacts


You're right! The theme selector is applying the theme change synchronously while rendering is happening. Let me fix that:

grep -B 5 -A 15 "onSelect.*themeName" packages/coding-agent/src/tui/tui-renderer.ts | head -30


grep -B 5 -A 15 "showThemeSelector" packages/coding-agent/src/tui/tui-renderer.ts | head -40


				return;
			}

			// Check for /theme command
			if (text === "/theme") {
				this.showThemeSelector();
				this.editor.setText("");
				return;
			}

			// Normal message submission - validate model and API key first
			const currentModel = this.agent.state.model;
			if (!currentModel) {
				this.showError(
					"No model selected.\n\n" +
						"Set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)\n" +
						"or create ~/.companion/agent/models.json\n\n" +
						"Then use /model to select a model.",
				);
				return;
			}
--
		this.editorContainer.addChild(this.editor);
		this.queueModeSelector = null;
		this.ui.setFocus(this.editor);
	}

	private showThemeSelector(): void {
		// Get current theme from settings
		const currentTheme = this.settingsManager.getTheme() || "dark";

		// Create theme selector
		this.themeSelector = new ThemeSelectorComponent(
			currentTheme,
			(themeName) => {
				// Apply the selected theme
				setTheme(themeName);

				// Save theme to settings
				this.settingsManager.setTheme(themeName);


grep -A 25 "private showThemeSelector" packages/coding-agent/src/tui/tui-renderer.ts


	private showThemeSelector(): void {
		// Get current theme from settings
		const currentTheme = this.settingsManager.getTheme() || "dark";

		// Create theme selector
		this.themeSelector = new ThemeSelectorComponent(
			currentTheme,
			(themeName) => {
				// Apply the selected theme
				setTheme(themeName);

				// Save theme to settings
				this.settingsManager.setTheme(themeName);

				// Invalidate all components to clear cached rendering
				this.ui.invalidate();

				// Show confirmation message with proper spacing
				this.chatContainer.addChild(new Spacer(1));
				const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
				this.chatContainer.addChild(confirmText);

				// Hide selector and show editor again
				this.hideThemeSelector();
				this.ui.requestRender();
			},


edit

		this.themeSelector = new ThemeSelectorComponent(
			currentTheme,
			(themeName) => {
				// Apply the selected theme
				setTheme(themeName);

				// Save theme to settings
				this.settingsManager.setTheme(themeName);

				// Invalidate all components to clear cached rendering
				this.ui.invalidate();

				// Show confirmation message with proper spacing
				this.chatContainer.addChild(new Spacer(1));
				const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
				this.chatContainer.addChild(confirmText);

				// Hide selector and show editor again
				this.hideThemeSelector();
				this.ui.requestRender();
			},

		this.themeSelector = new ThemeSelectorComponent(
			currentTheme,
			(themeName) => {
				// Hide selector first
				this.hideThemeSelector();

				// Apply theme changes on next tick to avoid rendering artifacts
				setImmediate(() => {
					// Apply the selected theme
					setTheme(themeName);

					// Save theme to settings
					this.settingsManager.setTheme(themeName);

					// Invalidate all components to clear cached rendering
					this.ui.invalidate();

					// Show confirmation message with proper spacing
					this.chatContainer.addChild(new Spacer(1));
					const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
					this.chatContainer.addChild(confirmText);

					this.ui.requestRender();
				});
			},


Successfully replaced text in packages/coding-agent/src/tui/tui-renderer.ts. Changed 644 characters to 742 characters.

      ...
    1 		// Create theme selector
    2 		this.themeSelector = new ThemeSelectorComponent(
    3 			currentTheme,
    4 			(themeName) => {
- 951 				// Apply the selected theme
- 952 				setTheme(themeName);
+ 951 				// Hide selector first
+ 952 				this.hideThemeSelector();
  953
- 954 				// Save theme to settings
- 955 				this.settingsManager.setTheme(themeName);
+ 954 				// Apply theme changes on next tick to avoid rendering artifacts
+ 955 				setImmediate(() => {
+ 956 					// Apply the selected theme
+ 957 					setTheme(themeName);
  956
- 957 				// Invalidate all components to clear cached rendering
- 958 				this.ui.invalidate();
+ 959 					// Save theme to settings
+ 960 					this.settingsManager.setTheme(themeName);
  959
- 960 				// Show confirmation message with proper spacing
- 961 				this.chatContainer.addChild(new Spacer(1));
- 962 				const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
- 963 				this.chatContainer.addChild(confirmText);
+ 962 					// Invalidate all components to clear cached rendering
+ 963 					this.ui.invalidate();
  964
- 965 				// Hide selector and show editor again
- 966 				this.hideThemeSelector();
- 967 				this.ui.requestRender();
+ 965 					// Show confirmation message with proper spacing
+ 966 					this.chatContainer.addChild(new Spacer(1));
+ 967 					const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
+ 968 					this.chatContainer.addChild(confirmText);
+ 969
+ 970 					this.ui.requestRender();
+ 971 				});
  968 			},
  969 			() => {
  970 				// Just hide the selector
  971 				this.hideThemeSelector();
      ...


Now do the same for the preview callback:

bash

grep -A 10 "onPreview.*themeName" packages/coding-agent/src/tui/tui-renderer.ts


Command exited with code 1


grep -B 5 -A 10 "Preview theme" packages/coding-agent/src/tui/tui-renderer.ts


				// Just hide the selector
				this.hideThemeSelector();
				this.ui.requestRender();
			},
			(themeName) => {
				// Preview theme on selection change
				setTheme(themeName);
				this.ui.invalidate();
				this.ui.requestRender();
			},
		);

		// Replace editor with selector
		this.editorContainer.clear();
		this.editorContainer.addChild(this.themeSelector);
		this.ui.setFocus(this.themeSelector.getSelectList());


edit

			(themeName) => {
				// Preview theme on selection change
				setTheme(themeName);
				this.ui.invalidate();
				this.ui.requestRender();
			},

			(themeName) => {
				// Preview theme on selection change (next tick to avoid artifacts)
				setImmediate(() => {
					setTheme(themeName);
					this.ui.invalidate();
					this.ui.requestRender();
				});
			},


Successfully replaced text in packages/coding-agent/src/tui/tui-renderer.ts. Changed 146 characters to 213 characters.

      ...
    1 				this.hideThemeSelector();
    2 				this.ui.requestRender();
    3 			},
    4 			(themeName) => {
- 979 				// Preview theme on selection change
- 980 				setTheme(themeName);
- 981 				this.ui.invalidate();
- 982 				this.ui.requestRender();
+ 979 				// Preview theme on selection change (next tick to avoid artifacts)
+ 980 				setImmediate(() => {
+ 981 					setTheme(themeName);
+ 982 					this.ui.invalidate();
+ 983 					this.ui.requestRender();
+ 984 				});
  983 			},
  984 		);
  985
  986 		// Replace editor with selector
      ...


Perfect! Now both the theme selection and preview callbacks use `setImmediate()` to defer the theme application to the next tick. This ensures:

1. The current render cycle completes cleanly
2. The selector is hidden before theme changes
3. Theme invalidation and re-rendering happen in a fresh tick

This should eliminate the rendering artifacts you were seeing when switching themes!


yeah you can undo that, it was actually the console.logs/errors in theme.ts that did that fuck up


		this.themeSelector = new ThemeSelectorComponent(
			currentTheme,
			(themeName) => {
				// Hide selector first
				this.hideThemeSelector();

				// Apply theme changes on next tick to avoid rendering artifacts
				setImmediate(() => {
					// Apply the selected theme
					setTheme(themeName);

					// Save theme to settings
					this.settingsManager.setTheme(themeName);

					// Invalidate all components to clear cached rendering
					this.ui.invalidate();

					// Show confirmation message with proper spacing
					this.chatContainer.addChild(new Spacer(1));
					const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
					this.chatContainer.addChild(confirmText);

					this.ui.requestRender();
				});
			},

		this.themeSelector = new ThemeSelectorComponent(
			currentTheme,
			(themeName) => {
				// Apply the selected theme
				setTheme(themeName);

				// Save theme to settings
				this.settingsManager.setTheme(themeName);

				// Invalidate all components to clear cached rendering
				this.ui.invalidate();

				// Show confirmation message with proper spacing
				this.chatContainer.addChild(new Spacer(1));
				const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
				this.chatContainer.addChild(confirmText);

				// Hide selector and show editor again
				this.hideThemeSelector();
				this.ui.requestRender();
			},


Successfully replaced text in packages/coding-agent/src/tui/tui-renderer.ts. Changed 742 characters to 644 characters.

      ...
    1 		// Create theme selector
    2 		this.themeSelector = new ThemeSelectorComponent(
    3 			currentTheme,
    4 			(themeName) => {
- 951 				// Hide selector first
- 952 				this.hideThemeSelector();
+ 951 				// Apply the selected theme
+ 952 				setTheme(themeName);
  953
- 954 				// Apply theme changes on next tick to avoid rendering artifacts
- 955 				setImmediate(() => {
- 956 					// Apply the selected theme
- 957 					setTheme(themeName);
+ 954 				// Save theme to settings
+ 955 				this.settingsManager.setTheme(themeName);
  958
- 959 					// Save theme to settings
- 960 					this.settingsManager.setTheme(themeName);
+ 957 				// Invalidate all components to clear cached rendering
+ 958 				this.ui.invalidate();
  961
- 962 					// Invalidate all components to clear cached rendering
- 963 					this.ui.invalidate();
+ 960 				// Show confirmation message with proper spacing
+ 961 				this.chatContainer.addChild(new Spacer(1));
+ 962 				const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
+ 963 				this.chatContainer.addChild(confirmText);
  964
- 965 					// Show confirmation message with proper spacing
- 966 					this.chatContainer.addChild(new Spacer(1));
- 967 					const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
- 968 					this.chatContainer.addChild(confirmText);
- 969
- 970 					this.ui.requestRender();
- 971 				});
+ 965 				// Hide selector and show editor again
+ 966 				this.hideThemeSelector();
+ 967 				this.ui.requestRender();
  972 			},
  973 			() => {
  974 				// Just hide the selector
  975 				this.hideThemeSelector();
      ...


			(themeName) => {
				// Preview theme on selection change (next tick to avoid artifacts)
				setImmediate(() => {
					setTheme(themeName);
					this.ui.invalidate();
					this.ui.requestRender();
				});
			},

			(themeName) => {
				// Preview theme on selection change
				setTheme(themeName);
				this.ui.invalidate();
				this.ui.requestRender();
			},


Successfully replaced text in packages/coding-agent/src/tui/tui-renderer.ts. Changed 213 characters to 146 characters.

      ...
    1 				this.hideThemeSelector();
    2 				this.ui.requestRender();
    3 			},
    4 			(themeName) => {
- 975 				// Preview theme on selection change (next tick to avoid artifacts)
- 976 				setImmediate(() => {
- 977 					setTheme(themeName);
- 978 					this.ui.invalidate();
- 979 					this.ui.requestRender();
- 980 				});
+ 975 				// Preview theme on selection change
+ 976 				setTheme(themeName);
+ 977 				this.ui.invalidate();
+ 978 				this.ui.requestRender();
  981 			},
  982 		);
  983
  984 		// Replace editor with selector
      ...


Now let me remove/comment out those console.error calls in theme.ts:

bash

grep -n "console.error" packages/coding-agent/src/theme/theme.ts


grep -n "console\." packages/coding-agent/src/theme/theme.ts


grep -n "console" packages/coding-agent/src/theme/theme.ts


Hmm, looks like the console logs were already removed or commented out. Let me check what's in the theme detection functions:

grep -A 5 "Detected truecolor\|Detected.*background\|No COLORFGBG" packages/coding-agent/src/theme/theme.ts


i removed them all good


Got it! The console logs were causing the rendering artifacts and you've already removed them. The code is now clean without the `setImmediate()` workarounds.

cd packages/coding-agent && npm run build 2>&1 | tail -3


Waiting for the debugger to disconnect...
Waiting for the debugger to disconnect...


Perfect! Everything should be working smoothly now. Ready for the new release whenever you are!


create a custom theme for me then open it with the code command


(no output)


Successfully replaced text in ~/.companion/agent/themes/nord.json. Changed 344 characters to 454 characters.


edit

	"colors": {
		"accent": "accent",
		"border": "blue",
		"borderAccent": "cyan",
		"borderMuted": "darkGray",
		"success": "green",
		"error": "red",
		"warning": "yellow",
		"muted": "gray",
		"dim": "dimGray",
		"text": "",

	"colors": {
		"accent": "nord8",
		"border": "nord10",
		"borderAccent": "nord7",
		"borderMuted": "nord3",
		"success": "nord14",
		"error": "nord11",
		"warning": "nord13",
		"muted": "nord4",
		"dim": "nord3",
		"text": "",


Successfully replaced text in ~/.companion/agent/themes/nord.json. Changed 225 characters to 227 characters.

    ...
  1 		"toolSuccessBg": "#2e3b33",
  2 		"toolErrorBg": "#3b2e30"
  3 	},
  4 	"colors": {
-25 		"accent": "accent",
-26 		"border": "blue",
-27 		"borderAccent": "cyan",
-28 		"borderMuted": "darkGray",
-29 		"success": "green",
-30 		"error": "red",
-31 		"warning": "yellow",
-32 		"muted": "gray",
-33 		"dim": "dimGray",
+25 		"accent": "nord8",
+26 		"border": "nord10",
+27 		"borderAccent": "nord7",
+28 		"borderMuted": "nord3",
+29 		"success": "nord14",
+30 		"error": "nord11",
+31 		"warning": "nord13",
+32 		"muted": "nord4",
+33 		"dim": "nord3",
 34 		"text": "",
 35
 36 		"userMessageBg": "userMsgBg",
 37 		"userMessageText": "",
    ...


hm, i selected the theme while you were working and got a crash, that should never happen.

 ⠇ Working... (esc to interrupt)

─────────────────────────────────────────────────────────────────────────────────────
  dark                            (current)
→ light
  nord
─────────────────────────────────────────────────────────────────────────────────────
~/workspaces/companion-mono
↑880 ↓68k R29394k W3499k 22.966 73.5%                               claude-sonnet-4-5Waiting for the debugger to disconnect...
/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/theme/theme.ts:213
                throw new Error(`Variable reference not found: ${value}`);
                ^

Error: Variable reference not found: dimGray
    at resolveVarRefs (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/theme/theme.ts:213:9)
    at resolveThemeColors (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/theme/theme.ts:225:19)
    at createTheme (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/theme/theme.ts:377:25)
    at loadTheme (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/theme/theme.ts:393:9)
    at setTheme (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/theme/theme.ts:433:10)
    at ThemeSelectorComponent.onPreview (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/tui/tui-renderer.ts:976:5)
    at SelectList.ThemeSelectorComponent.selectList.onSelectionChange (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/tui/theme-selector.ts:50:9)
    at SelectList.notifySelectionChange (/Users/badlogic/workspaces/companion-mono/packages/tui/src/components/select-list.ts:180:9)
    at SelectList.handleInput (/Users/badlogic/workspaces/companion-mono/packages/tui/src/components/select-list.ts:160:9)
    at TUI.handleInput (/Users/badlogic/workspaces/companion-mono/packages/tui/src/tui.ts:116:26)

Node.js v23.4.0
Waiting for the debugger to disconnect...
Waiting for the debugger to disconnect...


Ah! You navigated to the `nord` theme while I was still editing it, and it had an incomplete vars section - `dimGray` was referenced but I had removed it from the vars. The theme loader should catch this error gracefully instead of crashing. Let me fix this:

			(themeName) => {
				// Preview theme on selection change
				try {
					setTheme(themeName);
					this.ui.invalidate();
					this.ui.requestRender();
				} catch (error) {
					// Ignore errors during preview (theme might be invalid while being edited)
					// Just keep the current theme
				}
			},


Successfully replaced text in packages/coding-agent/src/tui/tui-renderer.ts. Changed 146 characters to 304 characters.

      ...
    1 				this.ui.requestRender();
    2 			},
    3 			(themeName) => {
    4 				// Preview theme on selection change
- 976 				setTheme(themeName);
- 977 				this.ui.invalidate();
- 978 				this.ui.requestRender();
+ 976 				try {
+ 977 					setTheme(themeName);
+ 978 					this.ui.invalidate();
+ 979 					this.ui.requestRender();
+ 980 				} catch (error) {
+ 981 					// Ignore errors during preview (theme might be invalid while being edited)
+ 982 					// Just keep the current theme
+ 983 				}
  979 			},
  980 		);
  981
  982 		// Replace editor with selector
      ...


Also wrap the onSelect callback:

		this.themeSelector = new ThemeSelectorComponent(
			currentTheme,
			(themeName) => {
				try {
					// Apply the selected theme
					setTheme(themeName);

					// Save theme to settings
					this.settingsManager.setTheme(themeName);

					// Invalidate all components to clear cached rendering
					this.ui.invalidate();

					// Show confirmation message with proper spacing
					this.chatContainer.addChild(new Spacer(1));
					const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
					this.chatContainer.addChild(confirmText);

					// Hide selector and show editor again
					this.hideThemeSelector();
					this.ui.requestRender();
				} catch (error) {
					// Theme is invalid - show error and keep selector open
					this.chatContainer.addChild(new Spacer(1));
					const errorText = new Text(
						theme.fg("error", `Failed to load theme: ${error instanceof Error ? error.message : String(error)}`),
						1,
						0,
					);
					this.chatContainer.addChild(errorText);
					this.hideThemeSelector();
					this.ui.requestRender();
				}
			},


Successfully replaced text in packages/coding-agent/src/tui/tui-renderer.ts. Changed 644 characters to 1078 characters.

      ...
    1 		// Create theme selector
    2 		this.themeSelector = new ThemeSelectorComponent(
    3 			currentTheme,
    4 			(themeName) => {
- 951 				// Apply the selected theme
- 952 				setTheme(themeName);
+ 951 				try {
+ 952 					// Apply the selected theme
+ 953 					setTheme(themeName);
  953
- 954 				// Save theme to settings
- 955 				this.settingsManager.setTheme(themeName);
+ 955 					// Save theme to settings
+ 956 					this.settingsManager.setTheme(themeName);
  956
- 957 				// Invalidate all components to clear cached rendering
- 958 				this.ui.invalidate();
+ 958 					// Invalidate all components to clear cached rendering
+ 959 					this.ui.invalidate();
  959
- 960 				// Show confirmation message with proper spacing
- 961 				this.chatContainer.addChild(new Spacer(1));
- 962 				const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
- 963 				this.chatContainer.addChild(confirmText);
+ 961 					// Show confirmation message with proper spacing
+ 962 					this.chatContainer.addChild(new Spacer(1));
+ 963 					const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
+ 964 					this.chatContainer.addChild(confirmText);
  964
- 965 				// Hide selector and show editor again
- 966 				this.hideThemeSelector();
- 967 				this.ui.requestRender();
+ 966 					// Hide selector and show editor again
+ 967 					this.hideThemeSelector();
+ 968 					this.ui.requestRender();
+ 969 				} catch (error) {
+ 970 					// Theme is invalid - show error and keep selector open
+ 971 					this.chatContainer.addChild(new Spacer(1));
+ 972 					const errorText = new Text(
+ 973 						theme.fg("error", `Failed to load theme: ${error instanceof Error ? error.message : String(error)}`),
+ 974 						1,
+ 975 						0,
+ 976 					);
+ 977 					this.chatContainer.addChild(errorText);
+ 978 					this.hideThemeSelector();
+ 979 					this.ui.requestRender();
+ 980 				}
  968 			},
  969 			() => {
  970 				// Just hide the selector
  971 				this.hideThemeSelector();
      ...


And also wrap the file watcher callback:

bash

grep -B 5 -A 10 "Reload the theme" packages/coding-agent/src/theme/theme.ts


		themeWatcher = fs.watch(themeFile, (eventType) => {
			if (eventType === "change") {
				// Debounce rapid changes
				setTimeout(() => {
					try {
						// Reload the theme
						theme = loadTheme(currentThemeName!);
						// Notify callback (to invalidate UI)
						if (onThemeChangeCallback) {
							onThemeChangeCallback();
						}
					} catch (error) {
						// Ignore errors (file might be in invalid state while being edited)
					}
				}, 100);
			} else if (eventType === "rename") {


Good, that one already has a try-catch. Now let me finish the nord theme properly:

edit

		"mdHeading": "#f0c674",
		"mdLink": "#81a2be",
		"mdLinkUrl": "dimGray",
		"mdCode": "accent",
		"mdCodeBlock": "green",
		"mdCodeBlockBorder": "gray",
		"mdQuote": "gray",
		"mdQuoteBorder": "gray",
		"mdHr": "gray",
		"mdListBullet": "accent",

		"toolDiffAdded": "green",
		"toolDiffRemoved": "red",
		"toolDiffContext": "gray",

		"syntaxComment": "gray",
		"syntaxKeyword": "cyan",
		"syntaxFunction": "blue",
		"syntaxVariable": "",
		"syntaxString": "green",
		"syntaxNumber": "yellow",
		"syntaxType": "cyan",
		"syntaxOperator": "",
		"syntaxPunctuation": "gray",

		"thinkingOff": "darkGray",
		"thinkingMinimal": "#

{"path": "~/.companion/agent/themes/nord.json", "oldText": "\t\t\"mdHeading\": \"#f0c674\",\n\t\t\"mdLink\": \"#81a2be\",\n\t\t\"mdLinkUrl\": \"dimGray\",\n\t\t\"mdCode\": \"accent\",\n\t\t\"mdCodeBlock\": \"green\",\n\t\t\"mdCodeBlockBorder\": \"gray\",\n\t\t\"mdQuote\": \"gray\",\n\t\t\"mdQuoteBorder\": \"gray\",\n\t\t\"mdHr\": \"gray\",\n\t\t\"mdListBullet\": \"accent\",\n\n\t\t\"toolDiffAdded\": \"green\",\n\t\t\"toolDiffRemoved\": \"red\",\n\t\t\"toolDiffContext\": \"gray\",\n\n\t\t\"syntaxComment\": \"gray\",\n\t\t\"syntaxKeyword\": \"cyan\",\n\t\t\"syntaxFunction\": \"blue\",\n\t\t\"syntaxVariable\": \"\",\n\t\t\"syntaxString\": \"green\",\n\t\t\"syntaxNumber\": \"yellow\",\n\t\t\"syntaxType\": \"cyan\",\n\t\t\"syntaxOperator\": \"\",\n\t\t\"syntaxPunctuation\": \"gray\",\n\n\t\t\"thinkingOff\": \"darkGray\",\n\t\t\"thinkingMinimal\": \"#


remove the nord.json


Done! The `nord.json` theme has been removed and the error handling is now in place so invalid themes during preview/selection won't crash the app.


ok, create a new custom theme and open it with code command. i'll edit it myself


Done! I've created `~/.companion/agent/themes/custom.json` (copied from the dark theme) and opened it in VS Code.

Now you can:
1. Use `/theme` to select "custom"
2. Edit the file in VS Code - changes will apply immediately when you save
3. Tweak colors to your liking with live preview!


my dude, this exploded when i restarted with -r. invalid themes must NEVER explode

➜  companion-mono git:(main) ✗ npx tsx packages/coding-agent/src/cli.ts -r
Debugger attached.
Debugger attached.
Debugger attached.
Error: Invalid hex color: #000cc
    at hexToRgb (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/theme/theme.ts:148:9)
    at bgAnsi (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/theme/theme.ts:191:24)
    at new Theme (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/theme/theme.ts:251:27)
    at createTheme (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/theme/theme.ts:388:9)
    at loadTheme (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/theme/theme.ts:393:9)
    at initTheme (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/theme/theme.ts:427:10)
    at main (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/main.ts:570:2)
    at <anonymous> (/Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/cli.ts:20:1)
    at ModuleJob.run (node:internal/modules/esm/module_job:271:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:547:26)
Waiting for the debugger to disconnect...
Waiting for the debugger to disconnect...
Waiting for the debugger to disconnect...


You're absolutely right - invalid themes should never crash the app, especially on startup! Let me wrap the theme initialization with proper error handling:

export function initTheme(themeName?: string): void {
	const name = themeName ?? getDefaultTheme();
	currentThemeName = name;
	theme = loadTheme(name);
	startThemeWatcher();
}

export function initTheme(themeName?: string): void {
	const name = themeName ?? getDefaultTheme();
	currentThemeName = name;
	try {
		theme = loadTheme(name);
		startThemeWatcher();
	} catch (error) {
		// Theme is invalid - fall back to dark theme
		console.error(`Failed to load theme "${name}": ${error}`);
		console.error("Falling back to dark theme");
		currentThemeName = "dark";
		theme = loadTheme("dark");
		// Don't start watcher for fallback theme
	}
}


Successfully replaced text in packages/coding-agent/src/theme/theme.ts. Changed 175 characters to 464 characters.

     ...
   1
   2 export function initTheme(themeName?: string): void {
   3 	const name = themeName ?? getDefaultTheme();
   4 	currentThemeName = name;
-427 	theme = loadTheme(name);
-428 	startThemeWatcher();
+427 	try {
+428 		theme = loadTheme(name);
+429 		startThemeWatcher();
+430 	} catch (error) {
+431 		// Theme is invalid - fall back to dark theme
+432 		console.error(`Failed to load theme "${name}": ${error}`);
+433 		console.error("Falling back to dark theme");
+434 		currentThemeName = "dark";
+435 		theme = loadTheme("dark");
+436 		// Don't start watcher for fallback theme
+437 	}
 429 }
 430
 431 export function setTheme(name: string): void {
 432 	currentThemeName = name;
     ...


export function setTheme(name: string): void {
	currentThemeName = name;
	theme = loadTheme(name);
	startThemeWatcher();
}

export function setTheme(name: string): void {
	currentThemeName = name;
	try {
		theme = loadTheme(name);
		startThemeWatcher();
	} catch (error) {
		// Theme is invalid - fall back to dark theme
		console.error(`Failed to load theme "${name}": ${error}`);
		console.error("Falling back to dark theme");
		currentThemeName = "dark";
		theme = loadTheme("dark");
		// Don't start watcher for fallback theme
		throw error; // Re-throw so caller can handle it
	}
}


Successfully replaced text in packages/coding-agent/src/theme/theme.ts. Changed 122 characters to 462 characters.

     ...
   1 }
   2
   3 export function setTheme(name: string): void {
   4 	currentThemeName = name;
-442 	theme = loadTheme(name);
-443 	startThemeWatcher();
+442 	try {
+443 		theme = loadTheme(name);
+444 		startThemeWatcher();
+445 	} catch (error) {
+446 		// Theme is invalid - fall back to dark theme
+447 		console.error(`Failed to load theme "${name}": ${error}`);
+448 		console.error("Falling back to dark theme");
+449 		currentThemeName = "dark";
+450 		theme = loadTheme("dark");
+451 		// Don't start watcher for fallback theme
+452 		throw error; // Re-throw so caller can handle it
+453 	}
 444 }
 445
 446 export function onThemeChange(callback: () => void): void {
 447 	onThemeChangeCallback = callback;
     ...


Waiting for the debugger to disconnect...
Waiting for the debugger to disconnect...


Now invalid themes will:
1. **On startup (`initTheme`)**: Log error, fall back to dark theme, continue running
2. **During theme switch (`


no conosle logs! setTheme should return an error that the caller can display however they want


You're right! Let me fix this properly:

export function initTheme(themeName?: string): void {
	const name = themeName ?? getDefaultTheme();
	currentThemeName = name;
	try {
		theme = loadTheme(name);
		startThemeWatcher();
	} catch (error) {
		// Theme is invalid - fall back to dark theme
		console.error(`Failed to load theme "${name}": ${error}`);
		console.error("Falling back to dark theme");
		currentThemeName = "dark";
		theme = loadTheme("dark");
		// Don't start watcher for fallback theme
	}
}

export function initTheme(themeName?: string): void {
	const name = themeName ?? getDefaultTheme();
	currentThemeName = name;
	try {
		theme = loadTheme(name);
		startThemeWatcher();
	} catch (error) {
		// Theme is invalid - fall back to dark theme silently
		currentThemeName = "dark";
		theme = loadTheme("dark");
		// Don't start watcher for fallback theme
	}
}


Successfully replaced text in packages/coding-agent/src/theme/theme.ts. Changed 464 characters to 365 characters.

     ...
   1 	try {
   2 		theme = loadTheme(name);
   3 		startThemeWatcher();
   4 	} catch (error) {
-431 		// Theme is invalid - fall back to dark theme
-432 		console.error(`Failed to load theme "${name}": ${error}`);
-433 		console.error("Falling back to dark theme");
+431 		// Theme is invalid - fall back to dark theme silently
 434 		currentThemeName = "dark";
 435 		theme = loadTheme("dark");
 436 		// Don't start watcher for fallback theme
 437 	}
     ...


export function setTheme(name: string): void {
	currentThemeName = name;
	try {
		theme = loadTheme(name);
		startThemeWatcher();
	} catch (error) {
		// Theme is invalid - fall back to dark theme
		console.error(`Failed to load theme "${name}": ${error}`);
		console.error("Falling back to dark theme");
		currentThemeName = "dark";
		theme = loadTheme("dark");
		// Don't start watcher for fallback theme
		throw error; // Re-throw so caller can handle it
	}
}

export function setTheme(name: string): { success: boolean; error?: string } {
	currentThemeName = name;
	try {
		theme = loadTheme(name);
		startThemeWatcher();
		return { success: true };
	} catch (error) {
		// Theme is invalid - fall back to dark theme
		currentThemeName = "dark";
		theme = loadTheme("dark");
		// Don't start watcher for fallback theme
		return {
			success: false,
			error: error instanceof Error ? error.message : String(error)
		};
	}
}


Successfully replaced text in packages/coding-agent/src/theme/theme.ts. Changed 462 characters to 466 characters.

     ...
   1 		// Don't start watcher for fallback theme
   2 	}
   3 }
   4
-438 export function setTheme(name: string): void {
+438 export function setTheme(name: string): { success: boolean; error?: string } {
 439 	currentThemeName = name;
 440 	try {
 441 		theme = loadTheme(name);
 442 		startThemeWatcher();
+443 		return { success: true };
 443 	} catch (error) {
 444 		// Theme is invalid - fall back to dark theme
-445 		console.error(`Failed to load theme "${name}": ${error}`);
-446 		console.error("Falling back to dark theme");
 447 		currentThemeName = "dark";
 448 		theme = loadTheme("dark");
 449 		// Don't start watcher for fallback theme
-450 		throw error; // Re-throw so caller can handle it
+449 		return {
+450 			success: false,
+451 			error: error instanceof Error ? error.message : String(error)
+452 		};
 451 	}
 452 }
 453
 454 export function onThemeChange(callback: () => void): void {
     ...


		this.themeSelector = new ThemeSelectorComponent(
			currentTheme,
			(themeName) => {
				try {
					// Apply the selected theme
					setTheme(themeName);

					// Save theme to settings
					this.settingsManager.setTheme(themeName);

					// Invalidate all components to clear cached rendering
					this.ui.invalidate();

					// Show confirmation message with proper spacing
					this.chatContainer.addChild(new Spacer(1));
					const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
					this.chatContainer.addChild(confirmText);

					// Hide selector and show editor again
					this.hideThemeSelector();
					this.ui.requestRender();
				} catch (error) {
					// Theme is invalid - show error and keep selector open
					this.chatContainer.addChild(new Spacer(1));
					const errorText = new Text(
						theme.fg("error", `Failed to load theme: ${error instanceof Error ? error.message : String(error)}`),
						1,
						0,
					);
					this.chatContainer.addChild(errorText);
					this.hideThemeSelector();
					this.ui.requestRender();
				}
			},

		this.themeSelector = new ThemeSelectorComponent(
			currentTheme,
			(themeName) => {
				// Apply the selected theme
				const result = setTheme(themeName);

				// Save theme to settings
				this.settingsManager.setTheme(themeName);

				// Invalidate all components to clear cached rendering
				this.ui.invalidate();

				// Show confirmation or error message
				this.chatContainer.addChild(new Spacer(1));
				if (result.success) {
					const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
					this.chatContainer.addChild(confirmText);
				} else {
					const errorText = new Text(
						theme.fg("error", `Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`),
						1,
						0,
					);
					this.chatContainer.addChild(errorText);
				}

				// Hide selector and show editor again
				this.hideThemeSelector();
				this.ui.requestRender();
			},


Successfully replaced text in packages/coding-agent/src/tui/tui-renderer.ts. Changed 1078 characters to 906 characters.

      ...
    1 		// Create theme selector
    2 		this.themeSelector = new ThemeSelectorComponent(
    3 			currentTheme,
    4 			(themeName) => {
- 951 				try {
- 952 					// Apply the selected theme
- 953 					setTheme(themeName);
+ 951 				// Apply the selected theme
+ 952 				const result = setTheme(themeName);
  954
- 955 					// Save theme to settings
- 956 					this.settingsManager.setTheme(themeName);
+ 954 				// Save theme to settings
+ 955 				this.settingsManager.setTheme(themeName);
  957
- 958 					// Invalidate all components to clear cached rendering
- 959 					this.ui.invalidate();
+ 957 				// Invalidate all components to clear cached rendering
+ 958 				this.ui.invalidate();
  960
- 961 					// Show confirmation message with proper spacing
- 962 					this.chatContainer.addChild(new Spacer(1));
+ 960 				// Show confirmation or error message
+ 961 				this.chatContainer.addChild(new Spacer(1));
+ 962 				if (result.success) {
  963 					const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
  964 					this.chatContainer.addChild(confirmText);
- 965
- 966 					// Hide selector and show editor again
- 967 					this.hideThemeSelector();
- 968 					this.ui.requestRender();
- 969 				} catch (error) {
- 970 					// Theme is invalid - show error and keep selector open
- 971 					this.chatContainer.addChild(new Spacer(1));
+ 965 				} else {
  972 					const errorText = new Text(
- 973 						theme.fg("error", `Failed to load theme: ${error instanceof Error ? error.message : String(error)}`),
+ 967 						theme.fg("error", `Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`),
  974 						1,
  975 						0,
  976 					);
  977 					this.chatContainer.addChild(errorText);
- 978 					this.hideThemeSelector();
- 979 					this.ui.requestRender();
  980 				}
+ 973
+ 974 				// Hide selector and show editor again
+ 975 				this.hideThemeSelector();
+ 976 				this.ui.requestRender();
  981 			},
  982 			() => {
  983 				// Just hide the selector
  984 				this.hideThemeSelector();
      ...


			(themeName) => {
				// Preview theme on selection change
				try {
					setTheme(themeName);
					this.ui.invalidate();
					this.ui.requestRender();
				} catch (error) {
					// Ignore errors during preview (theme might be invalid while being edited)
					// Just keep the current theme
				}
			},

			(themeName) => {
				// Preview theme on selection change
				const result = setTheme(themeName);
				if (result.success) {
					this.ui.invalidate();
					this.ui.requestRender();
				}
				// If failed, theme already fell back to dark, just don't re-render
			},


Successfully replaced text in packages/coding-agent/src/tui/tui-renderer.ts. Changed 304 characters to 267 characters.

      ...
    1 				this.ui.requestRender();
    2 			},
    3 			(themeName) => {
    4 				// Preview theme on selection change
- 985 				try {
- 986 					setTheme(themeName);
+ 985 				const result = setTheme(themeName);
+ 986 				if (result.success) {
  987 					this.ui.invalidate();
  988 					this.ui.requestRender();
- 989 				} catch (error) {
- 990 					// Ignore errors during preview (theme might be invalid while being edited)
- 991 					// Just keep the current theme
  992 				}
+ 990 				// If failed, theme already fell back to dark, just don't re-render
  993 			},
  994 		);
  995
  996 		// Replace editor with selector
      ...


Perfect! Now:

1. **`initTheme()`** - Silently falls back to dark theme if the saved theme is invalid (on startup)
2. **`setTheme()`** - Returns `{ success: boolean; error?: string }` so the caller can decide how to display errors
3. **Theme selector** - Shows a proper error message in the chat if theme loading fails
4. **Preview** - Silently ignores errors (theme might be mid-edit)
5. **No console logs** - All error handling is done through return values

Invalid themes will never crash the app again!


/the


delete the custom theme


(no output)


Done! The `custom.json` theme has been deleted.


create a custom light theme i'll edit it in code. open it for me in code once created


Done! I've created `~/.companion/agent/themes/custom-light.json` (copied from the light theme) and opened it in VS Code. Use `/theme` to select "custom-light" and edit away!


userMessageText is not used it seems?


Let me check where userMessageText is used:

bash

grep -r "userMessageText" packages/coding-agent/src --include="*.ts"


packages/coding-agent/src/theme/theme.ts:		userMessageText: ColorValueSchema,
packages/coding-agent/src/theme/theme.ts:	| "userMessageText"


import { Container, Markdown, Spacer } from "@mariozechner/companion-tui";
import { getMarkdownTheme, theme } from "../theme/theme.js";

/**
 * Component that renders a user message
 */
export class UserMessageComponent extends Container {
	constructor(text: string, isFirst: boolean) {
		super();

		// Add spacer before user message (except first one)
		if (!isFirst) {
			this.addChild(new Spacer(1));
		}
		this.addChild(
			new Markdown(text, 1, 1, getMarkdownTheme(), {
				bgColor: (text: string) => theme.bg("userMessageBg", text),
			}),
		);
	}
}


You're right! `userMessageText` is defined in the theme but never used. The user message only sets the background color, not the text color. If we want to use it, we should add it to the Markdown defaultTextStyle:

edit

		this.addChild(
			new Markdown(text, 1, 1, getMarkdownTheme(), {
				bgColor: (text: string) => theme.bg("userMessageBg", text),
			}),
		);

		this.addChild(
			new Markdown(text, 1, 1, getMarkdownTheme(), {
				bgColor: (text: string) => theme.bg("userMessageBg", text),
				color: (text: string) => theme.fg("userMessageText", text),
			}),
		);


Successfully replaced text in packages/coding-agent/src/tui/user-message.ts. Changed 142 characters to 206 characters.

    ...
  1 		}
  2 		this.addChild(
  3 			new Markdown(text, 1, 1, getMarkdownTheme(), {
  4 				bgColor: (text: string) => theme.bg("userMessageBg", text),
+18 				color: (text: string) => theme.fg("userMessageText", text),
 18 			}),
 19 		);
 20 	}
 21 }


Fixed! Now `userMessageText` is actually used - it sets the default text color for user messages. Since both themes have it set to `""` (terminal default), you won't see a difference unless you customize it to a specific color.


make sure toolText is also used. tool execution uses to text color types, one for the heading and one for the tool result (in case of bash and read, and write, edit does its own thing i think. investigate


Let me investigate how tool execution uses text colors:


import * as os from "node:os";
import { Container, Spacer, Text } from "@mariozechner/companion-tui";
import stripAnsi from "strip-ansi";
import { theme } from "../theme/theme.js";

/**
 * Convert absolute path to tilde notation if it's in home directory
 */
function shortenPath(path: string): string {
	const home = os.homedir();
	if (path.startsWith(home)) {
		return "~" + path.slice(home.length);
	}
	return path;
}

/**
 * Replace tabs with spaces for consistent rendering
 */
function replaceTabs(text: string): string {
	return text.replace(/\t/g, "   ");
}

/**
 * Component that renders a tool call with its result (updateable)
 */
export class ToolExecutionComponent extends Container {
	private contentText: Text;
	private toolName: string;
	private args: any;
	private expanded = false;
	private result?: {
		content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
		isError: boolean;
		details?: any;
	};

	constructor(toolName: string, args: any) {
		super();
		this.toolName = toolName;
		this.args = args;
		this.addChild(new Spacer(1));
		// Content with colored background and padding
		this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
		this.addChild(this.contentText);
		this.updateDisplay();
	}

	updateArgs(args: any): void {
		this.args = args;
		this.updateDisplay();
	}

	updateResult(result: {
		content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
		details?: any;
		isError: boolean;
	}): void {
		this.result = result;
		this.updateDisplay();
	}

	setExpanded(expanded: boolean): void {
		this.expanded = expanded;
		this.updateDisplay();
	}

	private updateDisplay(): void {
		const bgFn = this.result
			? this.result.isError
				? (text: string) => theme.bg("toolErrorBg", text)
				: (text: string) => theme.bg("toolSuccessBg", text)
			: (text: string) => theme.bg("toolPendingBg", text);

		this.contentText.setCustomBgFn(bgFn);
		this.contentText.setText(this.formatToolExecution());
	}

	private getTextOutput(): string {
		if (!this.result) return "";

		// Extract text from content blocks
		const textBlocks = this.result.content?.filter((c: any) => c.type === "text") || [];
		const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];

		// Strip ANSI codes from raw output (bash may emit colors/formatting)
		let output = textBlocks.map((c: any) => stripAnsi(c.text || "")).join("\n");

		// Add indicator for images
		if (imageBlocks.length > 0) {
			const imageIndicators = imageBlocks.map((img: any) => `[Image: ${img.mimeType}]`).join("\n");
			output = output ? `${output}\n${imageIndicators}` : imageIndicators;
		}

		return output;
	}

	private formatToolExecution(): string {
		let text = "";

		// Format based on tool type
		if (this.toolName === "bash") {
			const command = this.args?.command || "";
			text = theme.bold(`$ ${command || theme.fg("muted", "...")}`);

			if (this.result) {
				// Show output without code fences - more minimal
				const output = this.getTextOutput().trim();
				if (output) {
					const lines = output.split("\n");
					const maxLines = this.expanded ? lines.length : 5;
					const displayLines = lines.slice(0, maxLines);
					const remaining = lines.length - maxLines;

					text += "\n\n" + displayLines.map((line: string) => theme.fg("muted", line)).join("\n");
					if (remaining > 0) {
						text += theme.fg("muted", `\n... (${remaining} more lines)`);
					}
				}
			}
		} else if (this.toolName === "read") {
			const path = shortenPath(this.args?.file_path || this.args?.path || "");
			const offset = this.args?.offset;
			const limit = this.args?.limit;

			// Build path display with offset/limit suffix
			let pathDisplay = path ? theme.fg("accent", path) : theme.fg("muted", "...");
			if (offset !== undefined) {
				const endLine = limit !== undefined ? offset + limit : "";
				pathDisplay += theme.fg("muted", `:${offset}${endLine ? `-${endLine}` : ""}`);
			}

			text = theme.bold("read") + " " + pathDisplay;

			if (this.result) {
				const output = this.getTextOutput();
				const lines = output.split("\n");
				const maxLines = this.expanded ? lines.length : 10;
				const displayLines = lines.slice(0, maxLines);
				const remaining = lines.length - maxLines;

				text += "\n\n" + displayLines.map((line: string) => theme.fg("muted", replaceTabs(line))).join("\n");
				if (remaining > 0) {
					text += theme.fg("muted", `\n... (${remaining} more lines)`);
				}
			}
		} else if (this.toolName === "write") {
			const path = shortenPath(this.args?.file_path || this.args?.path || "");
			const fileContent = this.args?.content || "";
			const lines = fileContent ? fileContent.split("\n") : [];
			const totalLines = lines.length;

			text = theme.bold("write") + " " + (path ? theme.fg("accent", path) : theme.fg("muted", "..."));
			if (totalLines > 10) {
				text += ` (${totalLines} lines)`;
			}

			// Show first 10 lines of content if available
			if (fileContent) {
				const maxLines = this.expanded ? lines.length : 10;
				const displayLines = lines.slice(0, maxLines);
				const remaining = lines.length - maxLines;

				text += "\n\n" + displayLines.map((line: string) => theme.fg("muted", replaceTabs(line))).join("\n");
				if (remaining > 0) {
					text += theme.fg("muted", `\n... (${remaining} more lines)`);
				}
			}
		} else if (this.toolName === "edit") {
			const path = shortenPath(this.args?.file_path || this.args?.path || "");
			text = theme.bold("edit") + " " + (path ? theme.fg("accent", path) : theme.fg("muted", "..."));

			if (this.result) {
				// Show error message if it's an error
				if (this.result.isError) {
					const errorText = this.getTextOutput();
					if (errorText) {
						text += "\n\n" + theme.fg("error", errorText);
					}
				} else if (this.result.details?.diff) {
					// Show diff if available
					const diffLines = this.result.details.diff.split("\n");
					const coloredLines = diffLines.map((line: string) => {
						if (line.startsWith("+")) {
							return theme.fg("toolDiffAdded", line);
						} else if (line.startsWith("-")) {
							return theme.fg("toolDiffRemoved", line);
						} else {
							return theme.fg("toolDiffContext", line);
						}
					});
					text += "\n\n" + coloredLines.join("\n");
				}
			}
		} else {
			// Generic tool
			text = theme.bold(this.toolName);

			const content = JSON.stringify(this.args, null, 2);
			text += "\n\n" + content;
			const output = this.getTextOutput();
			if (output) {
				text += "\n" + output;
			}
		}

		return text;
	}
}


I can see the issue. The tool execution uses:
- **Heading/command** - `theme.bold()` for tool names and commands
- **Output/results** - `theme.fg("muted", ...)` for tool output
- **Edit diffs** - Uses dedicated diff colors (`toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext`)

But `toolText` is never used! It should be applied as a default text color for the whole tool box. The Text component supports both a background function AND a foreground color can be set via defaultTextStyle. But looking at the Text constructor, it doesn't support a default foreground color - only background.

Actually, wait - let me check how we could apply the toolText color:

bash

grep -A 10 "constructor.*Text" packages/tui/src/components/text.ts | head -15