diff --git a/README.md b/README.md index 9323644..02bed62 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ Forge Desktop can download packs (from GitHub Releases) and install a selected p - `forge run next --plan ` - `forge run resume --plan --run-id ` - `forge workflow check --plan ` +- `forge workflow auto --plan --adapter codex|claude` +- `forge codex session` ## Workflow Enforcement diff --git a/apps/desktop/src-tauri/src/forge_cli.rs b/apps/desktop/src-tauri/src/forge_cli.rs index eb9eebc..69f930a 100644 --- a/apps/desktop/src-tauri/src/forge_cli.rs +++ b/apps/desktop/src-tauri/src/forge_cli.rs @@ -85,10 +85,11 @@ pub fn run_forge_json(app: &AppHandle, cwd: &Path, args: &[String]) -> Result Result { let (bin, base_args) = resolve_forge_command(app); let mut cmd = tokio::process::Command::new(&bin); @@ -100,6 +101,10 @@ pub fn spawn_forge_stream( .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); + for (k, v) in extra_env { + cmd.env(k, v); + } + cmd.spawn() .map_err(|error| format!("failed to spawn forge command {bin:?}: {error}")) } diff --git a/apps/desktop/src-tauri/src/http_server.rs b/apps/desktop/src-tauri/src/http_server.rs index 5bbc0a4..f555a96 100644 --- a/apps/desktop/src-tauri/src/http_server.rs +++ b/apps/desktop/src-tauri/src/http_server.rs @@ -22,7 +22,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio_stream::wrappers::ReceiverStream; use uuid::Uuid; -use crate::forge_cli::{run_forge_json, spawn_forge_stream}; +use crate::forge_cli::{run_forge_json, spawn_forge_stream_with_env}; use crate::packs::{ compute_update_status, download_and_install_pack, fetch_packs_index, merge_bundled_packs, read_bundled_packs, read_installed_packs, read_pack_content, PackContent, @@ -198,15 +198,16 @@ async fn plan_validate( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct RunNextStreamQuery { +struct WorkflowAutoStreamQuery { project_root: String, plan_path: String, adapter: String, + push: Option, } -async fn run_next_stream( +async fn workflow_auto_stream( State(state): State, - Query(query): Query, + Query(query): Query, ) -> Result>>, (StatusCode, String)> { let stream_id = Uuid::new_v4().to_string(); @@ -236,17 +237,28 @@ async fn run_next_stream( .await; let cwd = PathBuf::from(&query.project_root); - let args = vec![ - "run".to_string(), - "next".to_string(), + let mut args = vec![ + "workflow".to_string(), + "auto".to_string(), "--plan".to_string(), query.plan_path, "--adapter".to_string(), query.adapter, "--jsonl".to_string(), ]; + if query.push == Some(false) { + args.push("--no-push".to_string()); + } - let mut child = match spawn_forge_stream(&app, &cwd, &args) { + let mut child = match spawn_forge_stream_with_env( + &app, + &cwd, + &args, + &[ + ("FORGE_INTERACTIVE", "1"), + ("FORGE_DESKTOP", "1"), + ], + ) { Ok(c) => c, Err(error) => { let _ = out_tx @@ -409,14 +421,269 @@ async fn run_next_stream( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct RunNextStreamInputRequest { +struct CodexSessionStreamQuery { + project_root: String, + auto_skill: Option, +} + +async fn codex_session_stream( + State(state): State, + Query(query): Query, +) -> Result>>, (StatusCode, String)> +{ + let stream_id = Uuid::new_v4().to_string(); + let (out_tx, out_rx) = tokio::sync::mpsc::channel::(256); + let (input_tx, mut input_rx) = tokio::sync::mpsc::channel::(64); + let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel::(false); + + state.run_streams.insert( + stream_id.clone(), + RunStreamControls { + input_tx, + cancel_tx, + }, + ); + + let app = state.app.clone(); + let run_streams = state.run_streams.clone(); + tokio::spawn(async move { + let _ = out_tx + .send( + serde_json::json!({ + "type": "sse.meta", + "streamId": stream_id + }) + .to_string(), + ) + .await; + + let cwd = PathBuf::from(&query.project_root); + let mut args = vec![ + "codex".to_string(), + "session".to_string(), + "--jsonl".to_string(), + ]; + + if let Some(skill) = query.auto_skill.as_ref().map(|s| s.trim().to_string()) { + if !skill.is_empty() { + args.push("--auto-skill".to_string()); + args.push(skill); + } + } + + let mut child = match spawn_forge_stream_with_env( + &app, + &cwd, + &args, + &[ + ("FORGE_INTERACTIVE", "1"), + ("FORGE_DESKTOP", "1"), + ], + ) { + Ok(c) => c, + Err(error) => { + let _ = out_tx + .send( + serde_json::json!({ + "type": "sse.error", + "message": error + }) + .to_string(), + ) + .await; + run_streams.remove(&stream_id); + return; + } + }; + + let mut stdin = match child.stdin.take() { + Some(s) => s, + None => { + let _ = out_tx + .send( + serde_json::json!({ + "type": "sse.error", + "message": "child stdin missing" + }) + .to_string(), + ) + .await; + run_streams.remove(&stream_id); + return; + } + }; + + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let mut stdout_lines = stdout.map(|s| BufReader::new(s).lines()); + let mut stderr_lines = stderr.map(|s| BufReader::new(s).lines()); + + let pid = child.id(); + let _ = out_tx + .send( + serde_json::json!({ + "type": "process.spawned", + "pid": pid + }) + .to_string(), + ) + .await; + + loop { + if *cancel_rx.borrow() { + let _ = child.start_kill(); + let _ = out_tx + .send(serde_json::json!({ "type": "process.killed" }).to_string()) + .await; + break; + } + + tokio::select! { + _ = cancel_rx.changed() => {} + maybe_input = input_rx.recv() => { + if let Some(text) = maybe_input { + let mut bytes = text.into_bytes(); + if !bytes.ends_with(b"\\n") { + bytes.push(b'\n'); + } + let _ = stdin.write_all(&bytes).await; + let _ = stdin.flush().await; + } + } + res = async { + if let Some(lines) = &mut stdout_lines { + lines.next_line().await + } else { + Ok(None) + } + } => { + match res { + Ok(Some(text)) => { + let trimmed = text.trim(); + if !trimmed.is_empty() { + let _ = out_tx.send(trimmed.to_string()).await; + } + } + Ok(None) => { stdout_lines = None; } + Err(error) => { + let _ = out_tx + .send(serde_json::json!({"type":"process.stdout_error","message": error.to_string()}).to_string()) + .await; + stdout_lines = None; + } + } + } + res = async { + if let Some(lines) = &mut stderr_lines { + lines.next_line().await + } else { + Ok(None) + } + } => { + match res { + Ok(Some(text)) => { + let trimmed = text.trim(); + if !trimmed.is_empty() { + let _ = out_tx + .send(serde_json::json!({"type":"process.stderr","line": trimmed}).to_string()) + .await; + } + } + Ok(None) => { stderr_lines = None; } + Err(error) => { + let _ = out_tx + .send(serde_json::json!({"type":"process.stderr_error","message": error.to_string()}).to_string()) + .await; + stderr_lines = None; + } + } + } + } + + if stdout_lines.is_none() && stderr_lines.is_none() { + break; + } + } + + let status = child.wait().await.ok(); + let code = status.as_ref().and_then(|s| s.code()).unwrap_or(-1); + let _ = out_tx + .send(serde_json::json!({ "type": "process.exit", "code": code }).to_string()) + .await; + + run_streams.remove(&stream_id); + }); + + let stream = ReceiverStream::new(out_rx).map(|line| Ok(Event::default().data(line))); + Ok(Sse::new(stream).keep_alive( + axum::response::sse::KeepAlive::new() + .interval(std::time::Duration::from_secs(15)) + .text("keep-alive"), + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkflowAutoPromptRespondRequest { + stream_id: String, + request_id: String, + answers: serde_json::Value, +} + +async fn workflow_auto_prompt_respond( + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, String)> { + let controls = state + .run_streams + .get(&body.stream_id) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "stream not found"))?; + + let line = serde_json::json!({ + "type": "user_input.response", + "requestId": body.request_id, + "answers": body.answers + }) + .to_string(); + controls + .input_tx + .send(line) + .await + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "stream input channel closed"))?; + Ok(Json(true)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkflowAutoStreamCancelRequest { + stream_id: String, +} + +async fn workflow_auto_stream_cancel( + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, String)> { + let controls = state + .run_streams + .get(&body.stream_id) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "stream not found"))?; + controls + .cancel_tx + .send(true) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "stream cancel channel closed"))?; + Ok(Json(true)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CodexSessionSendRequest { stream_id: String, text: String, } -async fn run_next_stream_input( +async fn codex_session_send( State(state): State, - Json(body): Json, + Json(body): Json, ) -> Result, (StatusCode, String)> { let controls = state .run_streams @@ -432,13 +699,45 @@ async fn run_next_stream_input( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct RunNextStreamCancelRequest { +struct CodexSessionPromptRespondRequest { stream_id: String, + request_id: String, + answers: serde_json::Value, } -async fn run_next_stream_cancel( +async fn codex_session_prompt_respond( State(state): State, - Json(body): Json, + Json(body): Json, +) -> Result, (StatusCode, String)> { + let controls = state + .run_streams + .get(&body.stream_id) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "stream not found"))?; + + let line = serde_json::json!({ + "type": "user_input.response", + "requestId": body.request_id, + "answers": body.answers + }) + .to_string(); + + controls + .input_tx + .send(line) + .await + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "stream input channel closed"))?; + Ok(Json(true)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CodexSessionCancelRequest { + stream_id: String, +} + +async fn codex_session_cancel( + State(state): State, + Json(body): Json, ) -> Result, (StatusCode, String)> { let controls = state .run_streams @@ -1081,33 +1380,33 @@ async fn plans_status( Query(query): Query, ) -> Result, (StatusCode, String)> { tauri::async_runtime::spawn_blocking(move || { - let state_path = PathBuf::from(&query.project_root).join(".forge").join("state.json"); - if !state_path.exists() { + let plan_path = { + let p = PathBuf::from(&query.plan_path); + if p.is_absolute() { + p + } else { + PathBuf::from(&query.project_root).join(p) + } + }; + if !plan_path.exists() { return Ok::<_, String>(PlanStatusResult { tasks: vec![] }); } - let raw = std::fs::read_to_string(&state_path) - .map_err(|e| format!("read state.json: {e}"))?; + let raw = std::fs::read_to_string(&plan_path) + .map_err(|e| format!("read plan: {e}"))?; let value: serde_json::Value = serde_json::from_str(&raw) - .map_err(|e| format!("parse state.json: {e}"))?; - - // state.json may scope by plan path: look for tasks under the plan key or at top level - let tasks_value = value - .get(&query.plan_path) - .and_then(|v| v.get("tasks")) - .or_else(|| value.get("tasks")); + .map_err(|e| format!("parse plan: {e}"))?; + let tasks_value = value.get("tasks"); let mut tasks = Vec::new(); - if let Some(tasks_obj) = tasks_value.and_then(|v| v.as_object()) { - for (id, task_val) in tasks_obj { - let state = task_val - .get("state") - .and_then(|v| v.as_str()) - .unwrap_or("pending") - .to_string(); - tasks.push(TaskStatus { - id: id.clone(), - state, - }); + if let Some(items) = tasks_value.and_then(|v| v.as_array()) { + for item in items { + let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if id.is_empty() { + continue; + } + let status = item.get("status").and_then(|v| v.as_str()).unwrap_or(""); + let state = if status.trim().is_empty() { "pending" } else { status }.to_string(); + tasks.push(TaskStatus { id, state }); } } tasks.sort_by(|a, b| a.id.cmp(&b.id)); @@ -1335,9 +1634,13 @@ pub async fn serve(app: AppHandle) -> Result<(), String> { .route("/api/cwd", get(get_cwd)) .route("/api/debug/status", get(debug_status)) .route("/api/plan/validate", post(plan_validate)) - .route("/api/run/next/stream", get(run_next_stream)) - .route("/api/run/next/input", post(run_next_stream_input)) - .route("/api/run/next/cancel", post(run_next_stream_cancel)) + .route("/api/workflow/auto/stream", get(workflow_auto_stream)) + .route("/api/workflow/auto/prompt/respond", post(workflow_auto_prompt_respond)) + .route("/api/workflow/auto/cancel", post(workflow_auto_stream_cancel)) + .route("/api/codex/session/stream", get(codex_session_stream)) + .route("/api/codex/session/send", post(codex_session_send)) + .route("/api/codex/session/prompt/respond", post(codex_session_prompt_respond)) + .route("/api/codex/session/cancel", post(codex_session_cancel)) .route("/api/evidence", get(get_evidence)) .route("/api/project/guidance-status", get(project_get_guidance_status)) .route("/api/packs/installed", get(packs_list_installed)) diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 9fa3fc6..d8bfffd 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -58,27 +58,12 @@
New Plan - Run + Run Open Evidence
- - - Send - + Cancel @@ -100,7 +85,7 @@

Current Task

ID: {{ current.taskId || "-" }}

Status: {{ current.state || "-" }}

-

Run ID: {{ current.runId || "-" }}

+

Phase: {{ current.phase || "-" }}

External Run ID: {{ current.externalRunId || "-" }}

Resume: {{ current.resumeCommand || "-" }}

Stream ID: {{ streamId || "-" }}

@@ -113,7 +98,7 @@ @@ -344,6 +329,34 @@ + + + +

Codex Needs Input

+
+
{{ q.header }}
+
{{ q.question }}
+ + + + +
+
+ Cancel + Submit +
+
+
@@ -367,9 +380,9 @@ import { plansStatus, projectGetGuidanceStatus, projectInstallGuidance, - runNextStreamCancel, - runNextStreamInput, - runNextStreamUrl, + workflowAutoCancel, + workflowAutoPromptRespond, + workflowAutoStreamUrl, selectFile, selectFolder, type InstalledPack, @@ -378,7 +391,6 @@ import { type PhaseGateBindings, type PlanFileEntry, type ProjectGuidanceStatus, - type RunNextResult, type TaskStatus, type ValidationIssue } from "./composables/useControlPlane"; @@ -410,6 +422,9 @@ const logs = ref([]); const evidence = ref([]); const LAST_PROJECT_ROOT_KEY = "forge.desktop.lastProjectRoot"; +const PUSH_AFTER_TASK_KEY = "forge.desktop.pushAfterTask"; + +const pushEnabled = ref(false); const guidanceStatus = ref(); const guidanceError = ref(""); @@ -542,14 +557,22 @@ function syncPackSelection(): void { selectedPackPath.value = (matchingInstalled ?? versions[0]!).path; } -const current = reactive({ +type WorkflowAutoUiState = { + state: "idle" | "running" | "paused" | "completed" | "failed"; + taskId?: string; + phase?: string; + externalRunId?: string; + resumeCommand?: string; + message: string; +}; + +const current = reactive({ state: "idle", message: "Not started" }); const streaming = ref(false); const streamId = ref(""); -const streamInput = ref(""); let eventSource: EventSource | null = null; type LiveOutputSegment = { @@ -694,7 +717,18 @@ watch(tab, (newTab) => { } }); +function loadPushSetting(): void { + try { + const raw = localStorage.getItem(`${PUSH_AFTER_TASK_KEY}:${projectRoot.value}`) ?? ""; + pushEnabled.value = raw === "true"; + } catch { + pushEnabled.value = false; + } +} + watch(projectRoot, () => { + loadPushSetting(); + // When switching projects, refresh pack + guidance context automatically. onRefreshGuidance(); onListInstalledPacks(); @@ -704,6 +738,14 @@ watch(projectRoot, () => { } }); +watch(pushEnabled, (value) => { + try { + localStorage.setItem(`${PUSH_AFTER_TASK_KEY}:${projectRoot.value}`, value ? "true" : "false"); + } catch { + // best-effort + } +}); + watch(selectedPackName, () => { // Keep selectedPackPath aligned to the selected pack name. const versions = downloadedVersionsForSelected.value; @@ -859,7 +901,56 @@ function pushLog(line: string): void { } } -async function onRunNextStream(): Promise { +type UserInputOption = { label: string; description?: string; isOther?: boolean }; +type UserInputQuestion = { id: string; header?: string; question: string; options: UserInputOption[] }; + +const userInputOpen = ref(false); +const userInputRequestId = ref(""); +const userInputQuestions = ref([]); +const userInputSelectedByQuestionId = ref>({}); +const userInputOtherTextByQuestionId = ref>({}); + +function openUserInput(requestId: string, questions: UserInputQuestion[]): void { + userInputRequestId.value = requestId; + userInputQuestions.value = questions; + userInputSelectedByQuestionId.value = {}; + userInputOtherTextByQuestionId.value = {}; + for (const q of questions) { + const first = q.options[0]?.label ?? ""; + if (first) userInputSelectedByQuestionId.value[q.id] = first; + } + userInputOpen.value = true; +} + +function closeUserInput(): void { + userInputOpen.value = false; + userInputRequestId.value = ""; + userInputQuestions.value = []; + userInputSelectedByQuestionId.value = {}; + userInputOtherTextByQuestionId.value = {}; +} + +async function cancelUserInput(): Promise { + closeUserInput(); + await onCancelStream(); +} + +async function submitUserInput(): Promise { + if (!streamId.value || !userInputRequestId.value) return; + const answers: Record = {}; + for (const q of userInputQuestions.value) { + const selected = userInputSelectedByQuestionId.value[q.id] ?? ""; + const option = q.options.find((o) => o.label === selected); + const value = + option?.isOther === true ? (userInputOtherTextByQuestionId.value[q.id] ?? "").trim() || selected : selected; + answers[q.id] = { answers: value ? [value] : [] }; + } + await workflowAutoPromptRespond(streamId.value, userInputRequestId.value, answers); + pushLog(` [prompt] responded to ${userInputRequestId.value}`); + closeUserInput(); +} + +async function onWorkflowAutoStream(): Promise { stopStream(); clearLiveOutput(); @@ -872,17 +963,16 @@ async function onRunNextStream(): Promise { current.state = "running"; current.taskId = undefined; - current.runId = undefined; + current.phase = undefined; current.externalRunId = undefined; current.resumeCommand = undefined; - current.message = "Running (stream)..."; + current.message = "Running (workflow auto)..."; streamId.value = ""; - streamInput.value = ""; streaming.value = true; - pushLog("Run (stream) -> started"); + pushLog("Workflow auto (stream) -> started"); - const url = runNextStreamUrl(projectRoot.value, planPath.value, adapter.value); + const url = workflowAutoStreamUrl(projectRoot.value, planPath.value, adapter.value, pushEnabled.value); eventSource = new EventSource(url); eventSource.addEventListener("message", (event) => { @@ -917,6 +1007,9 @@ async function onRunNextStream(): Promise { const tool = String(e.tool ?? "tool"); const status = String(e.status ?? ""); appendLiveOutput("system", `[tool] ${tool}${status ? ` ${status}` : ""}\n`); + } else if (e?.type === "run.user_input.requested") { + openUserInput(String(e.requestId ?? ""), (e.questions ?? []) as UserInputQuestion[]); + appendLiveOutput("system", `[prompt] waiting for user input (${String(e.requestId ?? "")})\n`); } else if (e?.type === "run.failed") { appendLiveOutput("stderr", `[failed] ${String(e.reason ?? "")}\n`); } else if (e?.type) { @@ -925,34 +1018,24 @@ async function onRunNextStream(): Promise { return; } - if (type === "run.next.result") { - const result = parsed.result as RunNextResult | undefined; - if (result) { - current.state = result.state; - current.taskId = result.taskId; - current.runId = result.runId; - current.externalRunId = result.externalRunId; - current.resumeCommand = result.resumeCommand; - current.message = result.message; - pushLog(`Run (stream) -> ${result.message}`); - if (result.runId) pushLog(` runId: ${result.runId}`); - if (result.externalRunId) pushLog(` externalRunId: ${result.externalRunId}`); - if (result.resumeCommand) pushLog(` resume: ${result.resumeCommand}`); - if (result.classification) pushLog(` classification: ${result.classification}`); - if (result.checksSummary?.length) { - for (const check of result.checksSummary) { - pushLog(` ${check}`); - } - } - if (result.llmOutput?.length) { - pushLog(" adapter output (tail):"); - for (const line of result.llmOutput) { - pushLog(` ${line}`); - } - } - } else { - pushLog("Run (stream) -> missing result payload"); - } + if (type === "workflow.auto.step") { + const taskId = String(parsed.taskId ?? ""); + const phase = String(parsed.phase ?? ""); + current.taskId = taskId || current.taskId; + current.phase = phase || current.phase; + current.message = taskId && phase ? `Running ${taskId} (${phase})` : "Running..."; + return; + } + + if (type === "workflow.auto.paused" || type === "workflow.auto.completed") { + const step = parsed.step ?? {}; + current.state = step.state === "paused" ? "paused" : step.state === "completed" ? "completed" : current.state; + current.taskId = typeof step.taskId === "string" ? step.taskId : current.taskId; + current.phase = typeof step.phase === "string" ? step.phase : current.phase; + current.externalRunId = typeof step.externalRunId === "string" ? step.externalRunId : undefined; + current.resumeCommand = typeof step.resumeCommand === "string" ? step.resumeCommand : undefined; + current.message = typeof step.message === "string" ? step.message : current.message; + pushLog(`Workflow auto (stream) -> ${current.message}`); stopStream(); return; } @@ -966,29 +1049,17 @@ async function onRunNextStream(): Promise { }); eventSource.addEventListener("error", () => { - pushLog("Run (stream) -> SSE error/disconnected"); + pushLog("Workflow auto (stream) -> SSE error/disconnected"); }); } -async function onSendStreamInput(): Promise { - const text = streamInput.value.trim(); - if (!text || !streamId.value) return; - try { - await runNextStreamInput(streamId.value, text); - pushLog(` [input] ${text}`); - streamInput.value = ""; - } catch (error) { - pushLog(` [input error] ${String(error)}`); - } -} - async function onCancelStream(): Promise { if (!streamId.value) return; try { - await runNextStreamCancel(streamId.value); - pushLog("Run (stream) -> cancel requested"); + await workflowAutoCancel(streamId.value); + pushLog("Workflow auto (stream) -> cancel requested"); } catch (error) { - pushLog(`Run (stream) -> cancel error: ${String(error)}`); + pushLog(`Workflow auto (stream) -> cancel error: ${String(error)}`); } finally { stopStream(); } diff --git a/apps/desktop/src/components/NewPlanDialog.test.ts b/apps/desktop/src/components/NewPlanDialog.test.ts index 61afd71..38f2bfb 100644 --- a/apps/desktop/src/components/NewPlanDialog.test.ts +++ b/apps/desktop/src/components/NewPlanDialog.test.ts @@ -76,8 +76,8 @@ describe("NewPlanDialog adapter-specific instructions", () => { // Given the codex adapter // When getting the skill invocation const result = getSkillInvocation("codex"); - // Then it returns a mention with description prompt - expect(result).toBe("Mention `$plan-guided` and describe your feature"); + // Then it indicates the guided flow starts automatically + expect(result).toBe("Guided plan creation starts automatically"); }); it("falls back to slash command for unknown adapters", () => { @@ -95,15 +95,8 @@ describe("new plan spawn config", () => { expect(config).toEqual({ command: "claude", cwd: "/tmp/project" }); }); - it("builds codex config with TERM/env and no-alt-screen", () => { + it("builds default config for other adapters", () => { const config = buildNewPlanSpawnConfig("codex", "/tmp/project"); - expect(config.command).toBe("codex"); - expect(config.cwd).toBe("/tmp/project"); - expect(config.args).toEqual(["--no-alt-screen"]); - expect(config.env).toEqual({ - TERM: "xterm-256color", - COLORTERM: "truecolor", - RUST_BACKTRACE: "1" - }); + expect(config).toEqual({ command: "codex", cwd: "/tmp/project" }); }); }); diff --git a/apps/desktop/src/components/NewPlanDialog.vue b/apps/desktop/src/components/NewPlanDialog.vue index 33995f8..5eabb41 100644 --- a/apps/desktop/src/components/NewPlanDialog.vue +++ b/apps/desktop/src/components/NewPlanDialog.vue @@ -12,7 +12,7 @@

Instructions

  1. - The terminal on the right is running {{ adapterLabel }}. + The panel on the right is running {{ adapterLabel }}.
  2. {{ skillInstruction }} to start guided plan creation. @@ -32,8 +32,8 @@ {{ error }} - - Starting terminal... + + {{ isCodex ? "Starting Codex session..." : "Starting terminal..." }} @@ -76,19 +76,45 @@ {{ error }} - -
    - -
    -
    - -
    + +
@@ -100,12 +126,42 @@ + + + +

Codex Needs Input

+
+
{{ q.header }}
+
{{ q.question }}
+ + + + +
+
+ Cancel + Submit +
+
+