From ed5f34b7761432bf70aab13d507199539e59e20f Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Feb 2026 14:42:41 +0000 Subject: [PATCH] Story 37: Editor Command for Worktrees Co-Authored-By: Claude Opus 4.6 --- frontend/src/api/settings.ts | 47 +++++++ frontend/src/components/AgentPanel.tsx | 168 ++++++++++++++++++++++++- server/src/http/mcp.rs | 98 ++++++++++++++- server/src/http/mod.rs | 7 +- server/src/http/settings.rs | 143 +++++++++++++++++++++ 5 files changed, 460 insertions(+), 3 deletions(-) create mode 100644 frontend/src/api/settings.ts create mode 100644 server/src/http/settings.rs diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts new file mode 100644 index 0000000..81fcd24 --- /dev/null +++ b/frontend/src/api/settings.ts @@ -0,0 +1,47 @@ +export interface EditorSettings { + editor_command: string | null; +} + +const DEFAULT_API_BASE = "/api"; + +function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { + return `${baseUrl}${path}`; +} + +async function requestJson( + path: string, + options: RequestInit = {}, + baseUrl = DEFAULT_API_BASE, +): Promise { + const res = await fetch(buildApiUrl(path, baseUrl), { + headers: { + "Content-Type": "application/json", + ...(options.headers ?? {}), + }, + ...options, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Request failed (${res.status})`); + } + + return res.json() as Promise; +} + +export const settingsApi = { + getEditorCommand(baseUrl?: string): Promise { + return requestJson("/settings/editor", {}, baseUrl); + }, + + setEditorCommand(command: string | null, baseUrl?: string): Promise { + return requestJson( + "/settings/editor", + { + method: "PUT", + body: JSON.stringify({ editor_command: command }), + }, + baseUrl, + ); + }, +}; diff --git a/frontend/src/components/AgentPanel.tsx b/frontend/src/components/AgentPanel.tsx index 8b951d2..027614e 100644 --- a/frontend/src/components/AgentPanel.tsx +++ b/frontend/src/components/AgentPanel.tsx @@ -6,6 +6,7 @@ import type { AgentStatusValue, } from "../api/agents"; import { agentsApi, subscribeAgentStream } from "../api/agents"; +import { settingsApi } from "../api/settings"; import type { UpcomingStory } from "../api/workflow"; const { useCallback, useEffect, useRef, useState } = React; @@ -171,6 +172,72 @@ function DiffCommand({ ); } +function EditorCommand({ + worktreePath, + editorCommand, +}: { + worktreePath: string; + editorCommand: string; +}) { + const [copied, setCopied] = useState(false); + const command = `${editorCommand} "${worktreePath}"`; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(command); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback: select text for manual copy + } + }; + + return ( +
+ + {command} + + +
+ ); +} + export function AgentPanel({ stories }: AgentPanelProps) { const [agents, setAgents] = useState>({}); const [roster, setRoster] = useState([]); @@ -178,10 +245,13 @@ export function AgentPanel({ stories }: AgentPanelProps) { const [actionError, setActionError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); const [selectorStory, setSelectorStory] = useState(null); + const [editorCommand, setEditorCommand] = useState(null); + const [editorInput, setEditorInput] = useState(""); + const [editingEditor, setEditingEditor] = useState(false); const cleanupRefs = useRef void>>({}); const logEndRefs = useRef>({}); - // Load roster and existing agents on mount + // Load roster, existing agents, and editor preference on mount useEffect(() => { agentsApi .getAgentConfig() @@ -211,6 +281,14 @@ export function AgentPanel({ stories }: AgentPanelProps) { }) .catch((err) => console.error("Failed to load agents:", err)); + settingsApi + .getEditorCommand() + .then((s) => { + setEditorCommand(s.editor_command); + setEditorInput(s.editor_command ?? ""); + }) + .catch((err) => console.error("Failed to load editor command:", err)); + return () => { for (const cleanup of Object.values(cleanupRefs.current)) { cleanup(); @@ -347,6 +425,19 @@ export function AgentPanel({ stories }: AgentPanelProps) { } }; + const handleSaveEditor = async () => { + try { + const trimmed = editorInput.trim() || null; + const result = await settingsApi.setEditorCommand(trimmed); + setEditorCommand(result.editor_command); + setEditorInput(result.editor_command ?? ""); + setEditingEditor(false); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setActionError(`Failed to save editor: ${message}`); + } + }; + /** Get all active agent keys for a story. */ const getActiveKeysForStory = (storyId: string): string[] => { return Object.keys(agents).filter((key) => { @@ -404,6 +495,81 @@ export function AgentPanel({ stories }: AgentPanelProps) { )} + {/* Editor preference */} +
+ Editor: + {editingEditor ? ( + <> + setEditorInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveEditor(); + if (e.key === "Escape") setEditingEditor(false); + }} + placeholder="zed, code, cursor..." + style={{ + fontSize: "0.75em", + background: "#111", + border: "1px solid #444", + borderRadius: "4px", + color: "#ccc", + padding: "2px 6px", + width: "120px", + }} + /> + + + + ) : ( + + )} +
+ {/* Roster badges */} {roster.length > 0 && (
) -> JsonRpcResponse { }, "required": ["story_id"] } + }, + { + "name": "get_editor_command", + "description": "Get the open-in-editor command for a worktree. Returns a ready-to-paste shell command like 'zed /path/to/worktree'. Requires the editor preference to be configured via PUT /api/settings/editor.", + "inputSchema": { + "type": "object", + "properties": { + "worktree_path": { + "type": "string", + "description": "Absolute path to the worktree directory" + } + }, + "required": ["worktree_path"] + } } ] }), @@ -601,6 +616,8 @@ async fn handle_tools_call( "create_worktree" => tool_create_worktree(&args, ctx).await, "list_worktrees" => tool_list_worktrees(ctx), "remove_worktree" => tool_remove_worktree(&args, ctx).await, + // Editor tools + "get_editor_command" => tool_get_editor_command(&args, ctx), _ => Err(format!("Unknown tool: {tool_name}")), }; @@ -945,6 +962,20 @@ async fn tool_remove_worktree(args: &Value, ctx: &AppContext) -> Result Result { + let worktree_path = args + .get("worktree_path") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: worktree_path")?; + + let editor = get_editor_command_from_store(ctx) + .ok_or_else(|| "No editor configured. Set one via PUT /api/settings/editor.".to_string())?; + + Ok(format!("{editor} {worktree_path}")) +} + /// Run `git log ..HEAD --oneline` in the worktree and return the commit /// summaries, or `None` if git is unavailable or there are no new commits. async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option> { @@ -1093,7 +1124,8 @@ mod tests { assert!(names.contains(&"create_worktree")); assert!(names.contains(&"list_worktrees")); assert!(names.contains(&"remove_worktree")); - assert_eq!(tools.len(), 16); + assert!(names.contains(&"get_editor_command")); + assert_eq!(tools.len(), 17); } #[test] @@ -1338,4 +1370,68 @@ mod tests { // commits key present (may be null since no real worktree) assert!(parsed.get("commits").is_some()); } + + // ── Editor command tool tests ───────────────────────────────── + + #[test] + fn tool_get_editor_command_missing_worktree_path() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_get_editor_command(&json!({}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("worktree_path")); + } + + #[test] + fn tool_get_editor_command_no_editor_configured() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_get_editor_command( + &json!({"worktree_path": "/some/path"}), + &ctx, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("No editor configured")); + } + + #[test] + fn tool_get_editor_command_formats_correctly() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + ctx.store.set("editor_command", json!("zed")); + + let result = tool_get_editor_command( + &json!({"worktree_path": "/home/user/worktrees/37_my_story"}), + &ctx, + ) + .unwrap(); + assert_eq!(result, "zed /home/user/worktrees/37_my_story"); + } + + #[test] + fn tool_get_editor_command_works_with_vscode() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + ctx.store.set("editor_command", json!("code")); + + let result = tool_get_editor_command( + &json!({"worktree_path": "/path/to/worktree"}), + &ctx, + ) + .unwrap(); + assert_eq!(result, "code /path/to/worktree"); + } + + #[test] + fn get_editor_command_in_tools_list() { + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + let tool = tools.iter().find(|t| t["name"] == "get_editor_command"); + assert!(tool.is_some(), "get_editor_command missing from tools list"); + let t = tool.unwrap(); + assert!(t["description"].is_string()); + let required = t["inputSchema"]["required"].as_array().unwrap(); + let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(req_names.contains(&"worktree_path")); + } } diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index 0dfa1c8..5c062a8 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -8,6 +8,7 @@ pub mod health; pub mod io; pub mod mcp; pub mod model; +pub mod settings; pub mod workflow; pub mod project; @@ -23,6 +24,7 @@ use poem::EndpointExt; use poem::{Route, get, post}; use poem_openapi::OpenApiService; use project::ProjectApi; +use settings::SettingsApi; use std::sync::Arc; use workflow::WorkflowApi; @@ -58,6 +60,7 @@ type ApiTuple = ( ChatApi, WorkflowApi, AgentsApi, + SettingsApi, ); type ApiService = OpenApiService; @@ -72,6 +75,7 @@ pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) { ChatApi { ctx: ctx.clone() }, WorkflowApi { ctx: ctx.clone() }, AgentsApi { ctx: ctx.clone() }, + SettingsApi { ctx: ctx.clone() }, ); let api_service = @@ -84,7 +88,8 @@ pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) { IoApi { ctx: ctx.clone() }, ChatApi { ctx: ctx.clone() }, WorkflowApi { ctx: ctx.clone() }, - AgentsApi { ctx }, + AgentsApi { ctx: ctx.clone() }, + SettingsApi { ctx }, ); let docs_service = diff --git a/server/src/http/settings.rs b/server/src/http/settings.rs new file mode 100644 index 0000000..22a0487 --- /dev/null +++ b/server/src/http/settings.rs @@ -0,0 +1,143 @@ +use crate::http::context::{AppContext, OpenApiResult, bad_request}; +use crate::store::StoreOps; +use poem_openapi::{Object, OpenApi, Tags, payload::Json}; +use serde::Serialize; +use serde_json::json; +use std::sync::Arc; + +const EDITOR_COMMAND_KEY: &str = "editor_command"; + +#[derive(Tags)] +enum SettingsTags { + Settings, +} + +#[derive(Object)] +struct EditorCommandPayload { + editor_command: Option, +} + +#[derive(Object, Serialize)] +struct EditorCommandResponse { + editor_command: Option, +} + +pub struct SettingsApi { + pub ctx: Arc, +} + +#[OpenApi(tag = "SettingsTags::Settings")] +impl SettingsApi { + /// Get the configured editor command (e.g. "zed", "code", "cursor"), or null if not set. + #[oai(path = "/settings/editor", method = "get")] + async fn get_editor(&self) -> OpenApiResult> { + let editor_command = self + .ctx + .store + .get(EDITOR_COMMAND_KEY) + .and_then(|v| v.as_str().map(|s| s.to_string())); + Ok(Json(EditorCommandResponse { editor_command })) + } + + /// Set the preferred editor command (e.g. "zed", "code", "cursor"). + /// Pass null or empty string to clear the preference. + #[oai(path = "/settings/editor", method = "put")] + async fn set_editor( + &self, + payload: Json, + ) -> OpenApiResult> { + let editor_command = payload.0.editor_command; + let trimmed = editor_command.as_deref().map(str::trim).filter(|s| !s.is_empty()); + match trimmed { + Some(cmd) => { + self.ctx.store.set(EDITOR_COMMAND_KEY, json!(cmd)); + self.ctx.store.save().map_err(bad_request)?; + Ok(Json(EditorCommandResponse { + editor_command: Some(cmd.to_string()), + })) + } + None => { + self.ctx.store.delete(EDITOR_COMMAND_KEY); + self.ctx.store.save().map_err(bad_request)?; + Ok(Json(EditorCommandResponse { + editor_command: None, + })) + } + } + } +} + +pub fn get_editor_command_from_store(ctx: &AppContext) -> Option { + ctx.store + .get(EDITOR_COMMAND_KEY) + .and_then(|v| v.as_str().map(|s| s.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::context::AppContext; + use tempfile::TempDir; + + fn test_ctx(dir: &TempDir) -> AppContext { + AppContext::new_test(dir.path().to_path_buf()) + } + + #[test] + fn editor_command_defaults_to_null() { + let dir = TempDir::new().unwrap(); + let ctx = test_ctx(&dir); + let result = get_editor_command_from_store(&ctx); + assert!(result.is_none()); + } + + #[test] + fn set_editor_command_persists_in_store() { + let dir = TempDir::new().unwrap(); + let ctx = test_ctx(&dir); + + ctx.store.set(EDITOR_COMMAND_KEY, json!("zed")); + ctx.store.save().unwrap(); + + let result = get_editor_command_from_store(&ctx); + assert_eq!(result, Some("zed".to_string())); + } + + #[test] + fn get_editor_command_from_store_returns_value() { + let dir = TempDir::new().unwrap(); + let ctx = test_ctx(&dir); + + ctx.store.set(EDITOR_COMMAND_KEY, json!("code")); + let result = get_editor_command_from_store(&ctx); + assert_eq!(result, Some("code".to_string())); + } + + #[test] + fn delete_editor_command_returns_none() { + let dir = TempDir::new().unwrap(); + let ctx = test_ctx(&dir); + + ctx.store.set(EDITOR_COMMAND_KEY, json!("cursor")); + ctx.store.delete(EDITOR_COMMAND_KEY); + let result = get_editor_command_from_store(&ctx); + assert!(result.is_none()); + } + + #[test] + fn editor_command_survives_reload() { + let dir = TempDir::new().unwrap(); + let store_path = dir.path().join(".story_kit_store.json"); + + { + let ctx = AppContext::new_test(dir.path().to_path_buf()); + ctx.store.set(EDITOR_COMMAND_KEY, json!("zed")); + ctx.store.save().unwrap(); + } + + // Reload from disk + let store2 = crate::store::JsonFileStore::new(store_path).unwrap(); + let val = store2.get(EDITOR_COMMAND_KEY); + assert_eq!(val, Some(json!("zed"))); + } +}