From f37b59d58fbe37c5aa7e337f0cc530241cb05155 Mon Sep 17 00:00:00 2001 From: CubeGameLP <126233386+CubeGameLP@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:09:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(mission-control):=20custom=20tiles=20?= =?UTF-8?q?=E2=80=94=20open=20any=20console=20program=20as=20a=20tile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/explore-notes/conpty-sessions.md | 3 +- src/ClaudeDo.Localization/locales/de.json | 19 ++++- src/ClaudeDo.Localization/locales/en.json | 19 ++++- src/ClaudeDo.Ui/AppSettings.cs | 35 +++++++++ .../ViewModels/MissionControlViewModel.cs | 75 +++++++++++++++++-- .../CustomTilesSettingsTabViewModel.cs | 69 +++++++++++++++++ .../Modals/SettingsModalViewModel.cs | 7 +- .../MissionControl/MissionControlView.axaml | 21 +++--- .../MissionControlView.axaml.cs | 15 ++-- .../Views/Modals/SettingsModalView.axaml | 36 +++++++++ .../Views/Modals/SettingsModalView.axaml.cs | 12 ++- .../MissionControlViewModelTests.cs | 61 ++++++++++++++- 12 files changed, 340 insertions(+), 32 deletions(-) create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/Settings/CustomTilesSettingsTabViewModel.cs diff --git a/docs/explore-notes/conpty-sessions.md b/docs/explore-notes/conpty-sessions.md index 8240c2b6..7ada0b68 100644 --- a/docs/explore-notes/conpty-sessions.md +++ b/docs/explore-notes/conpty-sessions.md @@ -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 `TaskRunner`. See [worker-task-pipeline.md](worker-task-pipeline.md). -## The five session kinds +## The six session kinds | 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` | | 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 :127.0.0.1:` + one remote shell command that base64-decodes a `claudedo` MCP config (per-session token) to `~/.claudedo/mcp-.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 diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index ddbfc14a..356c16c7 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -17,7 +17,8 @@ "categorySkills": "Session Skills", "categoryReports": "Berichte", "categoryTickets": "Externe Dienste", - "categorySsh": "SSH-Workspaces" + "categorySsh": "SSH-Workspaces", + "categoryCustomTiles": "Eigene Tiles" }, "repoHint": { "text": "Wirkt erst mit verknüpftem Repo.", @@ -176,6 +177,20 @@ "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": { "inheritedFromList": "geerbt · Liste", "inheritedFromGlobal": "geerbt · Global", @@ -414,6 +429,8 @@ "openSession": "Sitzung öffnen", "homeFolder": "Home (Schnellsitzung)", "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…", "recentSessions": "Letzte Sitzungen", "recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 36262cc9..414150b4 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -17,7 +17,8 @@ "categorySkills": "Session Skills", "categoryReports": "Reports", "categoryTickets": "External services", - "categorySsh": "SSH workspaces" + "categorySsh": "SSH workspaces", + "categoryCustomTiles": "Custom tiles" }, "repoHint": { "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 '." } }, + "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": { "inheritedFromList": "inherited · List", "inheritedFromGlobal": "inherited · Global", @@ -414,6 +429,8 @@ "openSession": "Open session", "homeFolder": "Home (quick session)", "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…", "recentSessions": "Recent sessions", "recentSessionsEmpty": "No sessions in the last 14 days", diff --git a/src/ClaudeDo.Ui/AppSettings.cs b/src/ClaudeDo.Ui/AppSettings.cs index 29ecbb89..e4e7831c 100644 --- a/src/ClaudeDo.Ui/AppSettings.cs +++ b/src/ClaudeDo.Ui/AppSettings.cs @@ -19,6 +19,9 @@ public sealed class AppSettings /// Mission Control overview column count; 0 = auto (derived from the pane count). public int MissionControlColumns { get; set; } + /// User-defined console programs offered in Mission Control's new-session flyout. + public List CustomTiles { get; set; } = new(); + private static readonly string DefaultConfigPath = Paths.Expand("~/.claudeDo/ui.config.json"); /// 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); } } + +/// A console program the user can open as a Mission Control tile. An empty +/// means the folder is picked at launch. +public sealed class CustomTile +{ + public string Name { get; set; } = ""; + public string Program { get; set; } = ""; + public string Arguments { get; set; } = ""; + public string Folder { get; set; } = ""; + + /// 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. + public static List SplitArgs(string? args) + { + var tokens = new List(); + 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; + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index d88c0614..ff1d3c52 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -98,8 +98,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable public bool HasRecentSessions => RecentSessions.Count > 0; // View-layer seam: the folder picker needs a TopLevel, so the view owns it (see - // MissionControlView.OnNewSessionClicked). - public Action? BrowseForSessionRequested { get; set; } + // MissionControlView.PickFolderAsync). Returns the chosen path, or null when cancelled. + public Func>? PickFolderAsync { get; set; } // View-layer seam: confirms the "abandon run" action, naming the open-task count. Wired by the // 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 is the target for quick general questions that belong to no project. var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var folders = new List<(string Label, string Dir)> { (Loc.T("missionControl.homeFolder"), home) }; SessionTargets.Add(new SessionTargetViewModel { - Label = Loc.T("missionControl.homeFolder"), + Label = folders[0].Label, StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(home)), }); SessionTargets.Add(new SessionTargetViewModel { Label = Loc.T("missionControl.browseForFolder"), - StartCommand = new RelayCommand(() => BrowseForSessionRequested?.Invoke()), + StartCommand = new RelayCommand(() => _ = BrowseAndOpenAsync(OpenAdHocConPtySessionAsync)), }); try { @@ -296,6 +297,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable foreach (var l in lists) { var dir = l.WorkingDir!; + folders.Add((l.Name, dir)); SessionTargets.Add(new SessionTargetViewModel { 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)); } + + // 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 open) + { + if (PickFolderAsync is null) return; + if (await PickFolderAsync() is { Length: > 0 } dir) await open(dir); + } + + /// 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. + 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)new[] { "/c", resolved.Path }.Concat(args).ToList()) + : (resolved.Path, args); + return System.Threading.Tasks.Task.FromResult(new TerminalLaunchDescriptor( + directory, exe, argv, new Dictionary())); + }) { IdentityKey = paneId }); } /// Rebuilds 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; } -/// One entry in the "new session" menu: a configured list's working dir, or browse. +/// 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. public sealed class SessionTargetViewModel { public required string Label { get; init; } - public required IRelayCommand StartCommand { get; init; } + public IRelayCommand? StartCommand { get; init; } + public IReadOnlyList? Children { get; init; } } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/CustomTilesSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/CustomTilesSettingsTabViewModel.cs new file mode 100644 index 00000000..bae3acb3 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/CustomTilesSettingsTabViewModel.cs @@ -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; + +/// 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. +public sealed partial class CustomTilesSettingsTabViewModel : ViewModelBase +{ + private readonly AppSettings _settings; + + /// Configured tiles, edited in place. + public ObservableCollection 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); + + /// Localized error for the first row missing its name or program, or null. + 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(); + } +} + +/// One editable custom tile row. +public sealed partial class CustomTileRowViewModel : ObservableObject +{ + private readonly Action _remove; + + [ObservableProperty] private string _name = ""; + [ObservableProperty] private string _program = ""; + [ObservableProperty] private string _arguments = ""; + + /// Start folder; empty = picked from a submenu at launch. + [ObservableProperty] private string _folder = ""; + + public CustomTileRowViewModel(Action remove) => _remove = remove; + + [RelayCommand] + private void Remove() => _remove(this); + + /// Snapshot as the persisted model, trimmed. + public CustomTile ToModel() => new() + { + Name = Name.Trim(), Program = Program.Trim(), Arguments = Arguments.Trim(), Folder = Folder.Trim(), + }; +} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs index 68bfa51c..3e1ec3ef 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs @@ -29,6 +29,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase public SessionSkillsSettingsTabViewModel SessionSkills { get; } public TicketSettingsTabViewModel Tickets { get; } public SshSettingsTabViewModel Ssh { get; } + public CustomTilesSettingsTabViewModel CustomTiles { get; } // Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung. public bool ShowOnlineInbox => false; @@ -111,6 +112,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase SessionSkills = new SessionSkillsSettingsTabViewModel(worker); Tickets = new TicketSettingsTabViewModel(worker, dbFactory); Ssh = new SshSettingsTabViewModel(worker); + CustomTiles = new CustomTilesSettingsTabViewModel(appSettings); _categories = new List { @@ -124,6 +126,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase new("OnlineInbox", new LocalizedString(localizer, "settings.onlineInbox.tabHeader"), "Erweitert", ShowOnlineInbox, 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("CustomTiles", new LocalizedString(localizer, "settings.sidebar.categoryCustomTiles"), "Erweitert", true, false), }; BasisCategories = _categories.Where(c => c.Group == "Basis").ToList(); ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList(); @@ -192,9 +195,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase [RelayCommand] 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; } ValidationError = ""; + // ui.config.json, not the worker — saved first so an offline worker can't lose the edit. + CustomTiles.Save(); IsBusy = true; try diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml index f47160cf..f8dbc755 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml @@ -16,6 +16,16 @@ + + + + + + + + +