diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37c920ef..b5613964 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -212,6 +212,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed
+- **The chosen theme now persists across restarts**, per repo. Picking a theme
+ (status-bar switcher or Settings) writes it to the repo's
+ `.clide/settings.yaml` (and a global default), and reopening the repo restores
+ it — including the high-contrast variant. A removed theme falls back to the
+ default instead of resetting silently. (T-293)
- Folded activity and agent-run cards in the Claude conversation now use the
same bottom spacing as the prose cards around them, instead of sitting
cramped 3px below the next card. (T-282)
diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart
index 18f36c1f..bd359b3c 100644
--- a/lib/kernel/src/facade.dart
+++ b/lib/kernel/src/facade.dart
@@ -5,6 +5,7 @@ import 'package:clide/kernel/src/clipboard.dart';
import 'package:clide/kernel/src/commands/keybindings.dart';
import 'package:clide/kernel/src/keymap/keymap_service.dart';
import 'package:clide/kernel/src/text_zoom.dart';
+import 'package:clide/kernel/src/theme/theme_persistence.dart';
import 'package:clide/kernel/src/toast.dart';
import 'package:clide/kernel/src/commands/palette.dart';
import 'package:clide/kernel/src/commands/registry.dart';
@@ -158,6 +159,7 @@ class KernelServices {
}
final theme = ThemeController(bundled: bundledThemes);
+ wireThemePersistence(theme, settings);
final panels = PanelRegistry();
final arrangement = LayoutArrangement();
final commands = CommandRegistry();
diff --git a/lib/kernel/src/theme/theme_persistence.dart b/lib/kernel/src/theme/theme_persistence.dart
new file mode 100644
index 00000000..62eb9625
--- /dev/null
+++ b/lib/kernel/src/theme/theme_persistence.dart
@@ -0,0 +1,40 @@
+/// Persist the chosen theme and restore it across loads (T-293).
+///
+/// clide runs a single live [ThemeController]; this bridges it to the
+/// [SettingsStore] so a choice survives a restart. On every theme change we
+/// write `app.theme` (the global last-used default) and, when a repo is open,
+/// `project.theme` in that repo's `.clide/settings.yaml`. Whenever the settings
+/// change — notably when a repo opens and its project values load — we restore
+/// the most specific saved theme: `project.theme`, else the global `app.theme`.
+/// The current name already encodes the high-contrast variant (`-hc`), so
+/// the toggle persists for free. An unknown/removed theme name is ignored, so a
+/// stale value can't wedge startup.
+library;
+
+import 'dart:async';
+
+import 'package:clide/kernel/src/settings.dart';
+import 'package:clide/kernel/src/theme/controller.dart';
+
+void wireThemePersistence(ThemeController theme, SettingsStore settings) {
+ theme.addListener(() {
+ final name = theme.currentName;
+ // Global default — lets a brand-new / unthemed repo inherit the last choice.
+ if (settings.get('app.theme') != name) {
+ unawaited(settings.set('app.theme', name));
+ }
+ // Per-repo, when one is open.
+ if (settings.projectDir != null && settings.get('project.theme') != name) {
+ unawaited(settings.set('project.theme', name));
+ }
+ });
+
+ void restore() {
+ final saved = settings.get('project.theme') ?? settings.get('app.theme');
+ if (saved == null || saved == theme.currentName) return;
+ if (theme.available.any((d) => d.name == saved)) theme.select(saved);
+ }
+
+ settings.addListener(restore);
+ restore(); // apply the global default already loaded at boot
+}
diff --git a/test/kernel/src/theme_persistence_test.dart b/test/kernel/src/theme_persistence_test.dart
new file mode 100644
index 00000000..0710be6a
--- /dev/null
+++ b/test/kernel/src/theme_persistence_test.dart
@@ -0,0 +1,106 @@
+import 'dart:io';
+
+import 'package:clide/kernel/kernel.dart';
+import 'package:clide/kernel/src/theme/theme_persistence.dart';
+import 'package:flutter/widgets.dart' show Color;
+import 'package:flutter_test/flutter_test.dart';
+
+ThemeDefinition _def(String name) => ThemeDefinition(
+ name: name,
+ displayName: name,
+ dark: true,
+ palette: Palette(const {
+ 'primary': Color(0xFF00A3D2),
+ 'accent': Color(0xFFFA5F8B),
+ 'background': Color(0xFF21262F),
+ 'surface': Color(0xFF393E48),
+ 'panel': Color(0xFF292E38),
+ 'foreground': Color(0xFFE2E8F5),
+ 'success': Color(0xFF00AB9A),
+ 'warning': Color(0xFFD08447),
+ 'error': Color(0xFFF06C6F),
+ }),
+ );
+
+void main() {
+ group('wireThemePersistence (T-293)', () {
+ late Directory appTmp;
+ late Directory repoTmp;
+ late SettingsStore settings;
+ late ThemeController theme;
+
+ setUp(() async {
+ appTmp = await Directory.systemTemp.createTemp('clide_theme_app_');
+ repoTmp = await Directory.systemTemp.createTemp('clide_theme_repo_');
+ settings = SettingsStore(appDir: appTmp);
+ await settings.load();
+ theme = ThemeController(bundled: [_def('summer-night'), _def('forest'), _def('paper'), _def('paper-hc')]);
+ wireThemePersistence(theme, settings);
+ });
+
+ tearDown(() async {
+ await pumpEventQueue(); // flush any in-flight unawaited set() file writes
+ settings.dispose();
+ theme.dispose();
+ for (final d in [appTmp, repoTmp]) {
+ if (await d.exists()) {
+ try {
+ await d.delete(recursive: true);
+ } catch (_) {}
+ }
+ }
+ });
+
+ test('selecting with no repo open persists app.theme (global default)', () async {
+ theme.select('forest');
+ expect(settings.get('app.theme'), 'forest');
+ expect(settings.get('project.theme'), isNull);
+ });
+
+ test('selecting with a repo open persists project.theme into that repo', () async {
+ await settings.setProjectDir(repoTmp);
+ theme.select('forest');
+ expect(settings.get('project.theme'), 'forest');
+ await pumpEventQueue(); // let the unawaited file write flush
+ expect(await File('${repoTmp.path}/.clide/settings.yaml').exists(), isTrue);
+ });
+
+ test('the high-contrast variant persists (the name encodes -hc)', () async {
+ theme.select('paper-hc');
+ expect(settings.get('app.theme'), 'paper-hc');
+ });
+
+ test('opening a repo restores project.theme over the global app.theme', () async {
+ await settings.set('app.theme', 'forest');
+ await File('${repoTmp.path}/.clide/settings.yaml').create(recursive: true);
+ await File('${repoTmp.path}/.clide/settings.yaml').writeAsString('project:\n theme: paper\n');
+
+ await settings.setProjectDir(repoTmp); // loads project values + notifies → restore
+
+ expect(theme.currentName, 'paper');
+ });
+
+ test('restores the global app.theme at wiring time (boot)', () async {
+ await File('${appTmp.path}/settings.yaml').writeAsString('app:\n theme: forest\n');
+ final s2 = SettingsStore(appDir: appTmp);
+ await s2.load();
+ final t2 = ThemeController(bundled: [_def('summer-night'), _def('forest')]);
+ expect(t2.currentName, 'summer-night'); // bundled.first before wiring
+
+ wireThemePersistence(t2, s2);
+
+ expect(t2.currentName, 'forest'); // restored from app.theme
+ s2.dispose();
+ t2.dispose();
+ });
+
+ test('an unknown saved theme is ignored (no throw, keeps current)', () async {
+ await File('${repoTmp.path}/.clide/settings.yaml').create(recursive: true);
+ await File('${repoTmp.path}/.clide/settings.yaml').writeAsString('project:\n theme: gone-theme\n');
+
+ await settings.setProjectDir(repoTmp);
+
+ expect(theme.currentName, 'summer-night'); // unchanged
+ });
+ });
+}