diff --git a/docs/superpowers/plans/2026-06-04-inherited-settings-and-turns.md b/docs/superpowers/plans/2026-06-04-inherited-settings-and-turns.md new file mode 100644 index 0000000..5077af8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-inherited-settings-and-turns.md @@ -0,0 +1,1107 @@ +# Inherited Settings Display, Overrides, and Turns — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make inherited config values visible in-place with a source-aware marker (`inherited · List` / `inherited · Global`), let users override them, and add **Max turns** as a list- and task-level overridable setting. + +**Architecture:** Add a nullable `MaxTurns` override to `ListConfigEntity` and `TaskEntity` resolved task → list → global in `TaskRunner`. Thread it through repositories, SignalR DTOs, the SignalR hub, and the MCP config tools. In the UI, a pure resolution helper computes `(value, source)`; a small reusable badge control plus per-field reset buttons render the marker in both the List settings modal and the task overrides flyout. + +**Tech Stack:** .NET 8, EF Core (SQLite), ASP.NET Core SignalR, Avalonia 12 + CommunityToolkit.Mvvm, xUnit. ModelContextProtocol MCP server. + +**Build/test reminders (see CLAUDE.md):** build individual csproj with `-c Release` (a running Worker locks Debug). Subagents use the `sonnet` model and stage files explicitly by path — never `git add -A`. `locales/en.json` and `locales/de.json` keys must stay in parity. Changing `IWorkerClient` / DTOs breaks hand-rolled fakes — update `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`. + +--- + +## File Structure + +**Created:** +- `src/ClaudeDo.Data/Migrations/_InheritableMaxTurns.cs` (+ `.Designer.cs`, generated by `dotnet ef`) +- `src/ClaudeDo.Ui/Services/InheritanceResolver.cs` — pure helper: `(value, source)` resolution + `InheritSource` enum +- `src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml` (+ `.axaml.cs`) — label + source badge control +- `tests/ClaudeDo.Ui.Tests/InheritanceResolverTests.cs` +- `tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs` (or extend an existing runner-config test file) +- `tests/ClaudeDo.Data.Tests/MaxTurnsRoundTripTests.cs` + +**Modified:** +- `src/ClaudeDo.Data/Models/ListConfigEntity.cs`, `TaskEntity.cs` +- `src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs`, `TaskEntityConfiguration.cs` +- `src/ClaudeDo.Data/Repositories/ListRepository.cs`, `TaskRepository.cs` +- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` +- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` +- `src/ClaudeDo.Worker/External/ConfigMcpTools.cs` +- `src/ClaudeDo.Ui/Services/WorkerClient.cs`, `Services/Interfaces/IWorkerClient.cs` +- `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` +- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs`, `Views/Modals/ListSettingsModalView.axaml` +- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, `Views/Islands/DetailsIslandView.axaml` +- `locales/en.json`, `locales/de.json` + +--- + +## Task 1: Add `MaxTurns` to entities, EF config, and migration + +**Files:** +- Modify: `src/ClaudeDo.Data/Models/ListConfigEntity.cs` +- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs` +- Modify: `src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs` +- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs:83-85` +- Create: migration (generated) + +- [ ] **Step 1: Add the property to `ListConfigEntity`** + +In `src/ClaudeDo.Data/Models/ListConfigEntity.cs`, after the `AgentPath` line: + +```csharp +public sealed class ListConfigEntity +{ + public required string ListId { get; init; } + public string? Model { get; set; } + public string? SystemPrompt { get; set; } + public string? AgentPath { get; set; } + public int? MaxTurns { get; set; } + + // Navigation property + public ListEntity List { get; set; } = null!; +} +``` + +- [ ] **Step 2: Add the property to `TaskEntity`** + +In `src/ClaudeDo.Data/Models/TaskEntity.cs`, beside the existing `Model` / `SystemPrompt` / `AgentPath` override properties, add: + +```csharp + public int? MaxTurns { get; set; } +``` + +- [ ] **Step 3: Map the columns in both entity configurations** + +In `ListConfigEntityConfiguration.cs`, after the `AgentPath` mapping: + +```csharp + builder.Property(c => c.MaxTurns).HasColumnName("max_turns"); +``` + +In `TaskEntityConfiguration.cs`, after line 85 (`AgentPath` mapping): + +```csharp + builder.Property(t => t.MaxTurns).HasColumnName("max_turns"); +``` + +- [ ] **Step 4: Generate the migration** + +Run: +```bash +dotnet ef migrations add InheritableMaxTurns --project src/ClaudeDo.Data --startup-project src/ClaudeDo.Worker +``` +Expected: creates `src/ClaudeDo.Data/Migrations/_InheritableMaxTurns.cs` (+ Designer) and updates `ClaudeDoDbContextModelSnapshot.cs`. The `Up` method should `AddColumn("max_turns", "list_config", nullable: true)` and `AddColumn("max_turns", "tasks", nullable: true)`. + +If the `dotnet ef` tool is unavailable, hand-author the migration mirroring `Migrations/20260603141020_DailyPrepMaxTasks.cs` but with `nullable: true` and no `defaultValue`, adding both columns, and add matching `Property("MaxTurns").HasColumnName("max_turns")` entries to the snapshot for the `ListConfigEntity` and `TaskEntity` builders. + +- [ ] **Step 5: Verify the migration is sane** + +Open the generated `_InheritableMaxTurns.cs` and confirm both `AddColumn` calls use `nullable: true` and there is **no** `defaultValue` (null = inherit). Confirm `Down` drops both columns. + +- [ ] **Step 6: Build Data** + +Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release` +Expected: build succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add src/ClaudeDo.Data/Models/ListConfigEntity.cs src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations/ +git commit -m "feat(data): add nullable max_turns override to list_config and tasks" +``` + +--- + +## Task 2: Repositories persist `MaxTurns` + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/ListRepository.cs:56-70` +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs:157-170` +- Test: `tests/ClaudeDo.Data.Tests/MaxTurnsRoundTripTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Data.Tests/MaxTurnsRoundTripTests.cs`. Use the same in-memory/temp-SQLite context setup as a neighboring Data.Tests file (open one, e.g. `ListRepositoryTests.cs`, and copy its context-creation helper exactly — it builds a `ClaudeDoDbContext` against a temp SQLite file and calls `Database.Migrate()` or `EnsureCreated()`). + +```csharp +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using Xunit; + +namespace ClaudeDo.Data.Tests; + +public class MaxTurnsRoundTripTests +{ + [Fact] + public async Task ListConfig_persists_max_turns() + { + await using var ctx = TestDb.NewContext(); // use the helper pattern from existing Data.Tests + var lists = new ListRepository(ctx); + await ctx.Lists.AddAsync(new ListEntity { Id = "L1", Name = "L1" }); + await ctx.SaveChangesAsync(); + + await lists.SetConfigAsync(new ListConfigEntity { ListId = "L1", MaxTurns = 42 }); + + var cfg = await lists.GetConfigAsync("L1"); + Assert.Equal(42, cfg!.MaxTurns); + } + + [Fact] + public async Task Task_agent_settings_persist_and_clear_max_turns() + { + await using var ctx = TestDb.NewContext(); + var tasks = new TaskRepository(ctx); + await ctx.Tasks.AddAsync(new TaskEntity { Id = "T1", ListId = "L1", Title = "t" }); + await ctx.SaveChangesAsync(); + + await tasks.UpdateAgentSettingsAsync("T1", model: null, systemPrompt: null, agentPath: null, maxTurns: 7); + var t = await tasks.GetByIdAsync("T1"); + Assert.Equal(7, t!.MaxTurns); + + await tasks.UpdateAgentSettingsAsync("T1", null, null, null, maxTurns: null); + var cleared = await tasks.GetByIdAsync("T1"); + Assert.Null(cleared!.MaxTurns); + } +} +``` + +> NOTE: replace `TestDb.NewContext()` with whatever the existing Data.Tests use (they may have a fixture or a static helper). Match the existing pattern exactly; do not invent a new harness. `ListEntity` requires only `Id` + `Name` (other fields have defaults) — verify against the existing tests. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter MaxTurnsRoundTripTests` +Expected: FAIL — `UpdateAgentSettingsAsync` has no `maxTurns` parameter (compile error), and `SetConfigAsync` does not copy `MaxTurns` onto existing rows. + +- [ ] **Step 3: Update `ListRepository.SetConfigAsync`** + +In `src/ClaudeDo.Data/Repositories/ListRepository.cs`, in the `else` branch that updates an existing row (after `existing.AgentPath = config.AgentPath;`): + +```csharp + existing.MaxTurns = config.MaxTurns; +``` + +(The `Add` branch already stores the full entity, so no change there.) + +- [ ] **Step 4: Update `TaskRepository.UpdateAgentSettingsAsync`** + +Replace the method body in `src/ClaudeDo.Data/Repositories/TaskRepository.cs:157-170` with: + +```csharp + public async Task UpdateAgentSettingsAsync( + string taskId, + string? model, + string? systemPrompt, + string? agentPath, + int? maxTurns = null, + CancellationToken ct = default) + { + await _context.Tasks + .Where(t => t.Id == taskId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Model, model) + .SetProperty(t => t.SystemPrompt, systemPrompt) + .SetProperty(t => t.AgentPath, agentPath) + .SetProperty(t => t.MaxTurns, maxTurns), ct); + } +``` + +> `maxTurns` is placed before `ct` with a default so existing 4-arg callers still compile; we update those callers explicitly in Task 4. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter MaxTurnsRoundTripTests` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/ListRepository.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Data.Tests/MaxTurnsRoundTripTests.cs +git commit -m "feat(data): persist max_turns in list and task repositories" +``` + +--- + +## Task 3: Runner resolves `MaxTurns` task → list → global + +**Files:** +- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:388-394` +- Test: `tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs` + +> Context: `BuildRunConfig` currently returns `MaxTurns: global.DefaultMaxTurns`. `ClaudeRunConfig.MaxTurns` is `int?` and `ClaudeArgsBuilder` already emits `--max-turns` when `> 0`. Only the resolution line changes. + +- [ ] **Step 1: Inspect the method signature** + +Open `src/ClaudeDo.Worker/Runner/TaskRunner.cs` around line 360-395 and confirm the names of the in-scope variables: the task entity (`task`), the list config (`listConfig`), and the app settings (`global`). If the config-building logic is not directly unit-testable (private method, lots of dependencies), extract a tiny pure static helper for just the MaxTurns precedence and test that: + +```csharp + internal static int? ResolveMaxTurns(int? taskTurns, int? listTurns, int globalDefault) + => taskTurns ?? listTurns ?? globalDefault; +``` + +Place it in `TaskRunner` as an `internal static` method. + +- [ ] **Step 2: Write the failing test** + +Create `tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs`: + +```csharp +using ClaudeDo.Worker.Runner; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Runner; + +public class MaxTurnsResolutionTests +{ + [Fact] + public void Task_override_wins() + => Assert.Equal(5, TaskRunner.ResolveMaxTurns(taskTurns: 5, listTurns: 20, globalDefault: 100)); + + [Fact] + public void List_override_used_when_no_task_override() + => Assert.Equal(20, TaskRunner.ResolveMaxTurns(taskTurns: null, listTurns: 20, globalDefault: 100)); + + [Fact] + public void Falls_back_to_global_default() + => Assert.Equal(100, TaskRunner.ResolveMaxTurns(taskTurns: null, listTurns: null, globalDefault: 100)); +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter MaxTurnsResolutionTests` +Expected: FAIL — `ResolveMaxTurns` not defined. + +- [ ] **Step 4: Add the helper and use it in `BuildRunConfig`** + +Add the `ResolveMaxTurns` helper from Step 1 to `TaskRunner`. Then change the `MaxTurns:` argument in the `ClaudeRunConfig` construction (currently `MaxTurns: global.DefaultMaxTurns,`) to: + +```csharp + MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, global.DefaultMaxTurns), +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter MaxTurnsResolutionTests` +Expected: PASS (3 tests). + +- [ ] **Step 6: Build Worker** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release` +Expected: build succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs +git commit -m "feat(worker): resolve max-turns from task then list then global default" +``` + +--- + +## Task 4: Thread `MaxTurns` through Worker transport (DTOs, Hub, MCP) + +**Files:** +- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:58-60, 334-368, 406-417` +- Modify: `src/ClaudeDo.Worker/External/ConfigMcpTools.cs` + +- [ ] **Step 1: Extend the Worker-side DTO records** + +In `src/ClaudeDo.Worker/Hub/WorkerHub.cs` (lines 58-60), add a trailing nullable `int?` to each record (defaulted so existing positional usages elsewhere keep compiling): + +```csharp +public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); +public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); +public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); +``` + +- [ ] **Step 2: Persist `MaxTurns` in `UpdateListConfig`** + +In `WorkerHub.UpdateListConfig` (lines 334-359): include MaxTurns in the "is everything empty?" delete check and in the `SetConfigAsync` call. + +```csharp + public async Task UpdateListConfig(UpdateListConfigDto dto) + { + using var ctx = _dbFactory.CreateDbContext(); + var repo = new ListRepository(ctx); + + var model = dto.Model.NullIfBlank(); + var systemPrompt = dto.SystemPrompt.NullIfBlank(); + var agentPath = dto.AgentPath.NullIfBlank(); + + if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null) + { + await repo.DeleteConfigAsync(dto.ListId); + } + else + { + await repo.SetConfigAsync(new ListConfigEntity + { + ListId = dto.ListId, + Model = model, + SystemPrompt = systemPrompt, + AgentPath = agentPath, + MaxTurns = dto.MaxTurns, + }); + } + + await _broadcaster.ListUpdated(dto.ListId); + } +``` + +- [ ] **Step 3: Return `MaxTurns` from `GetListConfig`** + +In `WorkerHub.GetListConfig` (line 367), change the return to include MaxTurns: + +```csharp + return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns); +``` + +- [ ] **Step 4: Persist `MaxTurns` in `UpdateTaskAgentSettings`** + +In `WorkerHub.UpdateTaskAgentSettings` (lines 406-417), pass `dto.MaxTurns` to the repository (note the new `maxTurns` parameter sits before `ct`): + +```csharp + await repo.UpdateAgentSettingsAsync( + dto.TaskId, + dto.Model.NullIfBlank(), + dto.SystemPrompt.NullIfBlank(), + dto.AgentPath.NullIfBlank(), + dto.MaxTurns); +``` + +- [ ] **Step 5: Extend the MCP config tools** + +In `src/ClaudeDo.Worker/External/ConfigMcpTools.cs`: + +Update the DTO (line 9): +```csharp +public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns); +``` + +Update `GetListConfig` (line 29): +```csharp + return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath, cfg.MaxTurns); +``` + +Update `SetListConfig` signature + body — add an `int? maxTurns` parameter (before `CancellationToken`), include it in the clear-check and the upsert: +```csharp + [McpServerTool, Description("Set a list's default model/system prompt/agent path/max turns. Passing model, systemPrompt, agentPath, and maxTurns all null clears the list config.")] + public async Task SetListConfig( + string listId, string? model, string? systemPrompt, string? agentPath, int? maxTurns, CancellationToken cancellationToken) + { + _ = await _lists.GetByIdAsync(listId, cancellationToken) + ?? throw new InvalidOperationException($"List {listId} not found."); + + var m = model.NullIfBlank(); + var sp = systemPrompt.NullIfBlank(); + var ap = agentPath.NullIfBlank(); + + if (m is null && sp is null && ap is null && maxTurns is null) + await _lists.DeleteConfigAsync(listId, cancellationToken); + else + await _lists.SetConfigAsync(new ListConfigEntity + { + ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap, MaxTurns = maxTurns, + }, cancellationToken); + + await _broadcaster.ListUpdated(listId); + } +``` + +Update `SetTaskConfig` signature + body — add `int? maxTurns` (before `CancellationToken`) and pass it through: +```csharp + [McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path/max turns). Pass null for any field to clear that override.")] + public async Task SetTaskConfig( + string taskId, string? model, string? systemPrompt, string? agentPath, int? maxTurns, CancellationToken cancellationToken) + { + _ = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + + await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, cancellationToken); + await _broadcaster.TaskUpdated(taskId); + } +``` + +- [ ] **Step 6: Build Worker** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release` +Expected: build succeeds. (If any other caller constructed these DTOs positionally and now mismatches, fix it — the new params are defaulted, so this should be clean.) + +- [ ] **Step 7: Commit** + +```bash +git add src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/External/ConfigMcpTools.cs +git commit -m "feat(worker): expose max-turns override over signalr and mcp config tools" +``` + +--- + +## Task 5: Mirror DTO changes in the UI transport layer + +**Files:** +- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs:524-526` +- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` (only if a signature/record change breaks it) + +> The UI keeps its own copies of `UpdateListConfigDto`, `UpdateTaskAgentSettingsDto`, `ListConfigDto`. They must mirror the Worker exactly or SignalR (de)serialization drops the field. `IWorkerClient` method signatures don't change (they take whole DTOs), so the interface and stub need no new members — but verify the stub still compiles. + +- [ ] **Step 1: Extend the UI DTO records** + +In `src/ClaudeDo.Ui/Services/WorkerClient.cs` (lines 524-526): + +```csharp +public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); +public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); +public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); +``` + +- [ ] **Step 2: Build the UI project** + +Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` +Expected: build succeeds (this pulls in Ui + Data). + +- [ ] **Step 3: Build the UI test project** + +Run: `dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release` +Expected: build succeeds. `StubWorkerClient` returns `null` for `GetListConfigAsync` and ignores `UpdateTaskAgentSettingsAsync(dto)`, so the defaulted record params keep it compiling. If it breaks, fix only the affected line. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Ui/Services/WorkerClient.cs +git commit -m "feat(ui): mirror max-turns field on signalr config dtos" +``` + +--- + +## Task 6: Localization keys (en + de parity) + +**Files:** +- Modify: `locales/en.json` +- Modify: `locales/de.json` + +> Localization.Tests enforces key parity. Find the right nesting by searching for the existing sibling keys (`modals.listSettings.model`, `details.modelLabel`, `vm.details.effectiveIfInherited`). + +- [ ] **Step 1: Add the new keys to `locales/en.json`** + +Add (place each next to its siblings; values shown): +- under `modals.listSettings`: `"maxTurns": "Max turns"` +- under `details`: `"maxTurnsLabel": "Max turns"`, `"systemPromptPrepended": "Prepended automatically:"` +- a new/extended `settings.inherit` group (or wherever shared UI strings live — match existing convention): + - `"inheritedFromList": "inherited · List"` + - `"inheritedFromGlobal": "inherited · Global"` + - `"overrideBadge": "override"` + - `"resetToInherited": "Reset to inherited"` + +- [ ] **Step 2: Add the same keys to `locales/de.json`** + +Same key paths, German values: +- `modals.listSettings.maxTurns`: `"Max. Turns"` +- `details.maxTurnsLabel`: `"Max. Turns"` +- `details.systemPromptPrepended`: `"Wird automatisch vorangestellt:"` +- `settings.inherit.inheritedFromList`: `"geerbt · Liste"` +- `settings.inherit.inheritedFromGlobal`: `"geerbt · Global"` +- `settings.inherit.overrideBadge`: `"überschrieben"` +- `settings.inherit.resetToInherited`: `"Auf geerbt zurücksetzen"` + +- [ ] **Step 3: Remove the now-unused `effectiveIfInherited` keys** + +Remove `vm.details.effectiveIfInherited` from BOTH `en.json` and `de.json` (it is replaced by the badge approach in Tasks 8-9; this plan deletes its only usages there). Removing from both keeps parity. + +- [ ] **Step 4: Run the localization tests** + +Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release` +Expected: PASS (en/de key parity holds). + +- [ ] **Step 5: Commit** + +```bash +git add locales/en.json locales/de.json +git commit -m "feat(i18n): add inherited-marker, turns, and prepended-prompt strings" +``` + +--- + +## Task 7: `InheritanceResolver` helper (pure, unit-tested) + +**Files:** +- Create: `src/ClaudeDo.Ui/Services/InheritanceResolver.cs` +- Test: `tests/ClaudeDo.Ui.Tests/InheritanceResolverTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Ui.Tests/InheritanceResolverTests.cs`: + +```csharp +using ClaudeDo.Ui.Services; +using Xunit; + +namespace ClaudeDo.Ui.Tests; + +public class InheritanceResolverTests +{ + [Fact] + public void Task_value_is_an_override() + { + var (value, source) = InheritanceResolver.Resolve("opus", "sonnet", "haiku"); + Assert.Equal("opus", value); + Assert.Equal(InheritSource.Override, source); + } + + [Fact] + public void Falls_through_to_list() + { + var (value, source) = InheritanceResolver.Resolve(null, "sonnet", "haiku"); + Assert.Equal("sonnet", value); + Assert.Equal(InheritSource.List, source); + } + + [Fact] + public void Falls_through_to_global() + { + var (value, source) = InheritanceResolver.Resolve(" ", null, "haiku"); + Assert.Equal("haiku", value); + Assert.Equal(InheritSource.Global, source); + } + + [Fact] + public void List_scope_treats_list_value_as_override() + { + var (value, source) = InheritanceResolver.ResolveList("sonnet", "haiku"); + Assert.Equal("sonnet", value); + Assert.Equal(InheritSource.Override, source); + + var (value2, source2) = InheritanceResolver.ResolveList(null, "haiku"); + Assert.Equal("haiku", value2); + Assert.Equal(InheritSource.Global, source2); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter InheritanceResolverTests` +Expected: FAIL — type does not exist. + +- [ ] **Step 3: Implement the helper** + +Create `src/ClaudeDo.Ui/Services/InheritanceResolver.cs`: + +```csharp +namespace ClaudeDo.Ui.Services; + +public enum InheritSource { Override, List, Global } + +public static class InheritanceResolver +{ + // Task-scope fields: task -> list -> global. + public static (string Value, InheritSource Source) Resolve( + string? taskValue, string? listValue, string? globalValue) + { + if (!string.IsNullOrWhiteSpace(taskValue)) return (taskValue!, InheritSource.Override); + if (!string.IsNullOrWhiteSpace(listValue)) return (listValue!, InheritSource.List); + return (globalValue ?? "", InheritSource.Global); + } + + // List-scope fields: list -> global (lists have no tier above them). + public static (string Value, InheritSource Source) ResolveList( + string? listValue, string? globalValue) + { + if (!string.IsNullOrWhiteSpace(listValue)) return (listValue!, InheritSource.Override); + return (globalValue ?? "", InheritSource.Global); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter InheritanceResolverTests` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Ui/Services/InheritanceResolver.cs tests/ClaudeDo.Ui.Tests/InheritanceResolverTests.cs +git commit -m "feat(ui): add inheritance resolver returning value and source" +``` + +--- + +## Task 8: `InheritedBadge` reusable control + +**Files:** +- Create: `src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml` +- Create: `src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml.cs` + +> A tiny `UserControl` rendering a single badge whose text comes from a bound string. The consuming VM supplies the already-localized badge text (e.g. "inherited · List" / "override") and a bool for muted styling. Keeping localization in the VM avoids `Loc` lookups inside the control. No `ICommand` lives in the control — reset buttons are placed by each consumer (Tasks 9-10). + +- [ ] **Step 1: Create the control XAML** + +`src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml`: + +```xml + + + + + +``` + +> If `SubtleFillBrush` is not a defined token, use an existing subtle background brush from `Design/Tokens.axaml` (open it and pick the closest — e.g. a hover/overlay brush). The user will do the final visual pass. + +- [ ] **Step 2: Create the code-behind with a `BadgeText` StyledProperty** + +`src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml.cs`: + +```csharp +using Avalonia; +using Avalonia.Controls; + +namespace ClaudeDo.Ui.Views.Controls; + +public partial class InheritedBadge : UserControl +{ + public static readonly StyledProperty BadgeTextProperty = + AvaloniaProperty.Register(nameof(BadgeText)); + + public string? BadgeText + { + get => GetValue(BadgeTextProperty); + set => SetValue(BadgeTextProperty, value); + } + + public InheritedBadge() => InitializeComponent(); +} +``` + +- [ ] **Step 3: Build the UI project** + +Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` +Expected: build succeeds (control compiles, even though nothing consumes it yet). + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml.cs +git commit -m "feat(ui): add reusable inherited-source badge control" +``` + +--- + +## Task 9: List settings modal — Turns field + inherited markers + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs` +- Modify: `src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml` + +> Lists inherit only from Global. The ViewModel loads the global defaults (via `GetAppSettingsAsync`) to render the muted resolved value + `inherited · Global` badge. For Model we drop the `(default)` sentinel row and use `SelectedItem == null` to mean "inherited", with `PlaceholderText` showing the resolved value. Turns uses a `NumericUpDown` whose `Value` is `decimal?` (null = inherit) with a `Watermark` showing the global default. + +- [ ] **Step 1: Add fields + load logic to the ViewModel** + +In `ListSettingsModalViewModel.cs`: + +1. Add a `using ClaudeDo.Ui.Services;` if not present (for `InheritanceResolver` / `InheritSource`). +2. Change the Model collection to **not** include the sentinel: +```csharp + public ObservableCollection ModelOptions { get; } = new(ModelRegistry.Aliases); +``` +3. Change `SelectedModel` to nullable (null = inherit) and add the badge + Turns properties: +```csharp + [ObservableProperty] private string? _selectedModel; // null = inherit from global + [ObservableProperty] private decimal? _maxTurns; // null = inherit from global + [ObservableProperty] private string _modelInheritedHint = ""; // muted resolved value, e.g. "sonnet" + [ObservableProperty] private string _modelBadge = ""; // localized badge text + [ObservableProperty] private string _turnsInheritedHint = ""; // muted resolved value, e.g. "100" + [ObservableProperty] private string _turnsBadge = ""; + [ObservableProperty] private string _agentBadge = ""; + [ObservableProperty] private string _agentInheritedHint = ""; +``` +4. Add badge recompute helpers and wire them to property changes: +```csharp + partial void OnSelectedModelChanged(string? value) => RecomputeModelBadge(); + partial void OnMaxTurnsChanged(decimal? value) => RecomputeTurnsBadge(); + partial void OnSelectedAgentChanged(AgentInfo? value) => RecomputeAgentBadge(); + + private string _globalModel = ModelRegistry.DefaultAlias; + private int _globalMaxTurns = 100; + + private void RecomputeModelBadge() + { + var overridden = !string.IsNullOrWhiteSpace(SelectedModel); + ModelInheritedHint = _globalModel; + ModelBadge = overridden ? Loc.T("settings.inherit.overrideBadge") : Loc.T("settings.inherit.inheritedFromGlobal"); + } + + private void RecomputeTurnsBadge() + { + var overridden = MaxTurns is not null; + TurnsInheritedHint = _globalMaxTurns.ToString(); + TurnsBadge = overridden ? Loc.T("settings.inherit.overrideBadge") : Loc.T("settings.inherit.inheritedFromGlobal"); + } + + private void RecomputeAgentBadge() + { + var overridden = SelectedAgent is not null && !string.IsNullOrWhiteSpace(SelectedAgent.Path); + // Agent has no global default; inherited means "(none)". + AgentInheritedHint = "(none)"; + AgentBadge = overridden ? Loc.T("settings.inherit.overrideBadge") : Loc.T("settings.inherit.inheritedFromGlobal"); + } +``` +5. In `LoadAsync`, load globals and seed the new fields. After the `GetListConfigAsync` block, replace the Model/Agent seeding with the null-sentinel approach and add MaxTurns + globals: +```csharp + var app = await _worker.GetAppSettingsAsync(); + _globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias; + _globalMaxTurns = app?.DefaultMaxTurns ?? 100; + + SelectedModel = string.IsNullOrWhiteSpace(config?.Model) ? null : config!.Model!; + MaxTurns = config?.MaxTurns is int mt ? mt : (decimal?)null; + SystemPrompt = config?.SystemPrompt ?? ""; + SelectedAgent = string.IsNullOrWhiteSpace(config?.AgentPath) + ? Agents[0] + : (Agents.FirstOrDefault(a => a.Path == config!.AgentPath) ?? Agents[0]); + + RecomputeModelBadge(); + RecomputeTurnsBadge(); + RecomputeAgentBadge(); +``` + (Remove the old line that set `SelectedModel = ... ListDefaultSentinel ...`.) + +6. In `SaveAsync`, compute the override values and include MaxTurns: +```csharp + var model = string.IsNullOrWhiteSpace(SelectedModel) ? null : SelectedModel; + var sp = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt; + var ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path; + var turns = MaxTurns is decimal d ? (int?)d : null; + + await _worker.UpdateListAsync(new UpdateListDto( + ListId, + string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name, + string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir, + DefaultCommitType)); + + await _worker.UpdateListConfigAsync(new UpdateListConfigDto( + ListId, model, sp, ap, turns)); +``` + +7. Add reset commands and update `ResetAgentSettings`: +```csharp + [RelayCommand] private void ResetModel() => SelectedModel = null; + [RelayCommand] private void ResetTurns() => MaxTurns = null; + [RelayCommand] private void ResetAgent() => SelectedAgent = Agents.Count > 0 ? Agents[0] : null; +``` + And in the existing `ResetAgentSettings`, replace `SelectedModel = ModelRegistry.ListDefaultSentinel;` with `SelectedModel = null;` and add `MaxTurns = null;`. + +- [ ] **Step 2: Update the modal XAML — Model field** + +In `ListSettingsModalView.axaml`, add `xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"` to the root if not already present (it is). Replace the Model `StackPanel` (lines ~76-81) with a label-row that carries the badge + reset, and a ComboBox using null-selection + placeholder: + +```xml + + + + +