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 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. 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) 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; }