fix: send long Windows Claude prompts via stdin

This commit is contained in:
Weilin Cai 2026-04-07 16:02:39 +08:00
parent 0f2635ab7a
commit cf672e44d5
2 changed files with 224 additions and 7 deletions

View file

@ -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<String>, prompt: String) -> (Vec<String>, Option<String>) {
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<String>,
) -> 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]

View file

@ -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<any> = {}) {
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",
);
});
});