diff --git a/apps/desktop/src-tauri/src/claude.rs b/apps/desktop/src-tauri/src/claude.rs index c615074..26b8844 100644 --- a/apps/desktop/src-tauri/src/claude.rs +++ b/apps/desktop/src-tauri/src/claude.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use tauri::{Emitter, Manager, WebviewWindow}; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, Command}; use tokio::sync::Mutex; @@ -785,6 +785,19 @@ fn create_command( cmd } +fn with_prompt_transport(mut args: Vec, prompt: String) -> (Vec, Option) { + args.push("-p".to_string()); + #[cfg(target_os = "windows")] + { + (args, Some(prompt)) + } + #[cfg(not(target_os = "windows"))] + { + args.push(prompt); + (args, None) + } +} + // ─── Event payloads (include tab_id for multi-tab routing) ─── #[derive(Clone, serde::Serialize)] @@ -811,10 +824,15 @@ async fn spawn_claude_process( window: WebviewWindow, mut cmd: Command, tab_id: String, + stdin_payload: Option, ) -> Result<(), String> { let window_label = window.label().to_string(); let process_key = format!("{}:{}", window_label, tab_id); + if stdin_payload.is_some() { + cmd.stdin(std::process::Stdio::piped()); + } + // Spawn the process let mut child = cmd.spawn().map_err(|e| { eprintln!( @@ -827,6 +845,15 @@ async fn spawn_claude_process( ) })?; + if let Some(payload) = stdin_payload { + if let Some(mut stdin) = child.stdin.take() { + tokio::spawn(async move { + let _ = stdin.write_all(payload.as_bytes()).await; + let _ = stdin.shutdown().await; + }); + } + } + let stdout = child.stdout.take().ok_or("Failed to capture stdout")?; let stderr = child.stderr.take().ok_or("Failed to capture stderr")?; @@ -1481,7 +1508,7 @@ pub async fn execute_claude_code( ) -> Result<(), String> { let claude_path = find_claude_binary()?; - let mut args = vec!["-p".to_string(), prompt]; + let (mut args, stdin_payload) = with_prompt_transport(Vec::new(), prompt); if let Some(m) = model { args.push("--model".to_string()); args.push(m); @@ -1489,7 +1516,7 @@ pub async fn execute_claude_code( args.extend(common_claude_args()); let cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref()); - spawn_claude_process(window, cmd, tab_id).await + spawn_claude_process(window, cmd, tab_id, stdin_payload).await } #[tauri::command] @@ -1503,7 +1530,7 @@ pub async fn continue_claude_code( ) -> Result<(), String> { let claude_path = find_claude_binary()?; - let mut args = vec!["-c".to_string(), "-p".to_string(), prompt]; + let (mut args, stdin_payload) = with_prompt_transport(vec!["-c".to_string()], prompt); if let Some(m) = model { args.push("--model".to_string()); args.push(m); @@ -1511,7 +1538,7 @@ pub async fn continue_claude_code( args.extend(common_claude_args()); let cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref()); - spawn_claude_process(window, cmd, tab_id).await + spawn_claude_process(window, cmd, tab_id, stdin_payload).await } #[tauri::command] @@ -1526,7 +1553,8 @@ pub async fn resume_claude_code( ) -> Result<(), String> { let claude_path = find_claude_binary()?; - let mut args = vec!["--resume".to_string(), session_id, "-p".to_string(), prompt]; + let (mut args, stdin_payload) = + with_prompt_transport(vec!["--resume".to_string(), session_id], prompt); if let Some(m) = model { args.push("--model".to_string()); args.push(m); @@ -1534,7 +1562,7 @@ pub async fn resume_claude_code( args.extend(common_claude_args()); let cmd = create_command(&claude_path, args, &project_path, effort_level.as_deref()); - spawn_claude_process(window, cmd, tab_id).await + spawn_claude_process(window, cmd, tab_id, stdin_payload).await } #[tauri::command] @@ -2012,6 +2040,23 @@ mod tests { assert!(prompt.contains("LaTeX")); } + #[test] + fn test_with_prompt_transport_always_includes_print_flag() { + let (args, stdin_payload) = + with_prompt_transport(vec!["--resume".to_string(), "abc".to_string()], "hello 文件".into()); + assert!(args.contains(&"-p".to_string())); + #[cfg(target_os = "windows")] + { + assert_eq!(stdin_payload.as_deref(), Some("hello 文件")); + assert!(!args.contains(&"hello 文件".to_string())); + } + #[cfg(not(target_os = "windows"))] + { + assert_eq!(stdin_payload, None); + assert_eq!(args.last().map(String::as_str), Some("hello 文件")); + } + } + // --- create_command --- #[test] diff --git a/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts b/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts new file mode 100644 index 0000000..660c00d --- /dev/null +++ b/apps/desktop/src/__tests__/stores/claude-chat-send-prompt.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; + +const { mockDocumentState, getDocumentState, createSnapshotMock } = vi.hoisted( + () => ({ + mockDocumentState: {} as any, + getDocumentState: vi.fn(), + createSnapshotMock: vi.fn(() => Promise.resolve(null)), + }), +); + +vi.mock("@/stores/document-store", () => ({ + useDocumentStore: { + getState: getDocumentState, + }, +})); + +vi.mock("@/stores/history-store", () => ({ + useHistoryStore: { + getState: vi.fn(() => ({ + createSnapshot: createSnapshotMock, + })), + }, +})); + +import { useClaudeChatStore } from "@/stores/claude-chat-store"; + +function resetClaudeChatStore() { + useClaudeChatStore.setState({ + messages: [], + sessionId: null, + isStreaming: false, + error: null, + totalInputTokens: 0, + totalOutputTokens: 0, + tabs: [ + { + id: "tab-default", + title: "New Chat", + sessionId: null, + messages: [], + isStreaming: false, + error: null, + totalInputTokens: 0, + totalOutputTokens: 0, + draft: { input: "", pinnedContexts: [] }, + }, + ], + activeTabId: "tab-default", + pendingInitialPrompt: null, + pendingAttachments: [], + selectedModel: "opus", + effortLevel: "medium", + _cancelledByUser: false, + }); +} + +function setMockDocumentState(overrides: Partial = {}) { + const content = [ + "Line 1", + "Line 2", + "Line 3", + "Line 4", + ].join("\n"); + + const state = { + projectRoot: "/project", + files: [ + { + id: "main.tex", + name: "main.tex", + relativePath: "main.tex", + absolutePath: "/project/main.tex", + type: "tex", + content, + isDirty: false, + }, + ], + activeFileId: "main.tex", + selectionRange: null, + saveAllFiles: vi.fn(() => Promise.resolve()), + refreshFiles: vi.fn(() => Promise.resolve()), + reloadFile: vi.fn(() => Promise.resolve()), + ...overrides, + }; + + Object.keys(mockDocumentState).forEach((key) => delete mockDocumentState[key]); + Object.assign(mockDocumentState, state); + getDocumentState.mockImplementation(() => mockDocumentState); + return state; +} + +describe("useClaudeChatStore.sendPrompt context assembly", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetClaudeChatStore(); + setMockDocumentState(); + }); + + it("uses a plain file label and full file content for whole-file mentions", async () => { + const wholeFileText = + "\\section{Intro}\nThis is the full file.\n\\textbf{Important note}"; + + await useClaudeChatStore.getState().sendPrompt("Please revise this", { + label: "@main.tex", + filePath: "main.tex", + selectedText: wholeFileText, + }); + + expect(invoke).toHaveBeenCalledWith( + "execute_claude_code", + expect.objectContaining({ + projectPath: "/project", + tabId: "tab-default", + prompt: expect.stringContaining("[Selection: @main.tex]"), + }), + ); + + const prompt = (vi.mocked(invoke).mock.calls[0]?.[1] as any)?.prompt as string; + expect(prompt).toContain("[Currently open file: main.tex]"); + expect(prompt).toContain("[Selection: @main.tex]"); + expect(prompt).toContain(wholeFileText); + + const userText = + useClaudeChatStore.getState().messages[0].message?.content?.[0].text; + expect(userText).toBe("@main.tex\nPlease revise this"); + }); + + it("uses a line-range label and only the selected slice for selection context", async () => { + const state = setMockDocumentState({ + files: [ + { + id: "main.tex", + name: "main.tex", + relativePath: "main.tex", + absolutePath: "/project/main.tex", + type: "tex", + content: "alpha\nbeta\ngamma\ndelta", + isDirty: false, + }, + ], + selectionRange: { start: 6, end: 16 }, + }); + + await useClaudeChatStore.getState().sendPrompt("Please revise this"); + + expect(invoke).toHaveBeenCalledWith( + "execute_claude_code", + expect.objectContaining({ + projectPath: "/project", + tabId: "tab-default", + prompt: expect.stringContaining("[Selection: @main.tex:2:1-3:6]"), + }), + ); + + const prompt = (vi.mocked(invoke).mock.calls[0]?.[1] as any)?.prompt as string; + expect(prompt).toContain("[Currently open file: main.tex]"); + expect(prompt).toContain("[Selection: @main.tex:2:1-3:6]"); + expect(prompt).toContain("[Selected text:\nbeta\ngamma\n]"); + expect(prompt).not.toContain("alpha\na"); + expect(prompt).not.toContain("\ndelta"); + + const userText = + useClaudeChatStore.getState().messages[0].message?.content?.[0].text; + expect(userText).toBe("@main.tex:2:1-3:6\nPlease revise this"); + expect(state.saveAllFiles).not.toHaveBeenCalled(); + expect(createSnapshotMock).toHaveBeenCalledWith( + "/project", + "[claude] Before Claude edit", + ); + }); +});