mirror of
https://github.com/delibae/claude-prism.git
synced 2026-08-28 05:14:59 +00:00
fix: send long Windows Claude prompts via stdin
This commit is contained in:
parent
0f2635ab7a
commit
cf672e44d5
2 changed files with 224 additions and 7 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue