feat(mission-control): custom tiles — open any console program as a tile

Programs are configured in Settings → Custom tiles and stored in ui.config.json.
A tile with a fixed folder starts on click; without one it offers a submenu of
home, the list folders and browse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CubeGameLP
2026-10-05 20:09:19 +02:00
co-authored by Claude Opus 5.5
parent 43b03e4837
commit f37b59d58f
12 changed files with 340 additions and 32 deletions
+2 -1
View File
@@ -11,7 +11,7 @@ process hosts (real `claude` TUI in a Mission Control tile).
Autonomous queue tasks are **not** covered here — they stay on the stream-json path via Autonomous queue tasks are **not** covered here — they stay on the stream-json path via
`TaskRunner`. See [worker-task-pipeline.md](worker-task-pipeline.md). `TaskRunner`. See [worker-task-pipeline.md](worker-task-pipeline.md).
## The five session kinds ## The six session kinds
| Kind | Hub spec method | Notes | | Kind | Hub spec method | Notes |
|---|---|---| |---|---|---|
@@ -20,6 +20,7 @@ Autonomous queue tasks are **not** covered here — they stay on the stream-json
| Planning | (planning start/resume) | No `--effort`; uses `--permission-mode default`, **not** `plan` | | Planning | (planning start/resume) | No `--effort`; uses `--permission-mode default`, **not** `plan` |
| List handler | `GetMergeHelperLaunchSpec` / `GetMergeHelperHandoffLaunchSpec` | Model + effort fixed per role (`ModelRegistry.HandlerTriageAlias`/`HandlerWaitAlias`/`HandlerMergeAlias`), **not** from list config; `--permission-mode` via `PermissionModeResolver` (unattended) | | List handler | `GetMergeHelperLaunchSpec` / `GetMergeHelperHandoffLaunchSpec` | Model + effort fixed per role (`ModelRegistry.HandlerTriageAlias`/`HandlerWaitAlias`/`HandlerMergeAlias`), **not** from list config; `--permission-mode` via `PermissionModeResolver` (unattended) |
| SSH workspace | `GetSshLaunchSpec` | Exe is `ssh.exe`, not claude: `-R <random port>:127.0.0.1:<remote_mcp_port>` + one remote shell command that base64-decodes a `claudedo` MCP config (per-session token) to `~/.claudedo/mcp-<paneId>.json` and runs claude. **Not** recorded in `mission_control_sessions`, no `claudedo_pane`/handoff hint. Workspaces: `app_settings.ssh_workspaces`, Settings → SSH-Workspaces | | SSH workspace | `GetSshLaunchSpec` | Exe is `ssh.exe`, not claude: `-R <random port>:127.0.0.1:<remote_mcp_port>` + one remote shell command that base64-decodes a `claudedo` MCP config (per-session token) to `~/.claudedo/mcp-<paneId>.json` and runs claude. **Not** recorded in `mission_control_sessions`, no `claudedo_pane`/handoff hint. Workspaces: `app_settings.ssh_workspaces`, Settings → SSH-Workspaces |
| Custom tile | none — built in the UI | `MissionControlViewModel.OpenCustomTileSession`: any console program from `ui.config.json` → `CustomTiles` (Settings → Custom tiles). Exe resolved via `ExecutableResolver` (shims through `cmd /c`), args split by `CustomTile.SplitArgs` (the library re-quotes each token). Empty folder → a submenu of home/list folders/browse. Not recorded, no env, no MCP |
## ⚠️ Gotcha: never pass task free-text as a CLI argument ## ⚠️ Gotcha: never pass task free-text as a CLI argument
+18 -1
View File
@@ -17,7 +17,8 @@
"categorySkills": "Session Skills", "categorySkills": "Session Skills",
"categoryReports": "Berichte", "categoryReports": "Berichte",
"categoryTickets": "Externe Dienste", "categoryTickets": "Externe Dienste",
"categorySsh": "SSH-Workspaces" "categorySsh": "SSH-Workspaces",
"categoryCustomTiles": "Eigene Tiles"
}, },
"repoHint": { "repoHint": {
"text": "Wirkt erst mit verknüpftem Repo.", "text": "Wirkt erst mit verknüpftem Repo.",
@@ -176,6 +177,20 @@
"path": "SSH-Workspace „{0}“: Pfad muss mit / oder ~ beginnen und darf kein ' enthalten." "path": "SSH-Workspace „{0}“: Pfad muss mit / oder ~ beginnen und darf kein ' enthalten."
} }
}, },
"customTiles": {
"hint": "Konsolenprogramme, die Mission Control als Tile öffnen kann (z. B. pwsh, ssh lenovo-server, lazygit). Argumente werden an Leerzeichen getrennt; ein Argument mit Leerzeichen in Anführungszeichen setzen. Ordner leer lassen, um ihn beim Start auszuwählen.",
"name": "Name",
"program": "Programm",
"arguments": "Argumente",
"folder": "Ordner (optional)",
"pickFolder": "Ordner wählen",
"add": "Tile hinzufügen",
"remove": "Entfernen",
"invalid": {
"name": "Eigenes Tile: Name fehlt.",
"program": "Eigenes Tile „{0}“: Programm fehlt."
}
},
"inherit": { "inherit": {
"inheritedFromList": "geerbt · Liste", "inheritedFromList": "geerbt · Liste",
"inheritedFromGlobal": "geerbt · Global", "inheritedFromGlobal": "geerbt · Global",
@@ -414,6 +429,8 @@
"openSession": "Sitzung öffnen", "openSession": "Sitzung öffnen",
"homeFolder": "Home (Schnellsitzung)", "homeFolder": "Home (Schnellsitzung)",
"sshTarget": "{0} (SSH)", "sshTarget": "{0} (SSH)",
"customTarget": "{0} (eigenes Tile)",
"customProgramNotFound": "Programm „{0}“ nicht gefunden — Namen prüfen oder vollen Pfad angeben (Einstellungen → Eigene Tiles).",
"browseForFolder": "Ordner wählen…", "browseForFolder": "Ordner wählen…",
"recentSessions": "Letzte Sitzungen", "recentSessions": "Letzte Sitzungen",
"recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen", "recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen",
+18 -1
View File
@@ -17,7 +17,8 @@
"categorySkills": "Session Skills", "categorySkills": "Session Skills",
"categoryReports": "Reports", "categoryReports": "Reports",
"categoryTickets": "External services", "categoryTickets": "External services",
"categorySsh": "SSH workspaces" "categorySsh": "SSH workspaces",
"categoryCustomTiles": "Custom tiles"
}, },
"repoHint": { "repoHint": {
"text": "Only takes effect once a repo is linked.", "text": "Only takes effect once a repo is linked.",
@@ -176,6 +177,20 @@
"path": "SSH workspace \"{0}\": remote path must start with / or ~ and must not contain '." "path": "SSH workspace \"{0}\": remote path must start with / or ~ and must not contain '."
} }
}, },
"customTiles": {
"hint": "Console programs Mission Control can open as a tile (e.g. pwsh, ssh lenovo-server, lazygit). Arguments are split on spaces; quote an argument that contains one. Leave the folder empty to pick it when starting the tile.",
"name": "Name",
"program": "Program",
"arguments": "Arguments",
"folder": "Folder (optional)",
"pickFolder": "Choose folder",
"add": "Add tile",
"remove": "Remove",
"invalid": {
"name": "Custom tile: name is missing.",
"program": "Custom tile \"{0}\": program is missing."
}
},
"inherit": { "inherit": {
"inheritedFromList": "inherited · List", "inheritedFromList": "inherited · List",
"inheritedFromGlobal": "inherited · Global", "inheritedFromGlobal": "inherited · Global",
@@ -414,6 +429,8 @@
"openSession": "Open session", "openSession": "Open session",
"homeFolder": "Home (quick session)", "homeFolder": "Home (quick session)",
"sshTarget": "{0} (SSH)", "sshTarget": "{0} (SSH)",
"customTarget": "{0} (custom)",
"customProgramNotFound": "Program \"{0}\" not found — check the name or use a full path (Settings → Custom tiles).",
"browseForFolder": "Choose folder…", "browseForFolder": "Choose folder…",
"recentSessions": "Recent sessions", "recentSessions": "Recent sessions",
"recentSessionsEmpty": "No sessions in the last 14 days", "recentSessionsEmpty": "No sessions in the last 14 days",
+35
View File
@@ -19,6 +19,9 @@ public sealed class AppSettings
/// <summary>Mission Control overview column count; 0 = auto (derived from the pane count).</summary> /// <summary>Mission Control overview column count; 0 = auto (derived from the pane count).</summary>
public int MissionControlColumns { get; set; } public int MissionControlColumns { get; set; }
/// <summary>User-defined console programs offered in Mission Control's new-session flyout.</summary>
public List<CustomTile> CustomTiles { get; set; } = new();
private static readonly string DefaultConfigPath = Paths.Expand("~/.claudeDo/ui.config.json"); private static readonly string DefaultConfigPath = Paths.Expand("~/.claudeDo/ui.config.json");
/// Where this instance persists. Instance-level (not static) so tests can redirect it — /// Where this instance persists. Instance-level (not static) so tests can redirect it —
@@ -58,3 +61,35 @@ public sealed class AppSettings
File.WriteAllText(ConfigPath, json); File.WriteAllText(ConfigPath, json);
} }
} }
/// <summary>A console program the user can open as a Mission Control tile. An empty
/// <see cref="Folder"/> means the folder is picked at launch.</summary>
public sealed class CustomTile
{
public string Name { get; set; } = "";
public string Program { get; set; } = "";
public string Arguments { get; set; } = "";
public string Folder { get; set; } = "";
/// <summary>Splits an argument string into tokens: whitespace separates, double quotes group and
/// are dropped. The terminal control quotes every token itself, so the raw string can't pass through.</summary>
public static List<string> SplitArgs(string? args)
{
var tokens = new List<string>();
var current = new System.Text.StringBuilder();
bool inQuotes = false, hasToken = false;
foreach (var c in args ?? "")
{
if (c == '"') { inQuotes = !inQuotes; hasToken = true; }
else if (char.IsWhiteSpace(c) && !inQuotes)
{
if (hasToken) tokens.Add(current.ToString());
current.Clear();
hasToken = false;
}
else { current.Append(c); hasToken = true; }
}
if (hasToken) tokens.Add(current.ToString());
return tokens;
}
}
@@ -98,8 +98,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
public bool HasRecentSessions => RecentSessions.Count > 0; public bool HasRecentSessions => RecentSessions.Count > 0;
// View-layer seam: the folder picker needs a TopLevel, so the view owns it (see // View-layer seam: the folder picker needs a TopLevel, so the view owns it (see
// MissionControlView.OnNewSessionClicked). // MissionControlView.PickFolderAsync). Returns the chosen path, or null when cancelled.
public Action? BrowseForSessionRequested { get; set; } public Func<System.Threading.Tasks.Task<string?>>? PickFolderAsync { get; set; }
// View-layer seam: confirms the "abandon run" action, naming the open-task count. Wired by the // View-layer seam: confirms the "abandon run" action, naming the open-task count. Wired by the
// view the same way TasksIslandViewModel/DetailsIslandViewModel are. // view the same way TasksIslandViewModel/DetailsIslandViewModel are.
@@ -274,15 +274,16 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// Home + browse go in first so the menu stays usable even if the list query below fails. // Home + browse go in first so the menu stays usable even if the list query below fails.
// Home is the target for quick general questions that belong to no project. // Home is the target for quick general questions that belong to no project.
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var folders = new List<(string Label, string Dir)> { (Loc.T("missionControl.homeFolder"), home) };
SessionTargets.Add(new SessionTargetViewModel SessionTargets.Add(new SessionTargetViewModel
{ {
Label = Loc.T("missionControl.homeFolder"), Label = folders[0].Label,
StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(home)), StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(home)),
}); });
SessionTargets.Add(new SessionTargetViewModel SessionTargets.Add(new SessionTargetViewModel
{ {
Label = Loc.T("missionControl.browseForFolder"), Label = Loc.T("missionControl.browseForFolder"),
StartCommand = new RelayCommand(() => BrowseForSessionRequested?.Invoke()), StartCommand = new RelayCommand(() => _ = BrowseAndOpenAsync(OpenAdHocConPtySessionAsync)),
}); });
try try
{ {
@@ -296,6 +297,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
foreach (var l in lists) foreach (var l in lists)
{ {
var dir = l.WorkingDir!; var dir = l.WorkingDir!;
folders.Add((l.Name, dir));
SessionTargets.Add(new SessionTargetViewModel SessionTargets.Add(new SessionTargetViewModel
{ {
Label = l.Name, Label = l.Name,
@@ -314,6 +316,65 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
} }
} }
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
// Outside the try: custom tiles come from ui.config.json and still work without the DB.
foreach (var tile in _settings?.CustomTiles ?? [])
{
var t = tile;
var label = Loc.T("missionControl.customTarget", t.Name);
if (!string.IsNullOrWhiteSpace(t.Folder))
{
SessionTargets.Add(new SessionTargetViewModel
{
Label = label,
StartCommand = new RelayCommand(() => OpenCustomTileSession(t, t.Folder)),
});
continue;
}
var children = folders
.Select(f => new SessionTargetViewModel
{
Label = f.Label,
StartCommand = new RelayCommand(() => OpenCustomTileSession(t, f.Dir)),
})
.Append(new SessionTargetViewModel
{
Label = Loc.T("missionControl.browseForFolder"),
StartCommand = new RelayCommand(() => _ = BrowseAndOpenAsync(dir =>
{
OpenCustomTileSession(t, dir);
return System.Threading.Tasks.Task.CompletedTask;
})),
})
.ToList();
SessionTargets.Add(new SessionTargetViewModel { Label = label, Children = children });
}
}
private async System.Threading.Tasks.Task BrowseAndOpenAsync(Func<string, System.Threading.Tasks.Task> open)
{
if (PickFolderAsync is null) return;
if (await PickFolderAsync() is { Length: > 0 } dir) await open(dir);
}
/// <summary>Opens a user-defined program from ui.config.json as a tile. Never deduped, never
/// recorded for resume — the UI builds the launch itself, the worker is not involved.</summary>
internal void OpenCustomTileSession(CustomTile tile, string directory)
{
var paneId = Guid.NewGuid().ToString("n");
AddConPtyPane(new ConPtyPaneViewModel(null, tile.Name, () =>
{
// Throws into the pane's start-failure line, same as a worker-side launch error.
var resolved = ClaudeDo.Data.Environment.ExecutableResolver.Resolve(tile.Program.Trim())
?? throw new InvalidOperationException(Loc.T("missionControl.customProgramNotFound", tile.Program));
var args = CustomTile.SplitArgs(tile.Arguments);
// .cmd/.bat shims can't be the process image of a pseudo console; run them through cmd.
var (exe, argv) = resolved.IsShim
? ("cmd.exe", (IReadOnlyList<string>)new[] { "/c", resolved.Path }.Concat(args).ToList())
: (resolved.Path, args);
return System.Threading.Tasks.Task.FromResult(new TerminalLaunchDescriptor(
directory, exe, argv, new Dictionary<string, string>()));
}) { IdentityKey = paneId });
} }
/// <summary>Rebuilds <see cref="RecentSessions"/> from the last 14 days (max 50 sessions); runs on /// <summary>Rebuilds <see cref="RecentSessions"/> from the last 14 days (max 50 sessions); runs on
@@ -1013,9 +1074,11 @@ public sealed partial class RecentSessionViewModel : ObservableObject
public bool HasBrief => ShowBriefCommand is not null; public bool HasBrief => ShowBriefCommand is not null;
} }
/// <summary>One entry in the "new session" menu: a configured list's working dir, or browse.</summary> /// <summary>One entry in the "new session" menu: a folder to start in, browse, an SSH workspace, or a
/// custom tile — which, without a fixed folder, opens a submenu of folders instead of starting.</summary>
public sealed class SessionTargetViewModel public sealed class SessionTargetViewModel
{ {
public required string Label { get; init; } public required string Label { get; init; }
public required IRelayCommand StartCommand { get; init; } public IRelayCommand? StartCommand { get; init; }
public IReadOnlyList<SessionTargetViewModel>? Children { get; init; }
} }
@@ -0,0 +1,69 @@
using System.Collections.ObjectModel;
using ClaudeDo.Ui.Localization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
/// <summary>Settings tab for user-defined programs offered as Mission Control tiles. Persists to
/// ui.config.json (a UI-only preference), so it works without the worker.</summary>
public sealed partial class CustomTilesSettingsTabViewModel : ViewModelBase
{
private readonly AppSettings _settings;
/// <summary>Configured tiles, edited in place.</summary>
public ObservableCollection<CustomTileRowViewModel> Tiles { get; } = new();
public CustomTilesSettingsTabViewModel(AppSettings settings)
{
_settings = settings;
foreach (var t in settings.CustomTiles)
Tiles.Add(new CustomTileRowViewModel(Remove) { Name = t.Name, Program = t.Program, Arguments = t.Arguments, Folder = t.Folder });
}
[RelayCommand]
private void Add() => Tiles.Add(new CustomTileRowViewModel(Remove));
private void Remove(CustomTileRowViewModel row) => Tiles.Remove(row);
/// <summary>Localized error for the first row missing its name or program, or null.</summary>
public string? Validate()
{
foreach (var row in Tiles)
{
if (string.IsNullOrWhiteSpace(row.Name)) return Loc.T("settings.customTiles.invalid.name");
if (string.IsNullOrWhiteSpace(row.Program)) return Loc.T("settings.customTiles.invalid.program", row.Name);
}
return null;
}
public void Save()
{
_settings.CustomTiles = Tiles.Select(r => r.ToModel()).ToList();
_settings.Save();
}
}
/// <summary>One editable custom tile row.</summary>
public sealed partial class CustomTileRowViewModel : ObservableObject
{
private readonly Action<CustomTileRowViewModel> _remove;
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _program = "";
[ObservableProperty] private string _arguments = "";
/// <summary>Start folder; empty = picked from a submenu at launch.</summary>
[ObservableProperty] private string _folder = "";
public CustomTileRowViewModel(Action<CustomTileRowViewModel> remove) => _remove = remove;
[RelayCommand]
private void Remove() => _remove(this);
/// <summary>Snapshot as the persisted model, trimmed.</summary>
public CustomTile ToModel() => new()
{
Name = Name.Trim(), Program = Program.Trim(), Arguments = Arguments.Trim(), Folder = Folder.Trim(),
};
}
@@ -29,6 +29,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public SessionSkillsSettingsTabViewModel SessionSkills { get; } public SessionSkillsSettingsTabViewModel SessionSkills { get; }
public TicketSettingsTabViewModel Tickets { get; } public TicketSettingsTabViewModel Tickets { get; }
public SshSettingsTabViewModel Ssh { get; } public SshSettingsTabViewModel Ssh { get; }
public CustomTilesSettingsTabViewModel CustomTiles { get; }
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung. // Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
public bool ShowOnlineInbox => false; public bool ShowOnlineInbox => false;
@@ -111,6 +112,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
SessionSkills = new SessionSkillsSettingsTabViewModel(worker); SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
Tickets = new TicketSettingsTabViewModel(worker, dbFactory); Tickets = new TicketSettingsTabViewModel(worker, dbFactory);
Ssh = new SshSettingsTabViewModel(worker); Ssh = new SshSettingsTabViewModel(worker);
CustomTiles = new CustomTilesSettingsTabViewModel(appSettings);
_categories = new List<SettingsCategory> _categories = new List<SettingsCategory>
{ {
@@ -124,6 +126,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
new("OnlineInbox", new LocalizedString(localizer, "settings.onlineInbox.tabHeader"), "Erweitert", ShowOnlineInbox, false), new("OnlineInbox", new LocalizedString(localizer, "settings.onlineInbox.tabHeader"), "Erweitert", ShowOnlineInbox, false),
new("Tickets", new LocalizedString(localizer, "settings.sidebar.categoryTickets"), "Erweitert", TicketSettingsTabViewModel.FeatureEnabled, false), new("Tickets", new LocalizedString(localizer, "settings.sidebar.categoryTickets"), "Erweitert", TicketSettingsTabViewModel.FeatureEnabled, false),
new("Ssh", new LocalizedString(localizer, "settings.sidebar.categorySsh"), "Erweitert", true, false), new("Ssh", new LocalizedString(localizer, "settings.sidebar.categorySsh"), "Erweitert", true, false),
new("CustomTiles", new LocalizedString(localizer, "settings.sidebar.categoryCustomTiles"), "Erweitert", true, false),
}; };
BasisCategories = _categories.Where(c => c.Group == "Basis").ToList(); BasisCategories = _categories.Where(c => c.Group == "Basis").ToList();
ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList(); ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList();
@@ -192,9 +195,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private async Task Save() private async Task Save()
{ {
var err = General.Validate() ?? Worktrees.Validate() ?? Prime.Validate() ?? Ssh.Validate(); var err = General.Validate() ?? Worktrees.Validate() ?? Prime.Validate() ?? Ssh.Validate() ?? CustomTiles.Validate();
if (err is not null) { ValidationError = err; return; } if (err is not null) { ValidationError = err; return; }
ValidationError = ""; ValidationError = "";
// ui.config.json, not the worker — saved first so an offline worker can't lose the edit.
CustomTiles.Save();
IsBusy = true; IsBusy = true;
try try
@@ -16,6 +16,16 @@
<mc:ConPtyPaneHost Margin="6" /> <mc:ConPtyPaneHost Margin="6" />
</DataTemplate> </DataTemplate>
</UserControl.DataTemplates> </UserControl.DataTemplates>
<UserControl.Resources>
<!-- Keyed so a custom tile's folder submenu reuses it for its own items (one level deep). -->
<ControlTheme x:Key="SessionTargetItemTheme" TargetType="MenuItem" BasedOn="{StaticResource {x:Type MenuItem}}"
x:DataType="vm:SessionTargetViewModel">
<Setter Property="Header" Value="{Binding Label}"/>
<Setter Property="Command" Value="{Binding StartCommand}"/>
<Setter Property="ItemsSource" Value="{Binding Children}"/>
<Setter Property="ItemContainerTheme" Value="{DynamicResource SessionTargetItemTheme}"/>
</ControlTheme>
</UserControl.Resources>
<UserControl.Styles> <UserControl.Styles>
<!-- Drop target outline while dragging a tile by its header; matches the pane's Margin/CornerRadius. --> <!-- Drop target outline while dragging a tile by its header; matches the pane's Margin/CornerRadius. -->
<Style Selector="Border.pane-drop-hint"> <Style Selector="Border.pane-drop-hint">
@@ -47,15 +57,8 @@
ToolTip.Tip="{loc:Tr missionControl.newSession}"> ToolTip.Tip="{loc:Tr missionControl.newSession}">
<PathIcon Width="16" Height="16" Data="{Binding Source='mdi:Plus', Converter={StaticResource IconKey}}"/> <PathIcon Width="16" Height="16" Data="{Binding Source='mdi:Plus', Converter={StaticResource IconKey}}"/>
<Button.Flyout> <Button.Flyout>
<MenuFlyout ItemsSource="{Binding SessionTargets}" Opening="OnSessionFlyoutOpening"> <MenuFlyout ItemsSource="{Binding SessionTargets}" Opening="OnSessionFlyoutOpening"
<MenuFlyout.ItemContainerTheme> ItemContainerTheme="{StaticResource SessionTargetItemTheme}"/>
<ControlTheme TargetType="MenuItem" BasedOn="{StaticResource {x:Type MenuItem}}"
x:DataType="vm:SessionTargetViewModel">
<Setter Property="Header" Value="{Binding Label}"/>
<Setter Property="Command" Value="{Binding StartCommand}"/>
</ControlTheme>
</MenuFlyout.ItemContainerTheme>
</MenuFlyout>
</Button.Flyout> </Button.Flyout>
</Button> </Button>
<Button x:Name="RecentSessionsButton" Classes="icon-btn" <Button x:Name="RecentSessionsButton" Classes="icon-btn"
@@ -149,21 +149,18 @@ public partial class MissionControlView : UserControl
private void OnRecentSessionsFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshRecentSessionsAsync(); private void OnRecentSessionsFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshRecentSessionsAsync();
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path. // The view owns the folder picker (it needs a TopLevel); the VM decides what to open with the path.
private async void BrowseForSessionAsync() private async System.Threading.Tasks.Task<string?> PickFolderAsync()
{ {
if (DataContext is not MissionControlViewModel vm) return;
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return; if (topLevel is null) return null;
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{ {
Title = "Choose a directory", Title = "Choose a directory",
AllowMultiple = false, AllowMultiple = false,
}); });
if (folders.Count == 0) return; return folders.Count == 0 ? null : folders[0].Path.LocalPath;
await vm.OpenAdHocConPtySessionAsync(folders[0].Path.LocalPath);
} }
private void OnDataContextChanged(object? sender, EventArgs e) private void OnDataContextChanged(object? sender, EventArgs e)
@@ -172,7 +169,7 @@ public partial class MissionControlView : UserControl
{ {
_vm.Panes.CollectionChanged -= OnPanesChanged; _vm.Panes.CollectionChanged -= OnPanesChanged;
_vm.PropertyChanged -= OnViewModelPropertyChanged; _vm.PropertyChanged -= OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = null; _vm.PickFolderAsync = null;
_vm.ConfirmAsync = null; _vm.ConfirmAsync = null;
} }
@@ -182,7 +179,7 @@ public partial class MissionControlView : UserControl
{ {
_vm.Panes.CollectionChanged += OnPanesChanged; _vm.Panes.CollectionChanged += OnPanesChanged;
_vm.PropertyChanged += OnViewModelPropertyChanged; _vm.PropertyChanged += OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = BrowseForSessionAsync; _vm.PickFolderAsync = PickFolderAsync;
_vm.ConfirmAsync = ShowConfirmAsync; _vm.ConfirmAsync = ShowConfirmAsync;
} }
@@ -852,6 +852,42 @@
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<TabItem Header="{loc:Tr settings.sidebar.categoryCustomTiles}">
<ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0">
<TextBlock Classes="meta" Text="{loc:Tr settings.customTiles.hint}" TextWrapping="Wrap" Opacity="0.6"/>
<ItemsControl ItemsSource="{Binding CustomTiles.Tiles}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="settings:CustomTileRowViewModel">
<Grid ColumnDefinitions="120,120,*,*,Auto,Auto" ColumnSpacing="8" Margin="0,0,0,6">
<TextBox Grid.Column="0" Text="{Binding Name, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.customTiles.name}"/>
<TextBox Grid.Column="1" Text="{Binding Program, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.customTiles.program}"/>
<TextBox Grid.Column="2" Text="{Binding Arguments, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.customTiles.arguments}"/>
<TextBox Grid.Column="3" Text="{Binding Folder, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.customTiles.folder}"/>
<Button Grid.Column="4" Classes="icon-btn" Click="OnPickCustomTileFolder"
ToolTip.Tip="{loc:Tr settings.customTiles.pickFolder}">
<PathIcon Width="14" Height="14" Data="{Binding Source='mdi:FolderOpenOutline', Converter={StaticResource IconKey}}"/>
</Button>
<Button Grid.Column="5" Classes="icon-btn" Command="{Binding RemoveCommand}"
ToolTip.Tip="{loc:Tr settings.customTiles.remove}">
<PathIcon Width="14" Height="14" Data="{Binding Source='mdi:TrashCanOutline', Converter={StaticResource IconKey}}"/>
</Button>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="btn" HorizontalAlignment="Left" Command="{Binding CustomTiles.AddCommand}"
Content="{loc:Tr settings.customTiles.add}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl> </TabControl>
</Grid> </Grid>
</DockPanel> </DockPanel>
@@ -1,5 +1,8 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
namespace ClaudeDo.Ui.Views.Modals; namespace ClaudeDo.Ui.Views.Modals;
@@ -8,7 +11,7 @@ public partial class SettingsModalView : Window
public SettingsModalView() public SettingsModalView()
{ {
InitializeComponent(); InitializeComponent();
} }
protected override void OnDataContextChanged(EventArgs e) protected override void OnDataContextChanged(EventArgs e)
@@ -17,4 +20,11 @@ public partial class SettingsModalView : Window
if (DataContext is SettingsModalViewModel vm) if (DataContext is SettingsModalViewModel vm)
vm.CloseAction = Close; vm.CloseAction = Close;
} }
private async void OnPickCustomTileFolder(object? sender, RoutedEventArgs e)
{
if ((sender as Control)?.DataContext is not CustomTileRowViewModel row) return;
var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { AllowMultiple = false });
if (folders.Count > 0) row.Folder = folders[0].Path.LocalPath;
}
} }
@@ -321,7 +321,7 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(4, vm.SessionTargets.Count); // home, browse, Alpha, VPS Assert.Equal(4, vm.SessionTargets.Count); // home, browse, Alpha, VPS
Assert.Contains("VPS", vm.SessionTargets[3].Label); Assert.Contains("VPS", vm.SessionTargets[3].Label);
vm.SessionTargets[3].StartCommand.Execute(null); vm.SessionTargets[3].StartCommand!.Execute(null);
var pane = Assert.Single(vm.ConPtySessions); var pane = Assert.Single(vm.ConPtySessions);
Assert.Equal("VPS", pane.DisplayTitle); Assert.Equal("VPS", pane.DisplayTitle);
} }
@@ -339,7 +339,7 @@ public class MissionControlViewModelTests : IDisposable
using var vm = BuildVm(new FakeWorker()); using var vm = BuildVm(new FakeWorker());
var browsed = false; var browsed = false;
vm.BrowseForSessionRequested = () => browsed = true; vm.PickFolderAsync = () => { browsed = true; return Task.FromResult<string?>(null); };
await vm.RefreshSessionTargetsAsync(); await vm.RefreshSessionTargetsAsync();
@@ -347,10 +347,65 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(4, vm.SessionTargets.Count); Assert.Equal(4, vm.SessionTargets.Count);
Assert.Equal(new[] { "Beta", "Alpha" }, vm.SessionTargets.Skip(2).Select(t => t.Label).ToArray()); Assert.Equal(new[] { "Beta", "Alpha" }, vm.SessionTargets.Skip(2).Select(t => t.Label).ToArray());
vm.SessionTargets[1].StartCommand.Execute(null); vm.SessionTargets[1].StartCommand!.Execute(null);
Assert.True(browsed); Assert.True(browsed);
} }
[Fact]
public async Task RefreshSessionTargetsAsync_CustomTiles_FixedFolderStartsDirectly_EmptyFolderOffersFolderSubmenu()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Alpha", WorkingDir = @"C:\repos\alpha", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var settings = new AppSettings { ConfigPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("n") + ".json") };
settings.CustomTiles.Add(new CustomTile { Name = "Shell", Program = "cmd", Folder = @"C:\" });
settings.CustomTiles.Add(new CustomTile { Name = "Git", Program = "cmd" });
var worker = new FakeWorker();
using var vm = new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker), settings);
await vm.RefreshSessionTargetsAsync();
// Home, browse, Alpha, then the two custom tiles.
var fixedTile = vm.SessionTargets[3];
var askTile = vm.SessionTargets[4];
Assert.NotNull(fixedTile.StartCommand);
Assert.Null(fixedTile.Children);
Assert.Null(askTile.StartCommand);
// Same folder choice as the top level: home, the list folders, then browse.
Assert.Equal(3, askTile.Children!.Count);
Assert.Equal("Alpha", askTile.Children[1].Label);
askTile.Children[1].StartCommand!.Execute(null);
var pane = Assert.Single(vm.ConPtySessions);
Assert.Equal("Git", pane.DisplayTitle);
}
[Fact]
public void CustomTile_SplitArgs_SplitsOnWhitespace_QuotesGroup()
{
Assert.Equal(new[] { "lenovo-server", "-p", "56789" }, CustomTile.SplitArgs("lenovo-server -p 56789 "));
Assert.Equal(new[] { "-c", "git log --oneline", "" }, CustomTile.SplitArgs("-c \"git log --oneline\" \"\""));
Assert.Empty(CustomTile.SplitArgs(null));
}
[Fact]
public void AppSettings_CustomTiles_RoundTripThroughConfigFile()
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("n") + ".json");
try
{
var settings = new AppSettings { ConfigPath = path };
settings.CustomTiles.Add(new CustomTile { Name = "Server", Program = "ssh", Arguments = "lenovo-server" });
settings.Save();
var tile = Assert.Single(AppSettings.Load(path).CustomTiles);
Assert.Equal(("Server", "ssh", "lenovo-server", ""), (tile.Name, tile.Program, tile.Arguments, tile.Folder));
}
finally { File.Delete(path); }
}
private async Task SeedQueueAsync() private async Task SeedQueueAsync()
{ {
await using var db = NewContext(); await using var db = NewContext();