diff --git a/src/ClaudeDo.Ui/Services/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/IWorkerClient.cs index 347e90d..c03389c 100644 --- a/src/ClaudeDo.Ui/Services/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/IWorkerClient.cs @@ -28,6 +28,7 @@ public interface IWorkerClient : INotifyPropertyChanged Task GetListConfigAsync(string listId); Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto); Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default); + Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default); Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default); Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default); Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index e8c096c..06b550f 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -386,6 +386,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => await _hub.InvokeAsync("ResumePlanningSessionAsync", taskId, ct); + public async Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) + => await _hub.InvokeAsync("OpenInteractiveTerminalAsync", taskId, ct); + public async Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index c2ddfcd..4547958 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -516,6 +516,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase catch { } } + [RelayCommand] + private async Task RunInteractivelyAsync(TaskRowViewModel? row) + { + if (row is null || _worker is null) return; + ForegroundHelper.AllowAny(); + try { await _worker.OpenInteractiveTerminalAsync(row.Id); } + catch { } + } + [RelayCommand] private async Task ResumePlanningSessionAsync(TaskRowViewModel? row) { diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml index 0c701ef..14cc299 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml @@ -38,6 +38,8 @@ IsVisible="{Binding IsQueued}" Click="OnRemoveFromQueueClick"/> + diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs index 0d350c8..023f886 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs @@ -45,6 +45,12 @@ public partial class TaskRowView : UserControl await vm.OpenPlanningSessionCommand.ExecuteAsync(row); } + private async void OnRunInteractivelyClick(object? sender, RoutedEventArgs e) + { + if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) + await vm.RunInteractivelyCommand.ExecuteAsync(row); + } + private async void OnResumePlanningSessionClick(object? sender, RoutedEventArgs e) { if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index f2176af..d1d4479 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -346,6 +346,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub return ctx; } + public async Task OpenInteractiveTerminalAsync(string taskId) + { + var ctx = await _planning.OpenInteractiveAsync(taskId, Context.ConnectionAborted); + await _launcher.LaunchInteractiveAsync(ctx, Context.ConnectionAborted); + } + public async Task DiscardPlanningSessionAsync(string taskId) { await _planning.DiscardAsync(taskId, Context.ConnectionAborted); diff --git a/src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs index ddbe016..3b0d83c 100644 --- a/src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs +++ b/src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs @@ -4,6 +4,7 @@ public interface IPlanningTerminalLauncher { Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken); Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken); + Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken); } public sealed class PlanningLaunchException : Exception diff --git a/src/ClaudeDo.Worker/Planning/InteractiveLaunchContext.cs b/src/ClaudeDo.Worker/Planning/InteractiveLaunchContext.cs new file mode 100644 index 0000000..b49317d --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/InteractiveLaunchContext.cs @@ -0,0 +1,6 @@ +namespace ClaudeDo.Worker.Planning; + +public sealed record InteractiveLaunchContext( + string TaskId, + string WorkingDir, + string InitialPrompt); diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs index 2af7d06..2d82d8d 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -138,6 +138,40 @@ public sealed class PlanningSessionManager Files: files); } + public async Task OpenInteractiveAsync(string taskId, CancellationToken ct) + { + var (tasks, lists, _, ctx) = CreateRepos(); + await using var __ = ctx; + + var task = await tasks.GetByIdAsync(taskId, ct) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + + var list = await lists.GetByIdAsync(task.ListId, ct) + ?? throw new InvalidOperationException($"List {task.ListId} not found."); + + var workingDir = list.WorkingDir; + if (string.IsNullOrWhiteSpace(workingDir) || !Directory.Exists(workingDir)) + throw new InvalidOperationException( + $"List '{list.Name}' has no valid working directory configured."); + + return new InteractiveLaunchContext( + TaskId: taskId, + WorkingDir: workingDir, + InitialPrompt: BuildInteractivePrompt(task)); + } + + private static string BuildInteractivePrompt(TaskEntity task) + { + var sb = new StringBuilder(); + sb.AppendLine($"# Task: {task.Title}"); + if (!string.IsNullOrWhiteSpace(task.Description)) + { + sb.AppendLine(); + sb.AppendLine(task.Description); + } + return sb.ToString(); + } + public async Task FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct) { var (tasks, lists, settings, ctx) = CreateRepos(); @@ -287,25 +321,31 @@ public sealed class PlanningSessionManager } """; - private static string BuildSystemPrompt() => - """ - You are a planning assistant for ClaudeDo. - Your role is to help break down a task into smaller, actionable subtasks. - Your final goal WILL ALWAYS be the creation of Subtasks + private static string BuildSystemPrompt() + { + var fromFile = PromptFiles.ReadOrNull(PromptKind.Planning); + if (fromFile is not null) return fromFile; - ALWAYS invoke the `superpowers:brainstorming` skill via the Skill tool at the - start of every planning session, and follow its process end-to-end. It guides - you through clarifying questions, approach exploration, and design approval - BEFORE any subtasks are created. Do not create child tasks until the user has - approved a design. - - NEVER Change files yourself. - - ALWAYS Use the available MCP tools (mcp__claudedo__*) to create child tasks once the - design is approved. When you are done planning, finalize the session. + return + """ + You are a planning assistant for ClaudeDo. + Your role is to help break down a task into smaller, actionable subtasks. + Your final goal WILL ALWAYS be the creation of Subtasks - Be concise and focused. Each subtask should be independently executable. - """; + ALWAYS invoke the `superpowers:brainstorming` skill via the Skill tool at the + start of every planning session, and follow its process end-to-end. It guides + you through clarifying questions, approach exploration, and design approval + BEFORE any subtasks are created. Do not create child tasks until the user has + approved a design. + + NEVER Change files yourself. + + ALWAYS Use the available MCP tools (mcp__claudedo__*) to create child tasks once the + design is approved. When you are done planning, finalize the session. + + Be concise and focused. Each subtask should be independently executable. + """; + } private static string BuildInitialPrompt(TaskEntity task) { diff --git a/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs b/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs index 9298f77..5e59ce2 100644 --- a/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs +++ b/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs @@ -74,6 +74,40 @@ public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher return Task.CompletedTask; } + public Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken) + { + if (!Directory.Exists(ctx.WorkingDir)) + throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}"); + + var resolvedWt = Resolve(_wtPath) + ?? throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}"); + var resolvedClaude = Resolve(_claudePath) + ?? throw new PlanningLaunchException($"claude executable not found: {_claudePath}"); + + var psi = new ProcessStartInfo + { + FileName = resolvedWt, + UseShellExecute = false, + CreateNoWindow = false, + }; + + psi.Environment["MAX_THINKING_TOKENS"] = "20000"; + + psi.ArgumentList.Add("-d"); + psi.ArgumentList.Add(ctx.WorkingDir); + psi.ArgumentList.Add(resolvedClaude); + psi.ArgumentList.Add("--model"); + psi.ArgumentList.Add(Model); + psi.ArgumentList.Add("--permission-mode"); + psi.ArgumentList.Add("auto"); + psi.ArgumentList.Add(ctx.InitialPrompt); + + var proc = Process.Start(psi) + ?? throw new PlanningLaunchException("Failed to start Windows Terminal process."); + + return Task.CompletedTask; + } + public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken) { if (!Directory.Exists(ctx.WorkingDir)) diff --git a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs index b35186b..b0abc28 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs @@ -169,6 +169,7 @@ internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher public bool ShouldThrow { get; set; } public int LaunchStartCalls { get; private set; } public int LaunchResumeCalls { get; private set; } + public int LaunchInteractiveCalls { get; private set; } public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken) { @@ -182,6 +183,13 @@ internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher LaunchResumeCalls++; return Task.CompletedTask; } + + public Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken) + { + if (ShouldThrow) throw new PlanningLaunchException("fake launch failure"); + LaunchInteractiveCalls++; + return Task.CompletedTask; + } } internal sealed class RecordingClientProxy : IClientProxy diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 281c495..1e91f95 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -39,6 +39,8 @@ sealed class FakeWorkerClient : IWorkerClient public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; } public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; } + public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; + public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask; public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; } public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) { DiscardPlanningCalls++; return Task.CompletedTask; } public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) { FinalizePlanningCalls++; return Task.CompletedTask; }