diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json
index fd8a2ab1..2bf19ba6 100644
--- a/src/ClaudeDo.Localization/locales/de.json
+++ b/src/ClaudeDo.Localization/locales/de.json
@@ -16,7 +16,8 @@
"categoryFiles": "Dateien & Prompts",
"categorySkills": "Session Skills",
"categoryReports": "Berichte",
- "categoryTickets": "Externe Dienste"
+ "categoryTickets": "Externe Dienste",
+ "categorySsh": "SSH-Workspaces"
},
"repoHint": {
"text": "Wirkt erst mit verknüpftem Repo.",
@@ -162,6 +163,19 @@
"fallbackListHint": "Ticket-Items, die Claude keiner Liste zuordnen kann, landen hier. Keine = sie werden übersprungen.",
"fallbackNone": "(keine)"
},
+ "ssh": {
+ "hint": "Server, auf denen Mission Control eine interaktive Claude-Session starten kann. Host ist ein Alias aus ~/.ssh/config (User, Key und Port kommen von dort); Claude muss auf dem Server installiert und eingeloggt sein.",
+ "name": "Name",
+ "host": "Host-Alias",
+ "remotePath": "Pfad auf dem Server (/… oder ~/…)",
+ "add": "Workspace hinzufügen",
+ "remove": "Entfernen",
+ "invalid": {
+ "name": "SSH-Workspace: Name fehlt.",
+ "host": "SSH-Workspace „{0}“: Host muss ein ssh-Alias sein (Buchstaben, Ziffern, . _ @ -, nicht mit - beginnend).",
+ "path": "SSH-Workspace „{0}“: Pfad muss mit / oder ~ beginnen und darf kein ' enthalten."
+ }
+ },
"inherit": {
"inheritedFromList": "geerbt · Liste",
"inheritedFromGlobal": "geerbt · Global",
diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json
index ac705af9..928398a3 100644
--- a/src/ClaudeDo.Localization/locales/en.json
+++ b/src/ClaudeDo.Localization/locales/en.json
@@ -16,7 +16,8 @@
"categoryFiles": "Files & Prompts",
"categorySkills": "Session Skills",
"categoryReports": "Reports",
- "categoryTickets": "External services"
+ "categoryTickets": "External services",
+ "categorySsh": "SSH workspaces"
},
"repoHint": {
"text": "Only takes effect once a repo is linked.",
@@ -162,6 +163,19 @@
"fallbackListHint": "Imported ticket items Claude can't match to a list land here. None = they are skipped.",
"fallbackNone": "(none)"
},
+ "ssh": {
+ "hint": "Servers you can start an interactive Claude session on from Mission Control. Host is an alias from ~/.ssh/config (user, key and port come from there); Claude must be installed and logged in on the server.",
+ "name": "Name",
+ "host": "Host alias",
+ "remotePath": "Remote path (/… or ~/…)",
+ "add": "Add workspace",
+ "remove": "Remove",
+ "invalid": {
+ "name": "SSH workspace: name is required.",
+ "host": "SSH workspace \"{0}\": host must be an ssh alias (letters, digits, . _ @ -, not starting with -).",
+ "path": "SSH workspace \"{0}\": remote path must start with / or ~ and must not contain '."
+ }
+ },
"inherit": {
"inheritedFromList": "inherited · List",
"inheritedFromGlobal": "inherited · Global",
diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/SshSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/SshSettingsTabViewModel.cs
new file mode 100644
index 00000000..9f8d927f
--- /dev/null
+++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/SshSettingsTabViewModel.cs
@@ -0,0 +1,100 @@
+using System.Collections.ObjectModel;
+using ClaudeDo.Data.Models;
+using ClaudeDo.Ui.Localization;
+using ClaudeDo.Ui.Services;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
+
+/// Settings tab for SSH workspaces offered as remote quick sessions in Mission Control.
+public sealed partial class SshSettingsTabViewModel : ViewModelBase
+{
+ private readonly IWorkerClient _worker;
+ private readonly Func _readSshConfig;
+
+ /// Configured workspaces, edited in place.
+ public ObservableCollection Workspaces { get; } = new();
+
+ /// Host aliases from the user's ssh config, offered as suggestions.
+ public ObservableCollection Hosts { get; } = new();
+
+ /// Creates the tab; defaults to ~/.ssh/config.
+ public SshSettingsTabViewModel(IWorkerClient worker, Func? readSshConfig = null)
+ {
+ _worker = worker;
+ _readSshConfig = readSshConfig ?? (() => File.ReadAllText(Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "config")));
+ }
+
+ /// Loads workspaces from the worker and host aliases from the ssh config.
+ public async Task LoadAsync()
+ {
+ Hosts.Clear();
+ try { foreach (var h in ParseHosts(_readSshConfig())) Hosts.Add(h); }
+ catch (Exception) { /* no ssh config = no suggestions, hosts can still be typed */ }
+
+ Workspaces.Clear();
+ foreach (var w in await _worker.GetSshWorkspacesAsync())
+ Workspaces.Add(new SshWorkspaceRowViewModel(w.Id, Remove) { Name = w.Name, Host = w.Host, RemotePath = w.RemotePath });
+ }
+
+ [RelayCommand]
+ private void Add() => Workspaces.Add(new SshWorkspaceRowViewModel(Guid.NewGuid().ToString("n"), Remove));
+
+ private void Remove(SshWorkspaceRowViewModel row) => Workspaces.Remove(row);
+
+ /// Localized error for the first invalid row, or null.
+ public string? Validate()
+ {
+ foreach (var row in Workspaces)
+ if (SshWorkspace.Validate(row.ToModel()) is { } field)
+ return Loc.T($"settings.ssh.invalid.{field}", row.Name);
+ return null;
+ }
+
+ /// Persists all rows via the worker.
+ public Task SaveAsync() => _worker.SetSshWorkspacesAsync(Workspaces.Select(r => r.ToModel()).ToList());
+
+ /// Concrete Host aliases from ssh config text; wildcard patterns and Match blocks are skipped.
+ public static List ParseHosts(string configText)
+ {
+ var hosts = new List();
+ foreach (var raw in configText.Split('\n'))
+ {
+ var parts = raw.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length < 2 || !parts[0].Equals("Host", StringComparison.OrdinalIgnoreCase)) continue;
+ hosts.AddRange(parts.Skip(1).Where(p => p.IndexOfAny(['*', '?', '!']) < 0 && !hosts.Contains(p)));
+ }
+ return hosts;
+ }
+}
+
+/// One editable SSH workspace row.
+public sealed partial class SshWorkspaceRowViewModel : ObservableObject
+{
+ private readonly string _id;
+ private readonly Action _remove;
+
+ /// Display name shown in Mission Control.
+ [ObservableProperty] private string _name = "";
+
+ /// ssh config host alias.
+ [ObservableProperty] private string _host = "";
+
+ /// Absolute or ~-relative directory on the server.
+ [ObservableProperty] private string _remotePath = "";
+
+ /// Creates a row with a stable id and a callback that removes it from the owner.
+ public SshWorkspaceRowViewModel(string id, Action remove)
+ {
+ _id = id;
+ _remove = remove;
+ }
+
+ [RelayCommand]
+ private void Remove() => _remove(this);
+
+ /// Snapshot as the persisted model, trimmed.
+ public SshWorkspace ToModel() => new(_id, Name.Trim(), Host.Trim(), RemotePath.Trim());
+}
diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs
index add10d66..68bfa51c 100644
--- a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs
@@ -28,6 +28,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public OnlineInboxSettingsViewModel OnlineInbox { get; }
public SessionSkillsSettingsTabViewModel SessionSkills { get; }
public TicketSettingsTabViewModel Tickets { get; }
+ public SshSettingsTabViewModel Ssh { get; }
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
public bool ShowOnlineInbox => false;
@@ -109,6 +110,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
Tickets = new TicketSettingsTabViewModel(worker, dbFactory);
+ Ssh = new SshSettingsTabViewModel(worker);
_categories = new List
{
@@ -121,6 +123,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
new("Reports", new LocalizedString(localizer, "settings.sidebar.categoryReports"), "Erweitert", true, true),
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),
};
BasisCategories = _categories.Where(c => c.Group == "Basis").ToList();
ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList();
@@ -177,6 +180,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
await OnlineInbox.LoadAsync();
await SessionSkills.LoadAsync(dto?.SessionSkills);
await Tickets.LoadAsync();
+ await Ssh.LoadAsync();
General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling);
await using var ctx = await _dbFactory.CreateDbContextAsync();
@@ -188,7 +192,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
[RelayCommand]
private async Task Save()
{
- var err = General.Validate() ?? Worktrees.Validate() ?? Prime.Validate();
+ var err = General.Validate() ?? Worktrees.Validate() ?? Prime.Validate() ?? Ssh.Validate();
if (err is not null) { ValidationError = err; return; }
ValidationError = "";
@@ -231,6 +235,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
await Prime.SaveAsync();
await OnlineInbox.SaveAsync();
await Tickets.SaveAsync();
+ await Ssh.SaveAsync();
CloseAction?.Invoke();
}
catch (Exception ex) { StatusMessage = Loc.T("vm.settingsModal.saveFailed", ex.Message); }
diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
index 0528c412..9e0de273 100644
--- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
+++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
@@ -820,6 +820,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/SshSettingsTabViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/SshSettingsTabViewModelTests.cs
new file mode 100644
index 00000000..68b05a26
--- /dev/null
+++ b/tests/ClaudeDo.Ui.Tests/ViewModels/SshSettingsTabViewModelTests.cs
@@ -0,0 +1,77 @@
+using ClaudeDo.Data.Models;
+using ClaudeDo.Ui.ViewModels.Modals.Settings;
+
+namespace ClaudeDo.Ui.Tests.ViewModels;
+
+public sealed class SshSettingsTabViewModelTests
+{
+ private sealed class Worker : StubWorkerClient
+ {
+ public List Stored = new();
+ public override Task> GetSshWorkspacesAsync() => Task.FromResult(Stored.ToList());
+ public override Task SetSshWorkspacesAsync(List workspaces) { Stored = workspaces; return Task.CompletedTask; }
+ }
+
+ [Fact]
+ public void ParseHosts_SkipsWildcardsAndComments_SplitsMultiAlias()
+ {
+ var text = """
+ # comment
+ Host *
+ User root
+ Host vps vps-alt
+ HostName 1.2.3.4
+ host gitea
+ Host *.internal
+ Match host foo
+ """;
+ Assert.Equal(["vps", "vps-alt", "gitea"], SshSettingsTabViewModel.ParseHosts(text));
+ }
+
+ [Fact]
+ public async Task LoadAddSave_RoundTripsRows()
+ {
+ var worker = new Worker { Stored = [new SshWorkspace("w1", "VPS", "vps", "/srv/app")] };
+ var vm = new SshSettingsTabViewModel(worker, () => "Host vps");
+
+ await vm.LoadAsync();
+ Assert.Single(vm.Workspaces);
+ Assert.Equal(["vps"], vm.Hosts);
+
+ vm.AddCommand.Execute(null);
+ vm.Workspaces[1].Name = "Box";
+ vm.Workspaces[1].Host = "box";
+ vm.Workspaces[1].RemotePath = "~/code";
+ Assert.Null(vm.Validate());
+
+ await vm.SaveAsync();
+ Assert.Equal(2, worker.Stored.Count);
+ Assert.Equal("w1", worker.Stored[0].Id); // existing id preserved
+ Assert.False(string.IsNullOrEmpty(worker.Stored[1].Id)); // new row got an id
+ }
+
+ [Fact]
+ public async Task Validate_ReportsInvalidRow_AndRemoveDropsIt()
+ {
+ var vm = new SshSettingsTabViewModel(new Worker(), () => "");
+ await vm.LoadAsync();
+ vm.AddCommand.Execute(null);
+ vm.Workspaces[0].Name = "Bad";
+ vm.Workspaces[0].Host = "-oProxyCommand=calc";
+ vm.Workspaces[0].RemotePath = "/x";
+
+ Assert.NotNull(vm.Validate());
+
+ vm.Workspaces[0].RemoveCommand.Execute(null);
+ Assert.Empty(vm.Workspaces);
+ Assert.Null(vm.Validate());
+ }
+
+ [Fact]
+ public async Task MissingSshConfig_YieldsNoHosts()
+ {
+ var vm = new SshSettingsTabViewModel(new Worker(), () => throw new FileNotFoundException());
+ await vm.LoadAsync();
+ Assert.Empty(vm.Hosts);
+ }
+}