feat(planning): run interactive planning sessions via embedded ConPTY

Planning start/resume opened an external Windows Terminal (wt) window.
Route them through the embedded ConPTY Command Center pane instead, matching
the existing interactive-session UX (no external window).

- Extract bare planning arg builders (BuildPlanningStartArgs/ResumeArgs) from
  WindowsTerminalLauncher; the wt path still uses them (kept, not removed).
- InteractiveLaunchSpecService.BuildPlanningStart/Resume map a planning
  context into a LaunchSpec (planning args + env: MAX_THINKING_TOKENS,
  CLAUDEDO_PLANNING_TOKEN). Hub GetPlanningStart/ResumeLaunchSpec run
  StartAsync/ResumeAsync then return the spec.
- UI: OpenPlanningSession + the resume branch raise OpenPlanningConPtyRequested;
  the shell opens Mission Control and hosts a planning ConPTY pane.

Env is process-global by design (sequential human-paced sessions). wt planning
code retained. Tests added for the arg/env mapping.
This commit is contained in:
mika kuns
2026-07-24 12:12:23 +02:00
parent 2612831a5e
commit ef285b21fd
16 changed files with 249 additions and 24 deletions
@@ -76,6 +76,12 @@ sealed class FakeWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public int PlanningStartSpecCalls { get; private set; }
public int PlanningResumeSpecCalls { get; private set; }
public Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
{ PlanningStartSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); }
public Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
{ PlanningResumeSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); }
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; }
public Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
@@ -224,25 +230,29 @@ public class TasksIslandViewModelPlanningTests
}
[Fact]
public async Task OpenPlanningSession_IgnoresNonIdleRow()
public void OpenPlanningSession_IgnoresNonIdleRow()
{
var row = MakeRow("t1", TaskStatus.Queued);
var (vm, worker) = VmFactory.Create([row]);
var (vm, _) = VmFactory.Create([row]);
(string TaskId, bool Resume)? raised = null;
vm.OpenPlanningConPtyRequested += (id, resume) => raised = (id, resume);
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.OpenPlanningSessionCommand).ExecuteAsync(row);
vm.OpenPlanningSessionCommand.Execute(row);
Assert.Equal(0, worker.StartPlanningCalls);
Assert.Null(raised);
}
[Fact]
public async Task OpenPlanningSession_CallsWorkerForIdleRow()
public void OpenPlanningSession_RequestsConPtyPaneForIdleRow()
{
var row = MakeRow("t1", TaskStatus.Idle);
var (vm, worker) = VmFactory.Create([row]);
var (vm, _) = VmFactory.Create([row]);
(string TaskId, bool Resume)? raised = null;
vm.OpenPlanningConPtyRequested += (id, resume) => raised = (id, resume);
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.OpenPlanningSessionCommand).ExecuteAsync(row);
vm.OpenPlanningSessionCommand.Execute(row);
Assert.Equal(1, worker.StartPlanningCalls);
Assert.Equal(("t1", false), raised);
}
[Fact]