feat(ui): add Run interactively action to task context menu

Spawns Windows Terminal in the list working directory running
`claude --permission-mode auto` with the task title and description
prefilled as the initial prompt. Reuses the planning launcher
infrastructure but skips worktree, system prompt, and MCP setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-25 10:02:21 +02:00
parent 615c1da665
commit 6c54759aa0
12 changed files with 135 additions and 17 deletions

View File

@@ -28,6 +28,7 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<ListConfigDto?> GetListConfigAsync(string listId); Task<ListConfigDto?> GetListConfigAsync(string listId);
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto); Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default); Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default); Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default); Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default); Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);

View File

@@ -386,6 +386,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct); => await _hub.InvokeAsync<PlanningSessionResumeInfo>("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) public async Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct); => await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);

View File

@@ -516,6 +516,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
catch { } 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] [RelayCommand]
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row) private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
{ {

View File

@@ -38,6 +38,8 @@
IsVisible="{Binding IsQueued}" IsVisible="{Binding IsQueued}"
Click="OnRemoveFromQueueClick"/> Click="OnRemoveFromQueueClick"/>
<Separator/> <Separator/>
<MenuItem Header="Run interactively"
Click="OnRunInteractivelyClick"/>
<MenuItem Header="Open planning Session" <MenuItem Header="Open planning Session"
Click="OnOpenPlanningSessionClick" Click="OnOpenPlanningSessionClick"
IsVisible="{Binding CanOpenPlanningSession}"/> IsVisible="{Binding CanOpenPlanningSession}"/>

View File

@@ -45,6 +45,12 @@ public partial class TaskRowView : UserControl
await vm.OpenPlanningSessionCommand.ExecuteAsync(row); 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) private async void OnResumePlanningSessionClick(object? sender, RoutedEventArgs e)
{ {
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)

View File

@@ -346,6 +346,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return ctx; 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) public async Task DiscardPlanningSessionAsync(string taskId)
{ {
await _planning.DiscardAsync(taskId, Context.ConnectionAborted); await _planning.DiscardAsync(taskId, Context.ConnectionAborted);

View File

@@ -4,6 +4,7 @@ public interface IPlanningTerminalLauncher
{ {
Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken); Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken); Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken);
Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken);
} }
public sealed class PlanningLaunchException : Exception public sealed class PlanningLaunchException : Exception

View File

@@ -0,0 +1,6 @@
namespace ClaudeDo.Worker.Planning;
public sealed record InteractiveLaunchContext(
string TaskId,
string WorkingDir,
string InitialPrompt);

View File

@@ -138,6 +138,40 @@ public sealed class PlanningSessionManager
Files: files); Files: files);
} }
public async Task<InteractiveLaunchContext> 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<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct) public async Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
{ {
var (tasks, lists, settings, ctx) = CreateRepos(); var (tasks, lists, settings, ctx) = CreateRepos();
@@ -287,25 +321,31 @@ public sealed class PlanningSessionManager
} }
"""; """;
private static string BuildSystemPrompt() => private static string BuildSystemPrompt()
""" {
You are a planning assistant for ClaudeDo. var fromFile = PromptFiles.ReadOrNull(PromptKind.Planning);
Your role is to help break down a task into smaller, actionable subtasks. if (fromFile is not null) return fromFile;
Your final goal WILL ALWAYS be the creation of Subtasks
ALWAYS invoke the `superpowers:brainstorming` skill via the Skill tool at the return
start of every planning session, and follow its process end-to-end. It guides """
you through clarifying questions, approach exploration, and design approval You are a planning assistant for ClaudeDo.
BEFORE any subtasks are created. Do not create child tasks until the user has Your role is to help break down a task into smaller, actionable subtasks.
approved a design. Your final goal WILL ALWAYS be the creation of Subtasks
NEVER Change files yourself. 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.
ALWAYS Use the available MCP tools (mcp__claudedo__*) to create child tasks once the NEVER Change files yourself.
design is approved. When you are done planning, finalize the session.
Be concise and focused. Each subtask should be independently executable. 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) private static string BuildInitialPrompt(TaskEntity task)
{ {

View File

@@ -74,6 +74,40 @@ public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher
return Task.CompletedTask; 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) public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
{ {
if (!Directory.Exists(ctx.WorkingDir)) if (!Directory.Exists(ctx.WorkingDir))

View File

@@ -169,6 +169,7 @@ internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher
public bool ShouldThrow { get; set; } public bool ShouldThrow { get; set; }
public int LaunchStartCalls { get; private set; } public int LaunchStartCalls { get; private set; }
public int LaunchResumeCalls { get; private set; } public int LaunchResumeCalls { get; private set; }
public int LaunchInteractiveCalls { get; private set; }
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken) public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
{ {
@@ -182,6 +183,13 @@ internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher
LaunchResumeCalls++; LaunchResumeCalls++;
return Task.CompletedTask; 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 internal sealed class RecordingClientProxy : IClientProxy

View File

@@ -39,6 +39,8 @@ sealed class FakeWorkerClient : IWorkerClient
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; } public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; }
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; 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 ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; }
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) { DiscardPlanningCalls++; 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; } public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) { FinalizePlanningCalls++; return Task.CompletedTask; }