feat: add Ollama provider support

- Add Rust Ollama backend module with /api/tags status check, streaming
  /api/chat command, and cancellation support.
- Add frontend provider selection (Claude / Ollama) in chat composer.
- Persist Ollama URL/model in settings store and per-tab state.
- Implement structured edit parser for <proposed-change> blocks emitted
  by Ollama models and convert them into proposed changes.
- Add use-ollama-events hook to stream responses into the chat UI.
- Update CSP to allow localhost Ollama connections.
- Add OLLAMA.md documentation and mention in README.
- Add/extend frontend tests; all 165 pass.

ClaudePrism users can now chat with local Ollama models as an
alternative to Claude Code, including offline file-edit suggestions.
This commit is contained in:
Chakkrit Termritthikun 2026-06-14 21:43:01 +07:00
parent 1939030e9a
commit 30157821f7
18 changed files with 1566 additions and 147 deletions

49
OLLAMA.md Normal file
View file

@ -0,0 +1,49 @@
# Ollama Support in ClaudePrism
ClaudePrism can use locally-hosted [Ollama](https://ollama.com/) models as an alternative to the Claude Code backend. This lets you chat about your LaTeX project and receive structured edit suggestions while keeping everything on your machine.
## What works
- **Streaming chat** with any Ollama model.
- **Structured file edits**: Ollama can emit `<proposed-change>` blocks that ClaudePrism converts into proposed changes, just like Claude's Write/Edit tools.
- **Per-tab provider switching**: each conversation can independently use Claude or Ollama, or you can change the provider on the fly from the composer.
## What does not work (yet)
- Native Claude Code tool use (`Bash`, `Read`, `Write`, etc.) is **not available** through Ollama.
- Persistent sessions are **not** stored for Ollama; each prompt sends the current conversation history.
- Claude-specific slash commands and skills rely on the Claude Code CLI and are only available with the Claude provider.
## Setup
1. [Install Ollama](https://ollama.com/download) and start it locally.
2. Pull a model:
```bash
ollama pull llama3
```
3. Open ClaudePrism and switch the chat provider to **Ollama** from the composer model picker.
4. Confirm the Ollama URL (default: `http://localhost:11434`) and click **Refresh** to load your local models.
5. Select a model and start chatting.
## Structured edits
When you ask Ollama to modify a file, it can output edits in this format:
```xml
<proposed-change file="relative/path.tex">
<old>
exact existing text
</old>
<new>
replacement text
</new>
</proposed-change>
```
ClaudePrism parses these blocks after the response finishes and shows them in the **Proposed Changes** panel. You can accept or reject each change, just like edits from Claude.
## Troubleshooting
- **"Could not connect to Ollama"** — make sure the Ollama server is running and reachable at the configured URL.
- **"No models found"** — pull at least one model with `ollama pull <model>`.
- **Edits not applied** — the old text in the `<old>` block must closely match the file. The parser tolerates leading/trailing blank lines; for larger mismatches, try rephrasing your request or making the change manually.

View file

@ -111,6 +111,9 @@ Chat with Claude directly in the editor. Select between Sonnet, Opus, Haiku mode
<img src="./assets/demo/claudecommand.webp" alt="Claude AI Assistant & Slash Commands" width="600" />
</p>
### Local Models via Ollama
Prefer to keep AI inference on your own machine? ClaudePrism also supports [Ollama](https://ollama.com/) for local chat and structured file edits. Switch between Claude and Ollama per conversation from the composer model picker. See [OLLAMA.md](./OLLAMA.md) for setup instructions.
### History & Proposed Changes
Every save creates a snapshot in a local Git repository (`.claudeprism/history.git/`). Label important checkpoints, browse diffs between any two snapshots, and restore previous versions. When Claude suggests edits, changes appear in a dedicated panel with visual diffs — accept or reject per chunk, or apply/undo all at once (`⌘Y` / `⌘N`).

View file

@ -502,13 +502,14 @@ dependencies = [
[[package]]
name = "claude-prism-desktop"
version = "1.1.7"
version = "1.2.0"
dependencies = [
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"dotenvy",
"flate2",
"futures-util",
"git2",
"hmac",
"objc2",
@ -4074,6 +4075,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http 1.4.2",
"http-body 1.0.1",
"http-body-util",
@ -4093,12 +4095,14 @@ dependencies = [
"sync_wrapper 1.0.2",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
@ -4138,7 +4142,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.5.0",
"web-sys",
]
@ -6520,6 +6524,19 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"

View file

@ -23,7 +23,8 @@ serde_json = "1"
serde_yaml = "0.9"
tokio = { version = "1", features = ["full"] }
dirs = "5"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
futures-util = "0.3"
hmac = "0.12"
sha1 = "0.10"
base64 = "0.22"

View file

@ -1,6 +1,7 @@
mod claude;
mod history;
mod latex;
mod ollama;
mod skills;
mod slash_commands;
mod uv;
@ -341,6 +342,7 @@ pub fn run() {
.plugin(tauri_plugin_process::init())
.manage(claude::ClaudeProcessState::default())
.manage(latex::LatexCompilerState::default())
.manage(ollama::OllamaState::default())
.manage(zotero::ZoteroOAuthState::default())
.setup(|app| {
// Safety net: force-show the main window after a timeout if the
@ -385,6 +387,9 @@ pub fn run() {
claude::set_claude_fast_mode,
claude::list_claude_sessions,
claude::load_session_history,
ollama::check_ollama_status,
ollama::send_ollama_message,
ollama::cancel_ollama_message,
zotero::zotero_start_oauth,
zotero::zotero_complete_oauth,
zotero::zotero_cancel_oauth,

View file

@ -0,0 +1,477 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use futures_util::StreamExt;
use reqwest::header::CONTENT_TYPE;
use serde::{Deserialize, Serialize};
use tauri::{Emitter, WebviewWindow};
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
// ─── Request / Response Types ───
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OllamaChatMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Serialize)]
struct OllamaChatRequest {
model: String,
messages: Vec<OllamaChatMessage>,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
options: Option<serde_json::Map<String, serde_json::Value>>,
}
#[derive(Debug, Deserialize)]
struct OllamaChatResponse {
#[serde(default)]
message: Option<OllamaMessage>,
#[serde(default)]
done: bool,
#[serde(default)]
eval_count: Option<u64>,
#[serde(default)]
prompt_eval_count: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct OllamaMessage {
role: String,
content: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct OllamaModelEntry {
name: String,
}
#[derive(Debug, Serialize)]
pub struct OllamaStatus {
pub available: bool,
pub models: Vec<String>,
pub error: Option<String>,
}
// ─── Event Payloads ───
#[derive(Clone, serde::Serialize)]
struct OllamaOutputEvent {
tab_id: String,
data: String,
}
#[derive(Clone, serde::Serialize)]
struct OllamaCompleteEvent {
tab_id: String,
success: bool,
}
#[derive(Clone, serde::Serialize)]
struct OllamaErrorEvent {
tab_id: String,
data: String,
}
// ─── Cancellation State ───
#[derive(Default, Clone)]
pub struct OllamaState {
/// Streaming task handles keyed by `window_label:tab_id`.
pub tasks: Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
}
fn process_key(window: &WebviewWindow, tab_id: &str) -> String {
format!("{}:{}", window.label(), tab_id)
}
/// System prompt adapted from the Claude Code integration.
/// Includes instructions for the structured edit XML format.
fn system_prompt() -> String {
concat!(
"You are an AI assistant integrated into a LaTeX document editor (Prism). ",
"You are running as a local Ollama model. ",
"Follow these rules strictly:\n",
"1. PLANNING FIRST: Before making changes, briefly describe your plan. ",
"Break large tasks into small, incremental steps.\n",
"2. INCREMENTAL EDITS: Never rewrite an entire file unless asked. ",
"Prefer editing existing content over replacing it wholesale.\n",
"3. PRESERVE EXISTING CONTENT: Keep the existing preamble, packages, and structure intact. ",
"Only add or modify what is needed for the current step.\n",
"4. LaTeX BEST PRACTICES: Use proper sectioning (\\chapter, \\section, \\subsection), ",
"citations (\\cite), cross-references (\\label, \\ref), and BibTeX for bibliographies.\n",
"5. PYTHON: If a .venv/ exists in the project, it is already activated. ",
"Use `uv pip install` to add packages and `python` to run scripts.\n",
"6. STRUCTURED EDITS: When you need to modify a file, emit one or more blocks exactly like this:\n",
"<proposed-change file=\"relative/path.tex\">\n",
"<old>\n",
"exact existing text to replace\n",
"</old>\n",
"<new>\n",
"replacement text\n",
"</new>\n",
"</proposed-change>\n",
"The old text must match the file exactly (line endings may differ). ",
"Place edits after your explanatory text, not inside it."
)
.to_string()
}
/// Build the chat request body, prepending the system prompt.
fn build_request(
model: String,
mut messages: Vec<OllamaChatMessage>,
stream: bool,
) -> OllamaChatRequest {
let system = OllamaChatMessage {
role: "system".to_string(),
content: system_prompt(),
};
messages.insert(0, system);
OllamaChatRequest {
model,
messages,
stream,
options: None,
}
}
/// Emit a text chunk shaped like a Claude assistant stream message.
fn emit_text_chunk(window: &WebviewWindow, tab_id: &str, text: &str) {
if text.is_empty() {
return;
}
let payload = serde_json::json!({
"type": "assistant",
"message": {
"content": [{ "type": "text", "text": text }]
}
});
let data = match serde_json::to_string(&payload) {
Ok(s) => s,
Err(e) => {
eprintln!("[ollama] failed to serialize chunk: {}", e);
return;
}
};
let _ = window.emit(
"ollama-output",
OllamaOutputEvent {
tab_id: tab_id.to_string(),
data,
},
);
}
/// Emit a final `result` message with token counts.
fn emit_result(window: &WebviewWindow, tab_id: &str, prompt_tokens: u64, eval_tokens: u64) {
let payload = serde_json::json!({
"type": "result",
"duration_ms": 0,
"duration_api_ms": 0,
"usage": {
"input_tokens": prompt_tokens,
"output_tokens": eval_tokens,
}
});
if let Ok(data) = serde_json::to_string(&payload) {
let _ = window.emit(
"ollama-output",
OllamaOutputEvent {
tab_id: tab_id.to_string(),
data,
},
);
}
}
/// Emit an error event.
fn emit_error(window: &WebviewWindow, tab_id: &str, message: &str) {
eprintln!("[ollama] error for tab {}: {}", tab_id, message);
let _ = window.emit(
"ollama-error",
OllamaErrorEvent {
tab_id: tab_id.to_string(),
data: message.to_string(),
},
);
}
/// Emit the completion event.
fn emit_complete(window: &WebviewWindow, tab_id: &str, success: bool) {
let _ = window.emit(
"ollama-complete",
OllamaCompleteEvent {
tab_id: tab_id.to_string(),
success,
},
);
}
/// Stream the Ollama response and emit events.
async fn stream_ollama_response(
window: WebviewWindow,
tab_id: String,
base_url: String,
request_body: OllamaChatRequest,
) {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(600))
.build()
{
Ok(c) => c,
Err(e) => {
emit_error(&window, &tab_id, &format!("Failed to build HTTP client: {}", e));
emit_complete(&window, &tab_id, false);
return;
}
};
let url = format!("{}/api/chat", base_url.trim_end_matches('/'));
let body_json = match serde_json::to_string(&request_body) {
Ok(j) => j,
Err(e) => {
emit_error(&window, &tab_id, &format!("Failed to serialize request: {}", e));
emit_complete(&window, &tab_id, false);
return;
}
};
eprintln!("[ollama] POST {} model={}", url, request_body.model);
let response = match client
.post(&url)
.header(CONTENT_TYPE, "application/json")
.body(body_json)
.send()
.await
{
Ok(resp) => resp,
Err(e) => {
let msg = if e.is_connect() {
format!(
"Could not connect to Ollama at {}. Is Ollama running?",
base_url
)
} else {
format!("Ollama request failed: {}", e)
};
emit_error(&window, &tab_id, &msg);
emit_complete(&window, &tab_id, false);
return;
}
};
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
emit_error(
&window,
&tab_id,
&format!("Ollama returned HTTP {}: {}", status, body),
);
emit_complete(&window, &tab_id, false);
return;
}
let mut prompt_tokens: u64 = 0;
let mut eval_tokens: u64 = 0;
let mut stream = response.bytes_stream();
let mut buffer = String::new();
while let Some(chunk_result) = stream.next().await {
let chunk = match chunk_result {
Ok(c) => c,
Err(e) => {
emit_error(&window, &tab_id, &format!("Stream read error: {}", e));
break;
}
};
buffer.push_str(&String::from_utf8_lossy(&chunk));
// Ollama streams one JSON object per line (NDJSON).
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim().to_string();
buffer.replace_range(..pos + 1, "");
if line.is_empty() {
continue;
}
let parsed: OllamaChatResponse = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
eprintln!("[ollama] failed to parse line: {} — error: {}", line, e);
continue;
}
};
if let Some(msg) = parsed.message {
emit_text_chunk(&window, &tab_id, &msg.content);
}
if parsed.done {
if let Some(n) = parsed.prompt_eval_count {
prompt_tokens = n;
}
if let Some(n) = parsed.eval_count {
eval_tokens = n;
}
break;
}
}
}
emit_result(&window, &tab_id, prompt_tokens, eval_tokens);
emit_complete(&window, &tab_id, true);
}
// ─── Tauri Commands ───
#[tauri::command]
pub async fn check_ollama_status(base_url: String) -> Result<OllamaStatus, String> {
let url = format!("{}/api/tags", base_url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
let response = client.get(&url).send().await;
match response {
Ok(resp) if resp.status().is_success() => {
let body = resp
.text()
.await
.map_err(|e| format!("Failed to read Ollama response: {}", e))?;
let parsed: serde_json::Value =
serde_json::from_str(&body).map_err(|e| format!("Invalid JSON from Ollama: {}", e))?;
let models = parsed
.get("models")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(String::from))
.collect()
})
.unwrap_or_default();
Ok(OllamaStatus {
available: true,
models,
error: None,
})
}
Ok(resp) => Ok(OllamaStatus {
available: false,
models: Vec::new(),
error: Some(format!("Ollama returned HTTP {}", resp.status())),
}),
Err(e) => Ok(OllamaStatus {
available: false,
models: Vec::new(),
error: Some(format!("Could not reach Ollama: {}", e)),
}),
}
}
#[tauri::command]
pub async fn send_ollama_message(
window: WebviewWindow,
state: tauri::State<'_, OllamaState>,
base_url: String,
model: String,
messages: Vec<OllamaChatMessage>,
tab_id: String,
_project_path: String,
) -> Result<(), String> {
if model.trim().is_empty() {
return Err("No Ollama model selected".to_string());
}
let key = process_key(&window, &tab_id);
let request = build_request(model, messages, true);
let win = window.clone();
// Abort any existing stream for this tab.
{
let mut tasks = state.tasks.lock().await;
if let Some(handle) = tasks.remove(&key) {
handle.abort();
}
}
let handle = tokio::spawn(async move {
stream_ollama_response(win, tab_id, base_url, request).await;
});
{
let mut tasks = state.tasks.lock().await;
tasks.insert(key, handle);
}
Ok(())
}
#[tauri::command]
pub async fn cancel_ollama_message(
window: WebviewWindow,
state: tauri::State<'_, OllamaState>,
tab_id: String,
) -> Result<(), String> {
let key = process_key(&window, &tab_id);
let mut tasks = state.tasks.lock().await;
if let Some(handle) = tasks.remove(&key) {
handle.abort();
}
let _ = window.emit(
"ollama-complete",
OllamaCompleteEvent {
tab_id,
success: false,
},
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_request_prepends_system_prompt() {
let request = build_request(
"llama3".to_string(),
vec![OllamaChatMessage {
role: "user".to_string(),
content: "hello".to_string(),
}],
true,
);
assert_eq!(request.model, "llama3");
assert!(request.stream);
assert_eq!(request.messages.len(), 2);
assert_eq!(request.messages[0].role, "system");
assert!(request.messages[0].content.contains("Prism"));
assert!(request.messages[0].content.contains("proposed-change"));
assert_eq!(request.messages[1].role, "user");
assert_eq!(request.messages[1].content, "hello");
}
#[test]
fn test_emit_text_chunk_serializes_claude_shape() {
// This test verifies the emitted JSON shape by calling the helper logic.
let text = "hi";
let payload = serde_json::json!({
"type": "assistant",
"message": {
"content": [{ "type": "text", "text": text }]
}
});
let data = serde_json::to_string(&payload).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&data).unwrap();
assert_eq!(parsed["type"], "assistant");
assert_eq!(parsed["message"]["content"][0]["type"], "text");
assert_eq!(parsed["message"]["content"][0]["text"], "hi");
}
}

View file

@ -31,7 +31,7 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; connect-src 'self' ipc: http://ipc.localhost https://api.zotero.org https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' asset: http://asset.localhost data: blob:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'self' blob:",
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; connect-src 'self' ipc: http://ipc.localhost http://localhost:* https://api.zotero.org https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' asset: http://asset.localhost data: blob:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'self' blob:",
"dangerousDisableAssetCspModification": true
}
},

View file

@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import {
parseOllamaProposedChanges,
applyOllamaEdit,
} from "@/lib/ollama-edit-parser";
describe("parseOllamaProposedChanges", () => {
it("returns an empty array when there are no edit blocks", () => {
expect(parseOllamaProposedChanges("Hello, world!")).toEqual([]);
});
it("parses a single proposed-change block", () => {
const text = `
Some explanation.
<proposed-change file="main.tex">
<old>
\\section{Introduction}
Hello.
</old>
<new>
\\section{Introduction}
Hello, world!
</new>
</proposed-change>
`;
const edits = parseOllamaProposedChanges(text);
expect(edits).toHaveLength(1);
expect(edits[0]).toEqual({
filePath: "main.tex",
oldText: "\\section{Introduction}\nHello.",
newText: "\\section{Introduction}\nHello, world!",
});
});
it("parses multiple proposed-change blocks", () => {
const text = `
<proposed-change file="a.tex">
<old>alpha</old>
<new>ALPHA</new>
</proposed-change>
<proposed-change file="b.tex">
<old>beta</old>
<new>BETA</new>
</proposed-change>
`;
const edits = parseOllamaProposedChanges(text);
expect(edits).toHaveLength(2);
expect(edits[0].filePath).toBe("a.tex");
expect(edits[1].filePath).toBe("b.tex");
});
it("ignores partial blocks without a closing tag", () => {
const text = `
<proposed-change file="main.tex">
<old>old text</old>
<new>new text</new>
`;
expect(parseOllamaProposedChanges(text)).toEqual([]);
});
});
describe("applyOllamaEdit", () => {
it("replaces exact old text", () => {
const result = applyOllamaEdit(
"\\section{Intro}\nHello.\n\\section{Body}",
"\\section{Intro}\nHello.\n",
"\\section{Intro}\nHello, world!\n",
);
expect(result).toBe("\\section{Intro}\nHello, world!\n\\section{Body}");
});
it("returns null when old text is not found", () => {
const result = applyOllamaEdit("some content", "missing text", "new text");
expect(result).toBeNull();
});
it("falls back to trimmed matching", () => {
const result = applyOllamaEdit(
"\\section{Intro}\nHello.\n",
"\n\\section{Intro}\nHello.\n",
"\\section{Intro}\nHello, world!",
);
expect(result).toBe("\\section{Intro}\nHello, world!\n");
});
});

View file

@ -23,6 +23,14 @@ vi.mock("@/stores/history-store", () => ({
},
}));
vi.mock("@/stores/settings-store", () => ({
useSettingsStore: {
getState: vi.fn(() => ({
ollamaBaseUrl: "http://localhost:11434",
})),
},
}));
import { useClaudeChatStore } from "@/stores/claude-chat-store";
function resetClaudeChatStore() {
@ -43,6 +51,8 @@ function resetClaudeChatStore() {
error: null,
totalInputTokens: 0,
totalOutputTokens: 0,
provider: "claude",
ollamaModel: "",
draft: { input: "", pinnedContexts: [] },
},
],
@ -51,6 +61,8 @@ function resetClaudeChatStore() {
pendingAttachments: [],
selectedModel: "opus",
effortLevel: "medium",
provider: "claude",
ollamaModel: "",
_cancelledByUser: false,
});
}
@ -168,4 +180,41 @@ describe("useClaudeChatStore.sendPrompt context assembly", () => {
"[claude] Before Claude edit",
);
});
it("invokes send_ollama_message when provider is Ollama", async () => {
useClaudeChatStore.setState({
provider: "ollama",
ollamaModel: "llama3",
tabs: [
{
...useClaudeChatStore.getState().tabs[0],
provider: "ollama",
ollamaModel: "llama3",
},
],
});
await useClaudeChatStore.getState().sendPrompt("Explain this section");
expect(invoke).toHaveBeenCalledWith(
"send_ollama_message",
expect.objectContaining({
baseUrl: "http://localhost:11434",
model: "llama3",
tabId: "tab-default",
projectPath: "/project",
}),
);
const messagesArg = (vi.mocked(invoke).mock.calls[0]?.[1] as any)
?.messages as { role: string; content: string }[];
expect(messagesArg).toHaveLength(1);
expect(messagesArg[0].role).toBe("user");
expect(messagesArg[0].content).toContain("Explain this section");
expect(createSnapshotMock).toHaveBeenCalledWith(
"/project",
"[ollama] Before Ollama response",
);
});
});

View file

@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest";
import { offsetToLineCol } from "@/stores/claude-chat-store";
import {
offsetToLineCol,
useClaudeChatStore,
} from "@/stores/claude-chat-store";
describe("offsetToLineCol", () => {
it("returns line 1, col 1 for offset 0 on empty string", () => {
@ -38,3 +41,68 @@ describe("offsetToLineCol", () => {
expect(offsetToLineCol("\n\n", 2)).toEqual({ line: 3, col: 1 });
});
});
describe("useClaudeChatStore._appendStreamingText", () => {
it("creates a new assistant message when the last message is not assistant", () => {
useClaudeChatStore.setState({
tabs: [
{
id: "tab-1",
title: "Chat",
sessionId: null,
messages: [
{
type: "user",
message: { content: [{ type: "text", text: "Hi" }] },
},
],
isStreaming: true,
error: null,
totalInputTokens: 0,
totalOutputTokens: 0,
provider: "ollama",
ollamaModel: "llama3",
draft: { input: "", pinnedContexts: [] },
},
],
activeTabId: "tab-1",
});
useClaudeChatStore.getState()._appendStreamingText("tab-1", "Hello");
const messages = useClaudeChatStore.getState().tabs[0].messages;
expect(messages).toHaveLength(2);
expect(messages[1].type).toBe("assistant");
expect(messages[1].message?.content?.[0].text).toBe("Hello");
});
it("merges text into the existing assistant message", () => {
useClaudeChatStore.setState({
tabs: [
{
id: "tab-1",
title: "Chat",
sessionId: null,
messages: [
{
type: "assistant",
message: { content: [{ type: "text", text: "Hello" }] },
},
],
isStreaming: true,
error: null,
totalInputTokens: 0,
totalOutputTokens: 0,
provider: "ollama",
ollamaModel: "llama3",
draft: { input: "", pinnedContexts: [] },
},
],
activeTabId: "tab-1",
});
useClaudeChatStore.getState()._appendStreamingText("tab-1", ", world!");
const messages = useClaudeChatStore.getState().tabs[0].messages;
expect(messages).toHaveLength(1);
expect(messages[0].message?.content?.[0].text).toBe("Hello, world!");
});
});

View file

@ -49,6 +49,8 @@ function resetStores() {
error: null,
totalInputTokens: 0,
totalOutputTokens: 0,
provider: "claude",
ollamaModel: "",
draft: { input: "", pinnedContexts: [] },
},
],

View file

@ -32,7 +32,9 @@ import { invoke } from "@tauri-apps/api/core";
import {
useClaudeChatStore,
offsetToLineCol,
type AiProvider,
} from "@/stores/claude-chat-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
import { getUniqueTargetName } from "@/lib/tauri/fs";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
@ -74,10 +76,55 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
const setSelectedModel = useClaudeChatStore((s) => s.setSelectedModel);
const effortLevel = useClaudeChatStore((s) => s.effortLevel);
const setEffortLevel = useClaudeChatStore((s) => s.setEffortLevel);
const provider = useClaudeChatStore((s) => s.provider);
const setProvider = useClaudeChatStore((s) => s.setProvider);
const ollamaModel = useClaudeChatStore((s) => s.ollamaModel);
const setOllamaModel = useClaudeChatStore((s) => s.setOllamaModel);
const ollamaBaseUrl = useSettingsStore((s) => s.ollamaBaseUrl);
const setOllamaBaseUrl = useSettingsStore((s) => s.setOllamaBaseUrl);
const activeTabId = useClaudeChatStore((s) => s.activeTabId);
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Ollama model list state
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [ollamaStatus, setOllamaStatus] = useState<{
available: boolean;
error: string | null;
loading: boolean;
}>({ available: false, error: null, loading: false });
const refreshOllamaModels = useCallback(async () => {
setOllamaStatus((prev) => ({ ...prev, loading: true }));
try {
const result = await invoke<{
available: boolean;
models: string[];
error?: string;
}>("check_ollama_status", { baseUrl: ollamaBaseUrl });
setOllamaModels(result.models);
setOllamaStatus({
available: result.available,
error: result.error ?? null,
loading: false,
});
if (
result.available &&
result.models.length > 0 &&
!result.models.includes(ollamaModel)
) {
setOllamaModel(result.models[0]);
}
} catch (err) {
setOllamaModels([]);
setOllamaStatus({
available: false,
error: err instanceof Error ? err.message : String(err),
loading: false,
});
}
}, [ollamaBaseUrl, ollamaModel, setOllamaModel]);
// Model picker state
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const modelPickerRef = useRef<HTMLDivElement>(null);
@ -87,7 +134,7 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
bottom: 0,
});
// Recalculate popup position when it opens
// Recalculate popup position and refresh Ollama models when the picker opens
useLayoutEffect(() => {
if (!modelPickerOpen || !modelButtonRef.current) return;
const rect = modelButtonRef.current.getBoundingClientRect();
@ -95,7 +142,10 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
left: rect.left,
bottom: window.innerHeight - rect.top + 4,
});
}, [modelPickerOpen]);
if (provider === "ollama") {
refreshOllamaModels();
}
}, [modelPickerOpen, provider, refreshOllamaModels]);
// Pinned contexts — supports multiple files/selections
const [pinnedContexts, setPinnedContexts] = useState<PinnedContext[]>([]);
@ -681,101 +731,202 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
createPortal(
<div
ref={modelPickerRef}
className="fixed w-64 rounded-lg border border-border bg-background shadow-lg"
className="fixed w-72 rounded-lg border border-border bg-background shadow-lg"
style={{
left: pickerPos.left,
bottom: pickerPos.bottom,
zIndex: 9999,
}}
>
{/* Models */}
<div className="p-1">
<div className="px-2 py-1 font-medium text-muted-foreground text-xs">
Model
</div>
{[
{
id: "sonnet" as const,
name: "Sonnet",
desc: "Fast, efficient for most tasks",
icon: <ZapIcon className="size-3.5" />,
},
{
id: "opus" as const,
name: "Opus",
desc: "Most capable, complex reasoning",
icon: <SparklesIcon className="size-3.5" />,
},
{
id: "haiku" as const,
name: "Haiku",
desc: "Fastest, simple tasks",
icon: <RabbitIcon className="size-3.5" />,
},
{
id: "opusplan" as const,
name: "OpusPlan",
desc: "Opus for planning, Sonnet for execution",
icon: <LayersIcon className="size-3.5" />,
},
].map((m) => (
<button
key={m.id}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
selectedModel === m.id
? "bg-accent text-accent-foreground"
: "hover:bg-muted",
)}
onClick={() => setSelectedModel(m.id)}
>
{m.icon}
<div className="min-w-0 flex-1">
<div className="font-medium text-xs">{m.name}</div>
<div className="truncate text-muted-foreground text-xs">
{m.desc}
</div>
</div>
{selectedModel === m.id && (
<CheckIcon className="size-3 shrink-0" />
)}
</button>
))}
</div>
<div className="border-border border-t" />
{/* Effort level */}
<div className="p-2">
<div className="mb-1.5 flex items-center justify-between px-1">
<span className="font-medium text-muted-foreground text-xs">
Effort
</span>
<span className="text-muted-foreground text-xs">
{effortLevel === "low"
? "Low"
: effortLevel === "medium"
? "Medium"
: "High"}
</span>
{/* Provider switch */}
<div className="border-border border-b p-2">
<div className="mb-1.5 px-1 font-medium text-muted-foreground text-xs">
Provider
</div>
<div className="flex gap-1">
{(["low", "medium", "high"] as const).map((level) => (
{(["claude", "ollama"] as AiProvider[]).map((p) => (
<button
key={level}
key={p}
onClick={() => setProvider(p)}
className={cn(
"flex-1 rounded-md py-1 text-center font-medium text-xs transition-colors",
effortLevel === level
provider === p
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80",
)}
onClick={() => setEffortLevel(level)}
>
{level === "low" ? "L" : level === "medium" ? "M" : "H"}
{p === "claude" ? "Claude" : "Ollama"}
</button>
))}
</div>
</div>
{provider === "claude" ? (
<>
{/* Claude models */}
<div className="p-1">
<div className="px-2 py-1 font-medium text-muted-foreground text-xs">
Model
</div>
{[
{
id: "sonnet" as const,
name: "Sonnet",
desc: "Fast, efficient for most tasks",
icon: <ZapIcon className="size-3.5" />,
},
{
id: "opus" as const,
name: "Opus",
desc: "Most capable, complex reasoning",
icon: <SparklesIcon className="size-3.5" />,
},
{
id: "haiku" as const,
name: "Haiku",
desc: "Fastest, simple tasks",
icon: <RabbitIcon className="size-3.5" />,
},
{
id: "opusplan" as const,
name: "OpusPlan",
desc: "Opus for planning, Sonnet for execution",
icon: <LayersIcon className="size-3.5" />,
},
].map((m) => (
<button
key={m.id}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
selectedModel === m.id
? "bg-accent text-accent-foreground"
: "hover:bg-muted",
)}
onClick={() => setSelectedModel(m.id)}
>
{m.icon}
<div className="min-w-0 flex-1">
<div className="font-medium text-xs">{m.name}</div>
<div className="truncate text-muted-foreground text-xs">
{m.desc}
</div>
</div>
{selectedModel === m.id && (
<CheckIcon className="size-3 shrink-0" />
)}
</button>
))}
</div>
<div className="border-border border-t" />
{/* Effort level */}
<div className="p-2">
<div className="mb-1.5 flex items-center justify-between px-1">
<span className="font-medium text-muted-foreground text-xs">
Effort
</span>
<span className="text-muted-foreground text-xs">
{effortLevel === "low"
? "Low"
: effortLevel === "medium"
? "Medium"
: "High"}
</span>
</div>
<div className="flex gap-1">
{(["low", "medium", "high"] as const).map((level) => (
<button
key={level}
className={cn(
"flex-1 rounded-md py-1 text-center font-medium text-xs transition-colors",
effortLevel === level
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80",
)}
onClick={() => setEffortLevel(level)}
>
{level === "low" ? "L" : level === "medium" ? "M" : "H"}
</button>
))}
</div>
</div>
</>
) : (
<>
{/* Ollama settings */}
<div className="space-y-2 p-2">
<div>
<label
htmlFor="ollama-url"
className="mb-1 block px-1 font-medium text-muted-foreground text-xs"
>
Ollama URL
</label>
<div className="flex gap-1">
<input
id="ollama-url"
type="text"
value={ollamaBaseUrl}
onChange={(e) => setOllamaBaseUrl(e.target.value)}
className="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs outline-none focus:border-ring"
placeholder="http://localhost:11434"
/>
<button
type="button"
onClick={() => refreshOllamaModels()}
disabled={ollamaStatus.loading}
className="rounded-md bg-muted px-2 py-1 font-medium text-muted-foreground text-xs transition-colors hover:bg-muted/80 disabled:opacity-50"
>
{ollamaStatus.loading ? "..." : "Refresh"}
</button>
</div>
</div>
<div>
<div className="mb-1 flex items-center justify-between px-1">
<span className="font-medium text-muted-foreground text-xs">
Model
</span>
<span className="text-xs">
{ollamaStatus.loading ? (
<span className="text-muted-foreground">
Checking
</span>
) : ollamaStatus.available ? (
<span className="text-green-600">Connected</span>
) : (
<span className="text-destructive">
{ollamaStatus.error || "Unreachable"}
</span>
)}
</span>
</div>
{ollamaModels.length === 0 ? (
<div className="rounded-md bg-muted px-2 py-1.5 text-muted-foreground text-xs">
No models found. Pull a model with{" "}
<code className="rounded bg-background px-1 py-0.5">
ollama pull &lt;model&gt;
</code>
.
</div>
) : (
<select
value={ollamaModel}
onChange={(e) => setOllamaModel(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2 py-1 text-xs outline-none focus:border-ring"
>
{ollamaModels.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
)}
</div>
</div>
</>
)}
</div>,
document.body,
)}
@ -904,20 +1055,24 @@ export const ChatComposer: FC<{ isOpen?: boolean }> = ({ isOpen }) => {
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground"
>
<span>
{selectedModel === "sonnet"
? "Sonnet"
: selectedModel === "opus"
? "Opus"
: selectedModel === "haiku"
? "Haiku"
: "OpusPlan"}
{provider === "ollama"
? ollamaModel || "Ollama"
: selectedModel === "sonnet"
? "Sonnet"
: selectedModel === "opus"
? "Opus"
: selectedModel === "haiku"
? "Haiku"
: "OpusPlan"}
</span>
<span className="text-muted-foreground/60">
{effortLevel === "low"
? "L"
: effortLevel === "medium"
? "M"
: "H"}
{provider === "ollama"
? "local"
: effortLevel === "low"
? "L"
: effortLevel === "medium"
? "M"
: "H"}
</span>
<ChevronDownIcon className="size-3" />
</button>

View file

@ -152,7 +152,7 @@ export const ChatMessages: FC = () => {
>
{displayMessages.length === 0 && !isStreaming && (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
Ask Claude about your LaTeX document...
Ask about your LaTeX document...
</div>
)}

View file

@ -9,6 +9,7 @@ import {
import { cn } from "@/lib/utils";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
import { useClaudeEvents } from "@/hooks/use-claude-events";
import { useOllamaEvents } from "@/hooks/use-ollama-events";
import { ChatMessages } from "./chat-messages";
import { ChatComposer } from "./chat-composer";
import { ChatTabBar } from "./chat-tab-bar";
@ -17,8 +18,9 @@ const MIN_HEIGHT = 150;
const DEFAULT_HEIGHT = 360;
export function ClaudeChatDrawer() {
// Initialize event listeners for Claude streaming
// Initialize event listeners for Claude and Ollama streaming
useClaudeEvents();
useOllamaEvents();
const anyStreaming = useClaudeChatStore((s) =>
s.tabs.some((t) => t.isStreaming),

View file

@ -0,0 +1,235 @@
import { useEffect, useRef } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
import { useDocumentStore } from "@/stores/document-store";
import { useHistoryStore } from "@/stores/history-store";
import { useProposedChangesStore } from "@/stores/proposed-changes-store";
import {
parseOllamaProposedChanges,
applyOllamaEdit,
} from "@/lib/ollama-edit-parser";
import { createLogger } from "@/lib/debug/logger";
const log = createLogger("ollama-event");
interface OllamaOutputPayload {
tab_id: string;
data: string;
}
interface OllamaCompletePayload {
tab_id: string;
success: boolean;
}
interface OllamaErrorPayload {
tab_id: string;
data: string;
}
/**
* Hook that manages Tauri event listeners for Ollama streaming output.
*
* Ollama responses are plain text, so we accumulate them per tab and convert
* each chunk into a Claude-shaped assistant message so the existing chat UI
* can render it. After the stream completes, we parse the full response for
* `<proposed-change>` blocks and register them as proposed changes.
*/
export function useOllamaEvents() {
// Per-tab mutable state stored in refs so long-lived listeners read latest.
const accumulatedTextRef = useRef(new Map<string, string>());
const listenersRef = useRef<UnlistenFn[]>([]);
// Reset accumulator whenever a tab starts streaming
const tabs = useClaudeChatStore((s) => s.tabs);
useEffect(() => {
for (const tab of tabs) {
if (tab.isStreaming) {
accumulatedTextRef.current.set(tab.id, "");
} else {
// Keep the accumulated text for a moment so the complete handler
// can still read it; it will clean up after itself.
}
}
}, [tabs]);
useEffect(() => {
function appendTextChunk(tabId: string, text: string) {
if (!text) return;
const current = accumulatedTextRef.current.get(tabId) ?? "";
accumulatedTextRef.current.set(tabId, current + text);
useClaudeChatStore.getState()._appendStreamingText(tabId, text);
}
async function registerProposedChanges(
tabId: string,
responseText: string,
) {
const docState = useDocumentStore.getState();
const projectRoot = docState.projectRoot;
if (!projectRoot) return;
const edits = parseOllamaProposedChanges(responseText);
if (edits.length === 0) return;
const warnings: string[] = [];
for (const edit of edits) {
const file = docState.files.find(
(f) => f.relativePath === edit.filePath,
);
if (!file) {
warnings.push(`Could not find file: ${edit.filePath}`);
continue;
}
const currentContent = file.content ?? "";
const newContent = applyOllamaEdit(
currentContent,
edit.oldText,
edit.newText,
);
if (newContent === null) {
warnings.push(
`Could not locate the specified text in ${edit.filePath}`,
);
continue;
}
useProposedChangesStore.getState().addChange({
id: `ollama-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
filePath: file.relativePath,
absolutePath: file.absolutePath,
oldContent: currentContent,
newContent,
toolName: "OllamaEdit",
});
}
if (warnings.length > 0) {
const chatStore = useClaudeChatStore.getState();
chatStore._appendMessage(tabId, {
type: "assistant",
message: {
content: [
{
type: "text",
text:
"_Some edits could not be applied:_\n\n" +
warnings.map((w) => `- ${w}`).join("\n"),
},
],
},
});
}
}
async function handleComplete(payload: OllamaCompletePayload) {
const { tab_id: tabId, success } = payload;
const chatStore = useClaudeChatStore.getState();
const tab = chatStore.tabs.find((t) => t.id === tabId);
if (!tab?.isStreaming) {
log.warn(`[${tabId}] ignoring duplicate ollama-complete event`);
return;
}
log.info(`[${tabId}] ollama complete success=${success}`);
if (!success && !tab.error && !chatStore._cancelledByUser) {
chatStore._setError(
tabId,
"Ollama response failed. Check that Ollama is running and the model is available.",
);
}
// Parse any structured edits from the full response.
const responseText = accumulatedTextRef.current.get(tabId) ?? "";
if (success && responseText) {
await registerProposedChanges(tabId, responseText);
}
accumulatedTextRef.current.delete(tabId);
chatStore._setStreaming(tabId, false);
// Snapshot after Ollama response.
const projectPath = useDocumentStore.getState().projectRoot;
if (projectPath) {
try {
await useHistoryStore
.getState()
.createSnapshot(projectPath, "[ollama] After Ollama response");
} catch {
/* snapshot failure should not break the flow */
}
}
await useDocumentStore.getState().refreshFiles();
}
let cancelled = false;
(async () => {
const unlistenOutput = await listen<OllamaOutputPayload>(
"ollama-output",
(event) => {
if (cancelled) return;
const { tab_id: tabId, data } = event.payload;
let msg;
try {
msg = JSON.parse(data);
} catch {
return;
}
const type = msg?.type;
if (type === "assistant" && Array.isArray(msg?.message?.content)) {
for (const block of msg.message.content) {
if (block?.type === "text" && typeof block.text === "string") {
appendTextChunk(tabId, block.text);
}
}
} else if (type === "result") {
// Result metadata — append to the chat store for token accounting.
useClaudeChatStore.getState()._appendMessage(tabId, msg);
}
},
);
if (cancelled) {
unlistenOutput();
return;
}
listenersRef.current.push(unlistenOutput);
const unlistenComplete = await listen<OllamaCompletePayload>(
"ollama-complete",
(event) => {
if (!cancelled) handleComplete(event.payload);
},
);
if (cancelled) {
unlistenComplete();
return;
}
listenersRef.current.push(unlistenComplete);
const unlistenError = await listen<OllamaErrorPayload>(
"ollama-error",
(event) => {
if (cancelled) return;
const { tab_id: tabId, data } = event.payload;
log.error(`[${tabId}] ollama-error: ${data}`);
useClaudeChatStore.getState()._setError(tabId, data);
},
);
if (cancelled) {
unlistenError();
return;
}
listenersRef.current.push(unlistenError);
})();
return () => {
cancelled = true;
for (const unlisten of listenersRef.current) {
unlisten();
}
listenersRef.current = [];
};
}, []);
}

View file

@ -0,0 +1,99 @@
export interface OllamaProposedEdit {
filePath: string;
oldText: string;
newText: string;
}
const CHANGE_OPEN_RE = /<proposed-change\s+file\s*=\s*["']([^"']+)["']\s*>/;
/**
* Parse `<proposed-change file="...">...<old>...<old/><new>...<new/><proposed-change/>`
* blocks from an Ollama response.
*
* This is intentionally tolerant: it searches linearly and does not require
* well-formed XML beyond the expected tags.
*/
export function parseOllamaProposedChanges(text: string): OllamaProposedEdit[] {
const edits: OllamaProposedEdit[] = [];
let searchFrom = 0;
while (true) {
const startMatch = findNextBlockStart(text, searchFrom);
if (!startMatch) break;
const openEnd = startMatch.end;
const filePath = startMatch.filePath;
const closeIdx = text.indexOf("</proposed-change>", openEnd);
if (closeIdx === -1) {
// No closing tag — ignore this partial block.
break;
}
const block = text.slice(openEnd, closeIdx);
const oldText = extractTag(block, "old");
const newText = extractTag(block, "new");
if (oldText !== null && newText !== null) {
edits.push({ filePath, oldText, newText });
}
searchFrom = closeIdx + "</proposed-change>".length;
}
return edits;
}
interface BlockStart {
end: number;
filePath: string;
}
function findNextBlockStart(text: string, from: number): BlockStart | null {
const regex = new RegExp(CHANGE_OPEN_RE.source, "g");
regex.lastIndex = from;
const match = regex.exec(text);
if (!match) return null;
return {
end: match.index + match[0].length,
filePath: match[1],
};
}
function extractTag(block: string, tag: string): string | null {
const open = `<${tag}>`;
const close = `</${tag}>`;
const start = block.indexOf(open);
if (start === -1) return null;
const contentStart = start + open.length;
const end = block.indexOf(close, contentStart);
if (end === -1) return null;
let content = block.slice(contentStart, end);
// Models commonly insert a leading/trailing newline after/before XML tags.
if (content.startsWith("\n")) content = content.slice(1);
if (content.endsWith("\n")) content = content.slice(0, -1);
return content;
}
/**
* Try to apply `oldText` `newText` to `fileContent`.
* Falls back to a trimmed match if an exact match fails.
*
* Returns the updated content, or `null` if the old text could not be located.
*/
export function applyOllamaEdit(
fileContent: string,
oldText: string,
newText: string,
): string | null {
if (fileContent.includes(oldText)) {
return fileContent.replace(oldText, newText);
}
// Trim-only fallback: handles models that add leading/trailing blank lines.
const trimmed = oldText.trim();
if (trimmed && fileContent.includes(trimmed)) {
return fileContent.replace(trimmed, newText);
}
return null;
}

View file

@ -2,6 +2,7 @@ import { create } from "zustand";
import { invoke } from "@tauri-apps/api/core";
import { useDocumentStore } from "./document-store";
import { useHistoryStore } from "./history-store";
import { useSettingsStore } from "./settings-store";
import { createLogger } from "@/lib/debug/logger";
const log = createLogger("claude");
@ -67,6 +68,8 @@ export interface TabDraft {
}[];
}
export type AiProvider = "claude" | "ollama";
export interface TabState {
id: string;
title: string;
@ -76,6 +79,8 @@ export interface TabState {
error: string | null;
totalInputTokens: number;
totalOutputTokens: number;
provider: AiProvider;
ollamaModel: string;
draft: TabDraft;
}
@ -87,9 +92,15 @@ const TAB_FIELDS = [
"error",
"totalInputTokens",
"totalOutputTokens",
"provider",
"ollamaModel",
] as const;
function makeDefaultTab(id: string): TabState {
function makeDefaultTab(
id: string,
provider: AiProvider = "claude",
ollamaModel = "",
): TabState {
return {
id,
title: "New Chat",
@ -99,6 +110,8 @@ function makeDefaultTab(id: string): TabState {
error: null,
totalInputTokens: 0,
totalOutputTokens: 0,
provider,
ollamaModel,
draft: { input: "", pinnedContexts: [] },
};
}
@ -108,6 +121,41 @@ function nextTabId(): string {
return `tab-${++tabCounter}`;
}
// ─── Ollama helpers ───
interface OllamaMessage {
role: "system" | "user" | "assistant";
content: string;
}
/** Build a clean message history for Ollama from the tab's current messages. */
function buildOllamaMessages(messages: ClaudeStreamMessage[]): OllamaMessage[] {
const out: OllamaMessage[] = [];
for (const msg of messages) {
if (msg.type === "system") continue;
if (msg.type === "result") continue;
if (msg.type === "user" && msg.message?.content) {
const text = msg.message.content
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("\n");
if (text) {
out.push({ role: "user", content: text });
}
}
if (msg.type === "assistant" && msg.message?.content) {
const text = msg.message.content
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("");
if (text) {
out.push({ role: "assistant", content: text });
}
}
}
return out;
}
/**
* Update a specific tab in `tabs[]` and, if that tab is the active tab,
* also project the changed fields to top-level state for consumer compatibility.
@ -181,6 +229,14 @@ interface ClaudeChatState {
effortLevel: "low" | "medium" | "high";
setEffortLevel: (level: "low" | "medium" | "high") => void;
/** Active AI provider for the current tab */
provider: AiProvider;
setProvider: (provider: AiProvider) => void;
/** Selected Ollama model for the current tab */
ollamaModel: string;
setOllamaModel: (model: string) => void;
// Actions
sendPrompt: (
userPrompt: string,
@ -202,6 +258,7 @@ interface ClaudeChatState {
// Internal actions (called by event hook, routed by tabId)
_appendMessage: (tabId: string, msg: ClaudeStreamMessage) => void;
_appendStreamingText: (tabId: string, text: string) => void;
_setSessionId: (tabId: string, id: string) => void;
_setStreaming: (tabId: string, streaming: boolean) => void;
_setError: (tabId: string, error: string | null) => void;
@ -230,6 +287,30 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
effortLevel: "medium",
setEffortLevel: (level) => set({ effortLevel: level }),
provider: "claude",
setProvider: (provider) =>
set((state) => {
const updates: Partial<ClaudeChatState> = { provider };
return {
...updates,
...applyTabUpdate(state, state.activeTabId, {
provider,
}),
};
}),
ollamaModel: "",
setOllamaModel: (model) =>
set((state) => {
const updates: Partial<ClaudeChatState> = { ollamaModel: model };
return {
...updates,
...applyTabUpdate(state, state.activeTabId, {
ollamaModel: model,
}),
};
}),
pendingInitialPrompt: null,
setPendingInitialPrompt: (prompt) => set({ pendingInitialPrompt: prompt }),
consumePendingInitialPrompt: () => {
@ -266,10 +347,12 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
// Guard: prevent sending from a tab that's already streaming
if (activeTab?.isStreaming) return;
const { sessionId, selectedModel, effortLevel } = state;
const { sessionId, selectedModel, effortLevel, provider, ollamaModel } =
state;
const sendStart = performance.now();
log.info("sendPrompt start", {
provider,
sessionId: !!sessionId,
hasContext: !!contextOverride,
tab: activeTabId,
@ -333,72 +416,90 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
};
});
// Flush unsaved edits to disk so Claude reads the latest content
// Flush unsaved edits to disk so the AI reads the latest content
if (docState.files.some((f) => f.isDirty)) {
log.debug("saving dirty files...");
await docState.saveAllFiles();
log.debug("saveAllFiles done");
}
// Snapshot before Claude edit
// Snapshot before AI edit
if (projectPath) {
try {
log.debug("creating snapshot...");
const snapshotLabel =
provider === "ollama"
? "[ollama] Before Ollama response"
: "[claude] Before Claude edit";
await useHistoryStore
.getState()
.createSnapshot(projectPath, "[claude] Before Claude edit");
.createSnapshot(projectPath, snapshotLabel);
log.debug("snapshot done");
} catch {
/* snapshot failure should not block Claude */
/* snapshot failure should not block the AI flow */
}
}
// Build prompt with full context for Claude
let prompt = userPrompt;
if (activeFile) {
const selRange = docState.selectionRange;
const selectedText =
selRange && activeFile.content
? activeFile.content.slice(selRange.start, selRange.end)
: null;
let ctx = `[Currently open file: ${activeFile.relativePath}]`;
if (contextOverride) {
ctx += `\n[Selection: ${contextOverride.label}]`;
ctx += `\n[Selected text:\n${contextOverride.selectedText}\n]`;
} else if (selectedText && selRange) {
const content = activeFile.content ?? "";
const startLC = offsetToLineCol(content, selRange.start);
const endLC = offsetToLineCol(content, selRange.end);
ctx += `\n[Selection: @${activeFile.relativePath}:${startLC.line}:${startLC.col}-${endLC.line}:${endLC.col}]`;
ctx += `\n[Selected text:\n${selectedText}\n]`;
}
prompt = `${ctx}\n\n${userPrompt}`;
}
log.info("invoking CLI", {
promptLength: prompt.length,
mode: sessionId ? "resume" : "new",
});
try {
if (sessionId) {
// Resume existing session
await invoke("resume_claude_code", {
projectPath,
sessionId,
prompt,
if (provider === "ollama") {
// Build full message history for Ollama
const currentMessages =
get().tabs.find((t) => t.id === activeTabId)?.messages ?? [];
const ollamaMessages = buildOllamaMessages(currentMessages);
const settings = useSettingsStore.getState();
await invoke("send_ollama_message", {
baseUrl: settings.ollamaBaseUrl,
model: ollamaModel,
messages: ollamaMessages,
tabId: activeTabId,
model: selectedModel,
effortLevel,
projectPath,
});
} else {
// New session
await invoke("execute_claude_code", {
projectPath,
prompt,
tabId: activeTabId,
model: selectedModel,
effortLevel,
// Build prompt with full context for Claude
let prompt = userPrompt;
if (activeFile) {
const selRange = docState.selectionRange;
const selectedText =
selRange && activeFile.content
? activeFile.content.slice(selRange.start, selRange.end)
: null;
let ctx = `[Currently open file: ${activeFile.relativePath}]`;
if (contextOverride) {
ctx += `\n[Selection: ${contextOverride.label}]`;
ctx += `\n[Selected text:\n${contextOverride.selectedText}\n]`;
} else if (selectedText && selRange) {
const content = activeFile.content ?? "";
const startLC = offsetToLineCol(content, selRange.start);
const endLC = offsetToLineCol(content, selRange.end);
ctx += `\n[Selection: @${activeFile.relativePath}:${startLC.line}:${startLC.col}-${endLC.line}:${endLC.col}]`;
ctx += `\n[Selected text:\n${selectedText}\n]`;
}
prompt = `${ctx}\n\n${userPrompt}`;
}
log.info("invoking Claude CLI", {
promptLength: prompt.length,
mode: sessionId ? "resume" : "new",
});
if (sessionId) {
await invoke("resume_claude_code", {
projectPath,
sessionId,
prompt,
tabId: activeTabId,
model: selectedModel,
effortLevel,
});
} else {
await invoke("execute_claude_code", {
projectPath,
prompt,
tabId: activeTabId,
model: selectedModel,
effortLevel,
});
}
}
log.info(
`sendPrompt complete in ${(performance.now() - sendStart).toFixed(0)}ms`,
@ -418,10 +519,14 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
},
cancelExecution: async () => {
const { activeTabId } = get();
const { activeTabId, provider } = get();
set({ _cancelledByUser: true });
try {
await invoke("cancel_claude_execution", { tabId: activeTabId });
if (provider === "ollama") {
await invoke("cancel_ollama_message", { tabId: activeTabId });
} else {
await invoke("cancel_claude_execution", { tabId: activeTabId });
}
} catch {
// ignore
}
@ -452,6 +557,7 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
totalInputTokens: 0,
totalOutputTokens: 0,
title: "New Chat",
// Keep provider + ollama model on new session
}),
);
},
@ -501,8 +607,9 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
createTab: () => {
log.debug("Creating new tab");
const state = get();
const id = nextTabId();
const newTab = makeDefaultTab(id);
const newTab = makeDefaultTab(id, state.provider, state.ollamaModel);
set((s) => ({
tabs: [...s.tabs, newTab],
activeTabId: id,
@ -513,6 +620,8 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
error: newTab.error,
totalInputTokens: newTab.totalInputTokens,
totalOutputTokens: newTab.totalOutputTokens,
provider: newTab.provider,
ollamaModel: newTab.ollamaModel,
}));
return id;
},
@ -544,6 +653,8 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
error: newActive.error,
totalInputTokens: newActive.totalInputTokens,
totalOutputTokens: newActive.totalOutputTokens,
provider: newActive.provider,
ollamaModel: newActive.ollamaModel,
});
} else {
set({ tabs: newTabs });
@ -565,6 +676,8 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
error: targetTab.error,
totalInputTokens: targetTab.totalInputTokens,
totalOutputTokens: targetTab.totalOutputTokens,
provider: targetTab.provider,
ollamaModel: targetTab.ollamaModel,
});
},
@ -597,6 +710,51 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
});
},
_appendStreamingText: (tabId: string, text: string) => {
if (!text) return;
set((state) => {
const tab = state.tabs.find((t) => t.id === tabId);
if (!tab) return {};
const lastMsg = tab.messages[tab.messages.length - 1];
if (
lastMsg?.type === "assistant" &&
Array.isArray(lastMsg.message?.content)
) {
const content = lastMsg.message.content;
const lastBlock = content[content.length - 1];
if (lastBlock?.type === "text") {
const updatedContent = [...content];
updatedContent[updatedContent.length - 1] = {
...lastBlock,
text: (lastBlock.text ?? "") + text,
};
return applyTabUpdate(state, tabId, {
messages: [
...tab.messages.slice(0, -1),
{
...lastMsg,
message: { ...lastMsg.message, content: updatedContent },
},
],
});
}
}
return applyTabUpdate(state, tabId, {
messages: [
...tab.messages,
{
type: "assistant",
message: {
content: [{ type: "text", text }],
},
} as ClaudeStreamMessage,
],
});
});
},
_setSessionId: (tabId: string, id: string) => {
set((state) => applyTabUpdate(state, tabId, { sessionId: id }));
},

View file

@ -2,12 +2,19 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
type CompilerBackend = "tectonic" | "texlive";
type AiProvider = "claude" | "ollama";
interface SettingsState {
compilerBackend: CompilerBackend;
setCompilerBackend: (backend: CompilerBackend) => void;
vimMode: boolean;
setVimMode: (enabled: boolean) => void;
aiProvider: AiProvider;
setAiProvider: (provider: AiProvider) => void;
ollamaBaseUrl: string;
setOllamaBaseUrl: (url: string) => void;
ollamaModel: string;
setOllamaModel: (model: string) => void;
}
export const useSettingsStore = create<SettingsState>()(
@ -17,6 +24,12 @@ export const useSettingsStore = create<SettingsState>()(
setCompilerBackend: (backend) => set({ compilerBackend: backend }),
vimMode: false,
setVimMode: (enabled) => set({ vimMode: enabled }),
aiProvider: "claude",
setAiProvider: (provider) => set({ aiProvider: provider }),
ollamaBaseUrl: "http://localhost:11434",
setOllamaBaseUrl: (url) => set({ ollamaBaseUrl: url }),
ollamaModel: "",
setOllamaModel: (model) => set({ ollamaModel: model }),
}),
{
name: "claude-prism-settings",