refactor(agent-config): single AgentConfigEditor for list + task scopes

This commit is contained in:
Mika Kuns
2026-06-23 08:52:49 +02:00
parent 60eb671e8f
commit eb0ddb56d3
14 changed files with 761 additions and 482 deletions

View File

@@ -19,6 +19,7 @@ ViewModels/
IslandsShellViewModel.cs — root coordinator
Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
NotesEditor, MergePreviewPresenter
Agent/ — AgentConfigEditorViewModel (scope-parameterized: List | Task)
Modals/ — About, Diff, ListSettings, Merge, RepoImport, Settings (+ Settings/ tab VMs),
UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree,
WorktreesOverview, UnifiedDiffParser
@@ -26,7 +27,7 @@ ViewModels/
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge, AgentConfigEditor
Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyles.axaml
(component styles + the filled icon geometry library)
```
@@ -36,10 +37,10 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip, responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RunInteractivelyAsync`, `RefineTask`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentSettingsSectionViewModel** (per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced save), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` — live worktree or commit range after merge —, `ReviewCombinedDiffCommand``PlanningDiffViewModel`), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand``RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` — live worktree or commit range after merge —, `ReviewCombinedDiffCommand``PlanningDiffViewModel`), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand``RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints).
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, per-list Model/SystemPrompt/AgentPath/MaxTurns with inherited-badge + reset, delete list), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`.
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync()`), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`.
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModalViewModel` has two modes: live worktree (branch diff vs base, with a Merge action) and commit-range `base..head` (`FromCommitRange = true` — shows a merged task's diff after its worktree is gone; no merge action). The view renders a file list with binary/empty placeholders via `DiffLinesView`.
- **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — ``/`` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).

View File

@@ -0,0 +1,259 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Agent;
public enum AgentConfigScope { List, Task }
/// <summary>
/// One agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile with inherited
/// badges + reset) shared by the List Settings modal and the per-task gear flyout.
/// Scope picks the inheritance depth (List: list→global; Task: task→list→global) and the
/// persistence (List: explicit <see cref="SaveAsync"/>; Task: debounced auto-save).
/// </summary>
public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposable
{
private readonly IWorkerClient _worker;
private readonly AgentConfigScope _scope;
private readonly EventHandler _langChangedHandler;
/// scope==List ⇒ the list id; scope==Task ⇒ the task id. Null ⇒ no save target.
internal string? TargetId { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsEnabled))]
private bool _isRunning;
// Task scope gates the editor while the run is live; List scope is always enabled.
public bool IsEnabled => !IsRunning;
[ObservableProperty] private string? _model;
[ObservableProperty] private decimal? _maxTurns;
[ObservableProperty] private string _systemPrompt = "";
[ObservableProperty] private AgentInfo? _selectedAgent;
[ObservableProperty] private string _modelBadge = "";
[ObservableProperty] private string _modelInheritedHint = "";
[ObservableProperty] private string _turnsBadge = "";
[ObservableProperty] private string _turnsInheritedHint = "";
[ObservableProperty] private string _agentBadge = "";
[ObservableProperty] private string _effectiveSystemPromptHint = "";
private string _globalModel = ModelRegistry.DefaultAlias;
private int _globalMaxTurns = 100;
private string? _listModel; // Task scope only
private int? _listMaxTurns; // Task scope only
private string? _listAgentName; // Task scope only
private bool _suppressSave;
private CancellationTokenSource? _saveCts;
public int EffectiveMaxTurns =>
MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns);
public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
public ObservableCollection<AgentInfo> Agents { get; } = new();
public AgentConfigEditorViewModel(IWorkerClient worker, AgentConfigScope scope)
{
_worker = worker;
_scope = scope;
_langChangedHandler = (_, _) => RecomputeBadges();
// Only the long-lived Task editor needs live re-badging; the List editor is a
// short-lived modal recreated with the current language on each open.
if (scope == AgentConfigScope.Task)
Loc.LanguageChanged += _langChangedHandler;
}
public void Dispose() => Loc.LanguageChanged -= _langChangedHandler;
partial void OnModelChanged(string? value) { RecomputeModelBadge(); QueueSave(); }
partial void OnMaxTurnsChanged(decimal? value)
{
RecomputeTurnsBadge();
OnPropertyChanged(nameof(EffectiveMaxTurns));
QueueSave();
}
partial void OnSystemPromptChanged(string value) => QueueSave();
partial void OnSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); QueueSave(); }
private void RecomputeBadges()
{
RecomputeModelBadge();
RecomputeTurnsBadge();
RecomputeAgentBadge();
}
private void RecomputeModelBadge()
{
var own = string.IsNullOrWhiteSpace(Model) ? null : Model;
var (value, source) = _scope == AgentConfigScope.Task
? InheritanceResolver.Resolve(own, _listModel, _globalModel)
: InheritanceResolver.ResolveList(own, _globalModel);
ModelInheritedHint = value;
ModelBadge = BadgeFor(source, own is not null);
}
private void RecomputeTurnsBadge()
{
var own = MaxTurns?.ToString();
var (value, source) = _scope == AgentConfigScope.Task
? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), _globalMaxTurns.ToString())
: InheritanceResolver.ResolveList(own, _globalMaxTurns.ToString());
TurnsInheritedHint = value;
TurnsBadge = BadgeFor(source, MaxTurns is not null);
}
private void RecomputeAgentBadge()
{
var agentSet = SelectedAgent is not null && !string.IsNullOrWhiteSpace(SelectedAgent.Path);
var own = agentSet ? SelectedAgent!.Path : null;
var (_, source) = _scope == AgentConfigScope.Task
? InheritanceResolver.Resolve(own, _listAgentName, null)
: InheritanceResolver.ResolveList(own, null);
AgentBadge = BadgeFor(source, agentSet);
}
private static string BadgeFor(InheritSource source, bool isSet) => isSet
? Loc.T("settings.inherit.overrideBadge")
: source == InheritSource.List
? Loc.T("settings.inherit.inheritedFromList")
: Loc.T("settings.inherit.inheritedFromGlobal");
private void QueueSave()
{
// List scope persists on the modal Save button; only Task auto-saves.
if (_suppressSave || _scope != AgentConfigScope.Task || TargetId is null) return;
_saveCts?.Cancel();
_saveCts = new CancellationTokenSource();
_ = DebouncedSaveAsync(_saveCts.Token);
}
private async System.Threading.Tasks.Task DebouncedSaveAsync(CancellationToken ct)
{
try
{
await System.Threading.Tasks.Task.Delay(300, ct);
if (TargetId is null) return;
await SaveAsync();
}
catch (OperationCanceledException) { }
catch { }
}
public async System.Threading.Tasks.Task SaveAsync()
{
if (TargetId is null) return;
var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
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;
if (_scope == AgentConfigScope.Task)
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns));
else
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns));
}
public async System.Threading.Tasks.Task LoadForListAsync(string listId, CancellationToken ct = default)
{
_suppressSave = true;
try
{
TargetId = listId;
await ReloadAgentsAsync("(none)");
await LoadGlobalDefaultsAsync();
var cfg = await _worker.GetListConfigAsync(listId);
ApplyConfig(cfg?.Model, cfg?.MaxTurns, cfg?.SystemPrompt, cfg?.AgentPath);
_listModel = null; _listMaxTurns = null; _listAgentName = null;
EffectiveSystemPromptHint = "";
RecomputeBadges();
OnPropertyChanged(nameof(EffectiveMaxTurns));
}
finally { _suppressSave = false; }
}
public async System.Threading.Tasks.Task LoadForTaskAsync(TaskEntity entity, CancellationToken ct = default)
{
_suppressSave = true;
try
{
TargetId = entity.Id;
await ReloadAgentsAsync("(inherited)");
ApplyConfig(entity.Model, entity.MaxTurns, entity.SystemPrompt, entity.AgentPath);
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
await LoadGlobalDefaultsAsync();
_listModel = listCfg?.Model;
_listMaxTurns = listCfg?.MaxTurns;
_listAgentName = string.IsNullOrWhiteSpace(listCfg?.AgentPath)
? null : System.IO.Path.GetFileName(listCfg!.AgentPath!);
EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt)
? "" : listCfg!.SystemPrompt!;
RecomputeBadges();
OnPropertyChanged(nameof(EffectiveMaxTurns));
}
finally { _suppressSave = false; }
}
public void Clear()
{
_suppressSave = true;
try
{
Model = null;
MaxTurns = null;
SystemPrompt = "";
SelectedAgent = null;
}
finally { _suppressSave = false; }
EffectiveSystemPromptHint = "";
TargetId = null;
}
private async System.Threading.Tasks.Task ReloadAgentsAsync(string placeholderName)
{
Agents.Clear();
Agents.Add(new AgentInfo(placeholderName, "", ""));
foreach (var a in await _worker.GetAgentsAsync()) Agents.Add(a);
}
private async System.Threading.Tasks.Task LoadGlobalDefaultsAsync()
{
var app = await _worker.GetAppSettingsAsync();
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
_globalMaxTurns = app?.DefaultMaxTurns ?? 100;
}
private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath)
{
Model = string.IsNullOrWhiteSpace(model) ? null : model!;
MaxTurns = maxTurns is int mt ? mt : (decimal?)null;
SystemPrompt = systemPrompt ?? "";
SelectedAgent = string.IsNullOrWhiteSpace(agentPath)
? Agents[0]
: (Agents.FirstOrDefault(a => a.Path == agentPath) ?? Agents[0]);
}
[RelayCommand] private void ResetModel() => Model = null;
[RelayCommand] private void ResetTurns() => MaxTurns = null;
[RelayCommand] private void ResetAgent() => SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
[RelayCommand]
private void ResetAll()
{
Model = null;
MaxTurns = null;
SystemPrompt = "";
SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
}
}

View File

@@ -1,196 +0,0 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class AgentSettingsSectionViewModel : ViewModelBase, IDisposable
{
private readonly IWorkerClient _worker;
private readonly EventHandler _langChangedHandler;
internal string? TaskId { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAgentSectionEnabled))]
private bool _isRunning;
public bool IsAgentSectionEnabled => !IsRunning;
[ObservableProperty] private string? _taskModelSelection;
[ObservableProperty] private string _taskSystemPrompt = "";
[ObservableProperty] private AgentInfo? _taskSelectedAgent;
[ObservableProperty] private decimal? _taskMaxTurns;
[ObservableProperty] private string _modelBadge = "";
[ObservableProperty] private string _modelInheritedHint = "";
[ObservableProperty] private string _turnsBadge = "";
[ObservableProperty] private string _turnsInheritedHint = "";
[ObservableProperty] private string _agentBadge = "";
[ObservableProperty] private string _effectiveSystemPromptHint = "";
private string _globalModel = ModelRegistry.DefaultAlias;
private int _globalMaxTurns = 100;
private string? _listModel;
private int? _listMaxTurns;
private string? _listAgentName;
private bool _suppressAgentSave;
private CancellationTokenSource? _agentSaveCts;
public int EffectiveMaxTurns =>
TaskMaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns);
public ObservableCollection<string> TaskModelOptions { get; } = new(ModelRegistry.Aliases);
public ObservableCollection<AgentInfo> TaskAgentOptions { get; } = new();
public AgentSettingsSectionViewModel(IWorkerClient worker)
{
_worker = worker;
_langChangedHandler = (_, _) =>
{
RecomputeModelBadge();
RecomputeTurnsBadge();
RecomputeAgentBadge();
};
Loc.LanguageChanged += _langChangedHandler;
}
public void Dispose() => Loc.LanguageChanged -= _langChangedHandler;
partial void OnTaskModelSelectionChanged(string? value) { RecomputeModelBadge(); QueueAgentSave(); }
partial void OnTaskMaxTurnsChanged(decimal? value)
{
RecomputeTurnsBadge();
OnPropertyChanged(nameof(EffectiveMaxTurns));
QueueAgentSave();
}
partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave();
partial void OnTaskSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); QueueAgentSave(); }
private void RecomputeModelBadge()
{
var (value, source) = InheritanceResolver.Resolve(TaskModelSelection, _listModel, _globalModel);
ModelInheritedHint = value;
ModelBadge = BadgeFor(source, !string.IsNullOrWhiteSpace(TaskModelSelection));
}
private void RecomputeTurnsBadge()
{
var (value, source) = InheritanceResolver.Resolve(
TaskMaxTurns?.ToString(), _listMaxTurns?.ToString(), _globalMaxTurns.ToString());
TurnsInheritedHint = value;
TurnsBadge = BadgeFor(source, TaskMaxTurns is not null);
}
private void RecomputeAgentBadge()
{
var taskSet = TaskSelectedAgent is not null && !string.IsNullOrWhiteSpace(TaskSelectedAgent.Path);
var (_, source) = InheritanceResolver.Resolve(
taskSet ? TaskSelectedAgent!.Path : null, _listAgentName, null);
AgentBadge = BadgeFor(source, taskSet);
}
private static string BadgeFor(InheritSource source, bool taskSet) => taskSet
? Loc.T("settings.inherit.overrideBadge")
: source == InheritSource.List
? Loc.T("settings.inherit.inheritedFromList")
: Loc.T("settings.inherit.inheritedFromGlobal");
private void QueueAgentSave()
{
if (_suppressAgentSave || TaskId is null) return;
_agentSaveCts?.Cancel();
_agentSaveCts = new CancellationTokenSource();
_ = SaveAgentSettingsAsync(_agentSaveCts.Token);
}
private async System.Threading.Tasks.Task SaveAgentSettingsAsync(CancellationToken ct)
{
try
{
await System.Threading.Tasks.Task.Delay(300, ct);
if (TaskId is null) return;
var model = string.IsNullOrWhiteSpace(TaskModelSelection) ? null : TaskModelSelection;
var sp = string.IsNullOrWhiteSpace(TaskSystemPrompt) ? null : TaskSystemPrompt;
var ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path)
? null : TaskSelectedAgent.Path;
var turns = TaskMaxTurns is decimal d ? (int?)d : null;
await _worker.UpdateTaskAgentSettingsAsync(
new UpdateTaskAgentSettingsDto(TaskId, model, sp, ap, turns));
}
catch (OperationCanceledException) { }
catch { }
}
internal async System.Threading.Tasks.Task LoadAsync(
ClaudeDo.Data.Models.TaskEntity entity, CancellationToken ct)
{
_suppressAgentSave = true;
try
{
TaskAgentOptions.Clear();
TaskAgentOptions.Add(new AgentInfo("(inherited)", "", ""));
var agents = await _worker.GetAgentsAsync();
foreach (var a in agents) TaskAgentOptions.Add(a);
TaskModelSelection = string.IsNullOrWhiteSpace(entity.Model) ? null : entity.Model!;
TaskMaxTurns = entity.MaxTurns is int tmt ? tmt : (decimal?)null;
TaskSystemPrompt = entity.SystemPrompt ?? "";
TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath)
? TaskAgentOptions[0]
: (TaskAgentOptions.FirstOrDefault(a => a.Path == entity.AgentPath) ?? TaskAgentOptions[0]);
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
var app = await _worker.GetAppSettingsAsync();
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
_globalMaxTurns = app?.DefaultMaxTurns ?? 100;
_listModel = listCfg?.Model;
_listMaxTurns = listCfg?.MaxTurns;
_listAgentName = string.IsNullOrWhiteSpace(listCfg?.AgentPath)
? null : System.IO.Path.GetFileName(listCfg!.AgentPath!);
EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt)
? "" : listCfg!.SystemPrompt!;
RecomputeModelBadge();
RecomputeTurnsBadge();
RecomputeAgentBadge();
OnPropertyChanged(nameof(EffectiveMaxTurns));
}
finally
{
_suppressAgentSave = false;
}
}
internal void Clear()
{
_suppressAgentSave = true;
try
{
TaskModelSelection = null;
TaskMaxTurns = null;
TaskSystemPrompt = "";
TaskSelectedAgent = null;
}
finally
{
_suppressAgentSave = false;
}
EffectiveSystemPromptHint = "";
TaskId = null;
}
[RelayCommand] private void ResetTaskModel() => TaskModelSelection = null;
[RelayCommand] private void ResetTaskTurns() => TaskMaxTurns = null;
[RelayCommand] private void ResetTaskAgent() =>
TaskSelectedAgent = TaskAgentOptions.Count > 0 ? TaskAgentOptions[0] : null;
}

View File

@@ -9,6 +9,7 @@ using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces;
using ClaudeDo.Ui.ViewModels.Agent;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -56,7 +57,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private readonly IMergeCoordinator _merge;
// ── Section view models ───────────────────────────────────────────────────
public AgentSettingsSectionViewModel AgentSettings { get; }
public AgentConfigEditorViewModel AgentSettings { get; }
public MergeSectionViewModel Merge { get; }
public PrepPanelViewModel Prep { get; }
@@ -425,7 +426,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
_notesApi = notesApi;
_merge = merge;
AgentSettings = new AgentSettingsSectionViewModel(worker);
AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task);
Merge = new MergeSectionViewModel(worker, services);
Prep = new PrepPanelViewModel(worker);
@@ -436,7 +437,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
AgentSettings.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(AgentSettingsSectionViewModel.EffectiveMaxTurns))
if (e.PropertyName == nameof(AgentConfigEditorViewModel.EffectiveMaxTurns))
OnPropertyChanged(nameof(TurnsText));
};
@@ -685,8 +686,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
WorktreeStateLabel, _listWorkingDir);
AgentSettings.TaskId = row.Id;
await AgentSettings.LoadAsync(entity, ct);
await AgentSettings.LoadForTaskAsync(entity, ct);
ct.ThrowIfCancellationRequested();
var runRepo = new TaskRunRepository(ctx);

View File

@@ -4,6 +4,7 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Agent;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
@@ -28,25 +29,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
[ObservableProperty] private string _workingDir = "";
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
[ObservableProperty] private string? _selectedModel; // null = inherit from global
[ObservableProperty] private decimal? _maxTurns; // null = inherit from global
[ObservableProperty] private string _modelInheritedHint = ""; // resolved value placeholder, e.g. "sonnet"
[ObservableProperty] private string _modelBadge = "";
[ObservableProperty] private string _turnsInheritedHint = "";
[ObservableProperty] private string _turnsBadge = "";
[ObservableProperty] private string _agentBadge = "";
[ObservableProperty] private string _systemPrompt = "";
[ObservableProperty] private AgentInfo? _selectedAgent;
private string _globalModel = ModelRegistry.DefaultAlias;
private int _globalMaxTurns = 100;
public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
public ObservableCollection<AgentInfo> Agents { get; } = new();
// The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile),
// scoped to this list (list → global inheritance).
public AgentConfigEditorViewModel Agent { get; }
public Action? CloseAction { get; set; }
@@ -54,34 +41,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
{
_worker = worker;
_dbFactory = dbFactory;
}
partial void OnSelectedModelChanged(string? value) => RecomputeModelBadge();
partial void OnMaxTurnsChanged(decimal? value) => RecomputeTurnsBadge();
partial void OnSelectedAgentChanged(AgentInfo? value) => RecomputeAgentBadge();
private void RecomputeModelBadge()
{
ModelInheritedHint = _globalModel;
ModelBadge = !string.IsNullOrWhiteSpace(SelectedModel)
? Loc.T("settings.inherit.overrideBadge")
: Loc.T("settings.inherit.inheritedFromGlobal");
}
private void RecomputeTurnsBadge()
{
TurnsInheritedHint = _globalMaxTurns.ToString();
TurnsBadge = MaxTurns is not null
? Loc.T("settings.inherit.overrideBadge")
: Loc.T("settings.inherit.inheritedFromGlobal");
}
private void RecomputeAgentBadge()
{
var overridden = SelectedAgent is not null && !string.IsNullOrWhiteSpace(SelectedAgent.Path);
AgentBadge = overridden
? Loc.T("settings.inherit.overrideBadge")
: Loc.T("settings.inherit.inheritedFromGlobal");
Agent = new AgentConfigEditorViewModel(worker, AgentConfigScope.List);
}
public async Task LoadAsync(
@@ -96,44 +56,19 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
WorkingDir = workingDir ?? "";
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
Agents.Clear();
Agents.Add(new AgentInfo("(none)", "", ""));
var agents = await _worker.GetAgentsAsync();
foreach (var a in agents) Agents.Add(a);
var config = await _worker.GetListConfigAsync(listId);
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();
await Agent.LoadForListAsync(listId, ct);
}
[RelayCommand]
private async Task SaveAsync()
{
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));
await Agent.SaveAsync();
CloseAction?.Invoke();
}
@@ -171,17 +106,4 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
[RelayCommand]
private void Cancel() => CloseAction?.Invoke();
[RelayCommand] private void ResetModel() => SelectedModel = null;
[RelayCommand] private void ResetTurns() => MaxTurns = null;
[RelayCommand] private void ResetAgent() => SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
[RelayCommand]
private void ResetAgentSettings()
{
SelectedModel = null;
MaxTurns = null;
SystemPrompt = "";
SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
}
}

View File

@@ -0,0 +1,85 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Agent"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Controls.AgentConfigEditor"
x:DataType="vm:AgentConfigEditorViewModel"
x:Name="Root">
<StackPanel Spacing="12">
<!-- Model -->
<StackPanel Spacing="4">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.agentEditor.model}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding ModelBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetModelCommand}"/>
</Grid>
<ComboBox ItemsSource="{Binding ModelOptions}"
SelectedItem="{Binding Model, Mode=TwoWay}"
PlaceholderText="{Binding ModelInheritedHint}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<!-- Max turns -->
<StackPanel Spacing="4">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.agentEditor.maxTurns}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding TurnsBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetTurnsCommand}"/>
</Grid>
<NumericUpDown Value="{Binding MaxTurns, Mode=TwoWay}"
PlaceholderText="{Binding TurnsInheritedHint}"
Minimum="1" Maximum="200" Increment="1" FormatString="0"
HorizontalAlignment="Stretch"/>
</StackPanel>
<!-- System prompt -->
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.agentEditor.systemPrompt}"/>
<TextBox Text="{Binding SystemPrompt, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="80"/>
<TextBlock Classes="meta" Opacity="0.6"
Text="{loc:Tr settings.agentEditor.promptPrepended}"
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
Text="{Binding EffectiveSystemPromptHint}"
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<!-- Agent file -->
<StackPanel Spacing="4">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.agentEditor.agentFile}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetAgentCommand}"/>
</Grid>
<Grid ColumnDefinitions="*,Auto">
<ComboBox Grid.Column="0"
ItemsSource="{Binding Agents}"
SelectedItem="{Binding SelectedAgent, Mode=TwoWay}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Classes="title" Text="{Binding Name}"/>
<TextBlock Classes="meta" Text="{Binding Description}"
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Classes="btn" Grid.Column="1" Content="{loc:Tr settings.agentEditor.browse}"
Margin="8,0,0,0" Click="BrowseAgentClicked"
IsVisible="{Binding #Root.ShowAgentBrowse}"/>
</Grid>
<TextBlock Classes="path-mono" Text="{Binding SelectedAgent.Path}"
TextTrimming="PrefixCharacterEllipsis"
IsVisible="{Binding SelectedAgent.Path, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,75 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Agent;
namespace ClaudeDo.Ui.Views.Controls;
public partial class AgentConfigEditor : UserControl
{
// List scope shows a file picker for ad-hoc agent files; the task flyout only
// picks from discovered agents, so it leaves this off (default).
public static readonly StyledProperty<bool> ShowAgentBrowseProperty =
AvaloniaProperty.Register<AgentConfigEditor, bool>(nameof(ShowAgentBrowse));
public bool ShowAgentBrowse
{
get => GetValue(ShowAgentBrowseProperty);
set => SetValue(ShowAgentBrowseProperty, value);
}
public AgentConfigEditor() => InitializeComponent();
private async void BrowseAgentClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not AgentConfigEditorViewModel vm) return;
var top = TopLevel.GetTopLevel(this);
if (top is null) return;
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choose agent file",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("Agent files (*.md)") { Patterns = new[] { "*.md" } },
new FilePickerFileType("All files") { Patterns = new[] { "*" } },
},
});
if (files.Count == 0) return;
var path = files[0].Path.LocalPath;
var existing = vm.Agents.FirstOrDefault(a => string.Equals(a.Path, path, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
vm.SelectedAgent = existing;
return;
}
var (name, description) = ReadFrontmatter(path);
var agent = new AgentInfo(name, description, path);
vm.Agents.Add(agent);
vm.SelectedAgent = agent;
}
private static (string name, string description) ReadFrontmatter(string filePath)
{
var fallback = System.IO.Path.GetFileNameWithoutExtension(filePath);
try
{
using var reader = new System.IO.StreamReader(filePath);
if (reader.ReadLine()?.Trim() != "---") return (fallback, "");
string name = fallback, description = "";
while (reader.ReadLine() is { } line)
{
if (line.Trim() == "---") break;
if (line.StartsWith("name:")) name = line["name:".Length..].Trim();
else if (line.StartsWith("description:")) description = line["description:".Length..].Trim();
}
return (name, description);
}
catch { return (fallback, ""); }
}
}

View File

@@ -52,7 +52,7 @@
<!-- Column 2: gear button with agent settings flyout -->
<Button Grid.Column="2" Classes="icon-btn"
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
IsEnabled="{Binding AgentSettings.IsAgentSectionEnabled}"
IsEnabled="{Binding AgentSettings.IsEnabled}"
VerticalAlignment="Top"
Margin="6,0,0,0">
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
@@ -60,62 +60,7 @@
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard">
<StackPanel Width="340" Spacing="10" Margin="4">
<TextBlock Text="{loc:Tr details.agentSettingsHeading}" FontWeight="SemiBold"/>
<StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.modelLabel}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentSettings.ModelBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding AgentSettings.ResetTaskModelCommand}"/>
</Grid>
<ComboBox ItemsSource="{Binding AgentSettings.TaskModelOptions}"
SelectedItem="{Binding AgentSettings.TaskModelSelection, Mode=TwoWay}"
PlaceholderText="{Binding AgentSettings.ModelInheritedHint}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.maxTurnsLabel}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentSettings.TurnsBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding AgentSettings.ResetTaskTurnsCommand}"/>
</Grid>
<NumericUpDown Value="{Binding AgentSettings.TaskMaxTurns, Mode=TwoWay}"
PlaceholderText="{Binding AgentSettings.TurnsInheritedHint}"
Minimum="1" Maximum="200" Increment="1" FormatString="0"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="2">
<TextBlock Classes="field-label" Text="{loc:Tr details.systemPromptLabel}"/>
<TextBox Text="{Binding AgentSettings.TaskSystemPrompt, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"/>
<TextBlock Classes="meta" Opacity="0.6"
Text="{loc:Tr details.systemPromptPrepended}"
IsVisible="{Binding AgentSettings.EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
Text="{Binding AgentSettings.EffectiveSystemPromptHint}"
IsVisible="{Binding AgentSettings.EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.agentFileLabel}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentSettings.AgentBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding AgentSettings.ResetTaskAgentCommand}"/>
</Grid>
<ComboBox ItemsSource="{Binding AgentSettings.TaskAgentOptions}"
SelectedItem="{Binding AgentSettings.TaskSelectedAgent, Mode=TwoWay}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<ctl:AgentConfigEditor DataContext="{Binding AgentSettings}"/>
</StackPanel>
</Flyout>
</Button.Flyout>

View File

@@ -69,72 +69,10 @@
<Grid ColumnDefinitions="*,Auto" Margin="4,0,0,6">
<TextBlock Classes="section-label" Text="{loc:Tr modals.listSettings.sectionAgent}" Margin="0"/>
<Button Classes="btn" Grid.Column="1" Content="{loc:Tr modals.listSettings.resetAgentSettings}"
Command="{Binding ResetAgentSettingsCommand}" />
Command="{Binding Agent.ResetAllCommand}" />
</Grid>
<Border Classes="section">
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr modals.listSettings.model}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding ModelBadge}"/>
<Button Grid.Column="3" Classes="btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetModelCommand}" Padding="6,1"/>
</Grid>
<ComboBox ItemsSource="{Binding ModelOptions}"
SelectedItem="{Binding SelectedModel, Mode=TwoWay}"
PlaceholderText="{Binding ModelInheritedHint}"
HorizontalAlignment="Left" MinWidth="160" />
</StackPanel>
<StackPanel Spacing="4">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr modals.listSettings.maxTurns}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding TurnsBadge}"/>
<Button Grid.Column="3" Classes="btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetTurnsCommand}" Padding="6,1"/>
</Grid>
<NumericUpDown Value="{Binding MaxTurns, Mode=TwoWay}"
PlaceholderText="{Binding TurnsInheritedHint}"
Minimum="1" Maximum="200" Increment="1" FormatString="0"
HorizontalAlignment="Left" Width="160"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.systemPrompt}"/>
<TextBox Text="{Binding SystemPrompt, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="80" />
</StackPanel>
<StackPanel Spacing="4">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr modals.listSettings.agentFile}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentBadge}"/>
<Button Grid.Column="3" Classes="btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetAgentCommand}" Padding="6,1"/>
</Grid>
<Grid ColumnDefinitions="*,Auto">
<ComboBox Grid.Column="0"
ItemsSource="{Binding Agents}"
SelectedItem="{Binding SelectedAgent, Mode=TwoWay}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Classes="title" Text="{Binding Name}"/>
<TextBlock Classes="meta" Text="{Binding Description}"/>
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Classes="btn" Grid.Column="1" Content="{loc:Tr modals.listSettings.browse}"
Margin="8,0,0,0" Click="BrowseAgentClicked" />
</Grid>
<TextBlock Classes="path-mono" Text="{Binding SelectedAgent.Path}"
TextTrimming="PrefixCharacterEllipsis"
IsVisible="{Binding SelectedAgent.Path, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</StackPanel>
<ctl:AgentConfigEditor DataContext="{Binding Agent}" ShowAgentBrowse="True"/>
</Border>
</StackPanel>

View File

@@ -1,7 +1,6 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
@@ -13,57 +12,6 @@ public partial class ListSettingsModalView : Window
InitializeComponent();
}
private async void BrowseAgentClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not ListSettingsModalViewModel vm) return;
var top = TopLevel.GetTopLevel(this);
if (top is null) return;
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choose agent file",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("Agent files (*.md)") { Patterns = new[] { "*.md" } },
new FilePickerFileType("All files") { Patterns = new[] { "*" } },
},
});
if (files.Count == 0) return;
var path = files[0].Path.LocalPath;
var existing = vm.Agents.FirstOrDefault(a => string.Equals(a.Path, path, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
vm.SelectedAgent = existing;
return;
}
var (name, description) = ReadFrontmatter(path);
var agent = new AgentInfo(name, description, path);
vm.Agents.Add(agent);
vm.SelectedAgent = agent;
}
private static (string name, string description) ReadFrontmatter(string filePath)
{
var fallback = System.IO.Path.GetFileNameWithoutExtension(filePath);
try
{
using var reader = new System.IO.StreamReader(filePath);
if (reader.ReadLine()?.Trim() != "---") return (fallback, "");
string name = fallback, description = "";
while (reader.ReadLine() is { } line)
{
if (line.Trim() == "---") break;
if (line.StartsWith("name:")) name = line["name:".Length..].Trim();
else if (line.StartsWith("description:")) description = line["description:".Length..].Trim();
}
return (name, description);
}
catch { return (fallback, ""); }
}
private async void BrowseClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not ListSettingsModalViewModel vm) return;