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

View File

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

View File

@@ -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)
{

View File

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

View File

@@ -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)

View File

@@ -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);

View File

@@ -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

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);
}
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)
{
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.
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
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
design is approved. When you are done planning, finalize the session.
NEVER Change files yourself.
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)
{

View File

@@ -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))

View File

@@ -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

View File

@@ -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; }