Skip to content

Commit 89f2772

Browse files
committed
fix some bugs; improve ux
1 parent 4a5e78f commit 89f2772

7 files changed

Lines changed: 1083 additions & 577 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ anyhow = "1"
1515
arboard = "3"
1616
dirs = "5"
1717
portable-pty = "0.8"
18+
termwiz = "0.23.3"
1819
reqwest = { version = "0.12", features = ["blocking", "json"] }
1920
serde = { version = "1", features = ["derive"] }
2021
serde_json = "1"

src/llm/anthropic.rs

Lines changed: 175 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1+
use std::time::Duration;
2+
13
use anyhow::{Context, Result};
2-
use log::{debug, error};
4+
use log::{debug, error, warn};
35
use serde_json::{json, Value};
46

57
use super::{ContentBlock, LLMEvent, LLMProvider, Message, RichMessage, Role};
68

9+
const RETRY_DELAYS: &[Duration] = &[
10+
Duration::from_millis(500),
11+
Duration::from_millis(2000),
12+
Duration::from_millis(4000),
13+
];
14+
715
pub struct AnthropicProvider {
816
api_key: String,
917
model: String,
@@ -18,49 +26,131 @@ impl AnthropicProvider {
1826
debug!("[Anthropic] POST /v1/messages model={} messages={}", self.model, body["messages"].as_array().map(|a| a.len()).unwrap_or(0));
1927

2028
let client = reqwest::blocking::Client::new();
21-
let resp = client
22-
.post("https://api.anthropic.com/v1/messages")
23-
.header("x-api-key", &self.api_key)
24-
.header("anthropic-version", "2023-06-01")
25-
.header("content-type", "application/json")
26-
.json(&body)
27-
.send()
28-
.context("sending request to Anthropic")?;
29-
30-
let status = resp.status();
31-
debug!("[Anthropic] response status={}", status);
32-
33-
let json: Value = resp.json().context("parsing Anthropic response")?;
34-
35-
if !status.is_success() {
36-
error!("[Anthropic] error response: {}", json);
29+
let mut last_err: anyhow::Error = anyhow::anyhow!("no attempts made");
30+
31+
for attempt in 0..=RETRY_DELAYS.len() {
32+
if attempt > 0 {
33+
let delay = RETRY_DELAYS[attempt - 1];
34+
warn!("[Anthropic] retry {}/{} after {}ms", attempt, RETRY_DELAYS.len(), delay.as_millis());
35+
std::thread::sleep(delay);
36+
}
37+
38+
let resp = match client
39+
.post("https://api.anthropic.com/v1/messages")
40+
.header("x-api-key", &self.api_key)
41+
.header("anthropic-version", "2023-06-01")
42+
.header("content-type", "application/json")
43+
.json(&body)
44+
.send()
45+
{
46+
Ok(r) => r,
47+
Err(e) => {
48+
warn!("[Anthropic] request error (attempt {}): {}", attempt + 1, e);
49+
last_err = anyhow::Error::from(e).context("sending request to Anthropic");
50+
continue;
51+
}
52+
};
53+
54+
let status = resp.status();
55+
debug!("[Anthropic] response status={}", status);
56+
57+
let json: Value = match resp.json().context("parsing Anthropic response") {
58+
Ok(v) => v,
59+
Err(e) => {
60+
warn!("[Anthropic] parse error (attempt {}): {}", attempt + 1, e);
61+
last_err = e;
62+
continue;
63+
}
64+
};
65+
66+
if status.is_server_error() || status.as_u16() == 429 {
67+
error!("[Anthropic] retryable error response (attempt {}): {}", attempt + 1, json);
68+
last_err = anyhow::anyhow!("Anthropic error {}: {}", status, json);
69+
continue;
70+
}
71+
72+
if !status.is_success() {
73+
error!("[Anthropic] error response: {}", json);
74+
}
75+
76+
return Ok(json);
3777
}
3878

39-
Ok(json)
79+
Err(last_err)
4080
}
4181
}
4282

43-
/// The `run_command` tool definition sent to Claude on every rich request.
44-
fn run_command_tool() -> Value {
45-
json!({
46-
"name": "run_command",
47-
"description": "Execute a shell command on the user's remote SSH session. \
48-
The user will be shown the command and must approve before it runs.",
49-
"input_schema": {
50-
"type": "object",
51-
"properties": {
52-
"command": {
53-
"type": "string",
54-
"description": "The exact shell command to execute."
83+
/// All tool definitions sent to Claude on every rich request.
84+
fn all_tools() -> Value {
85+
json!([
86+
{
87+
"name": "run_command",
88+
"description": "Execute an arbitrary shell command on the user's remote SSH session. \
89+
The user will be shown the command and must approve before it runs.",
90+
"input_schema": {
91+
"type": "object",
92+
"properties": {
93+
"command": { "type": "string", "description": "The exact shell command to execute." },
94+
"description": { "type": "string", "description": "One-sentence plain-English explanation of what this command does." }
5595
},
56-
"description": {
57-
"type": "string",
58-
"description": "One-sentence plain-English explanation of what this command does."
59-
}
60-
},
61-
"required": ["command"]
96+
"required": ["command"]
97+
}
98+
},
99+
{
100+
"name": "system_information",
101+
"description": "Return the SSH connection settings for the current session (host, user, port, description, identity file, extra options). No PTY interaction needed.",
102+
"input_schema": { "type": "object", "properties": {}, "required": [] }
103+
},
104+
{
105+
"name": "make_dir",
106+
"description": "Create a directory (and any missing parents) on the remote host using mkdir -p.",
107+
"input_schema": {
108+
"type": "object",
109+
"properties": {
110+
"path": { "type": "string", "description": "Absolute or relative path of the directory to create." }
111+
},
112+
"required": ["path"]
113+
}
114+
},
115+
{
116+
"name": "touch_file",
117+
"description": "Create an empty file (or update its timestamp) on the remote host using touch.",
118+
"input_schema": {
119+
"type": "object",
120+
"properties": {
121+
"file": { "type": "string", "description": "Path of the file to create or touch." }
122+
},
123+
"required": ["file"]
124+
}
125+
},
126+
{
127+
"name": "read_file",
128+
"description": "Read and return the contents of a file on the remote host using cat.",
129+
"input_schema": {
130+
"type": "object",
131+
"properties": {
132+
"file": { "type": "string", "description": "Path of the file to read." }
133+
},
134+
"required": ["file"]
135+
}
136+
},
137+
{
138+
"name": "list_dir",
139+
"description": "List the contents of a directory on the remote host using ls -la.",
140+
"input_schema": {
141+
"type": "object",
142+
"properties": {
143+
"path": { "type": "string", "description": "Directory path to list. Defaults to current directory." }
144+
},
145+
"required": []
146+
}
62147
}
63-
})
148+
])
149+
}
150+
151+
/// Wrap a path/filename in single quotes, escaping any embedded single quotes.
152+
fn shell_quote(s: &str) -> String {
153+
format!("'{}'", s.replace('\'', "'\\''"))
64154
}
65155

66156
/// Convert a `RichMessage` to the JSON format Anthropic expects.
@@ -173,7 +263,7 @@ impl LLMProvider for AnthropicProvider {
173263
let mut body = json!({
174264
"model": self.model,
175265
"max_tokens": 8096,
176-
"tools": [run_command_tool()],
266+
"tools": all_tools(),
177267
"messages": msgs,
178268
});
179269

@@ -198,12 +288,6 @@ impl LLMProvider for AnthropicProvider {
198288
let name = tool_use["name"].as_str().unwrap_or("").to_string();
199289
let input = tool_use["input"].clone();
200290

201-
let command = input["command"]
202-
.as_str()
203-
.ok_or_else(|| anyhow::anyhow!("run_command tool missing 'command' field"))?
204-
.to_string();
205-
let description = input["description"].as_str().map(|s| s.to_string());
206-
207291
// Build the content blocks to append to rich history.
208292
let mut assistant_blocks: Vec<ContentBlock> = vec![];
209293
for block in &content {
@@ -226,13 +310,53 @@ impl LLMProvider for AnthropicProvider {
226310
}
227311
}
228312

229-
debug!("[Anthropic] tool_call: name={} command={:?}", name, command);
230-
return Ok(LLMEvent::ToolCall {
231-
id,
232-
command,
233-
description,
234-
assistant_blocks,
235-
});
313+
// Dispatch by tool name.
314+
match name.as_str() {
315+
"system_information" => {
316+
debug!("[Anthropic] local tool: system_information");
317+
return Ok(LLMEvent::LocalTool { id, name, input, assistant_blocks });
318+
}
319+
"run_command" => {
320+
let command = input["command"]
321+
.as_str()
322+
.ok_or_else(|| anyhow::anyhow!("run_command missing 'command' field"))?
323+
.to_string();
324+
let description = input["description"].as_str().map(|s| s.to_string());
325+
debug!("[Anthropic] tool_call: run_command command={:?}", command);
326+
return Ok(LLMEvent::ToolCall { id, command, description, assistant_blocks });
327+
}
328+
"make_dir" => {
329+
let path = input["path"].as_str().unwrap_or(".");
330+
let command = format!("mkdir -p {}", shell_quote(path));
331+
let description = Some(format!("Create directory {}", path));
332+
debug!("[Anthropic] tool_call: make_dir path={:?}", path);
333+
return Ok(LLMEvent::ToolCall { id, command, description, assistant_blocks });
334+
}
335+
"touch_file" => {
336+
let file = input["file"].as_str().unwrap_or("");
337+
let command = format!("touch {}", shell_quote(file));
338+
let description = Some(format!("Create/touch file {}", file));
339+
debug!("[Anthropic] tool_call: touch_file file={:?}", file);
340+
return Ok(LLMEvent::ToolCall { id, command, description, assistant_blocks });
341+
}
342+
"read_file" => {
343+
let file = input["file"].as_str().unwrap_or("");
344+
let command = format!("cat {}", shell_quote(file));
345+
let description = Some(format!("Read file {}", file));
346+
debug!("[Anthropic] tool_call: read_file file={:?}", file);
347+
return Ok(LLMEvent::ToolCall { id, command, description, assistant_blocks });
348+
}
349+
"list_dir" => {
350+
let path = input["path"].as_str().unwrap_or(".");
351+
let command = format!("ls -la {}", shell_quote(path));
352+
let description = Some(format!("List directory {}", path));
353+
debug!("[Anthropic] tool_call: list_dir path={:?}", path);
354+
return Ok(LLMEvent::ToolCall { id, command, description, assistant_blocks });
355+
}
356+
other => {
357+
return Err(anyhow::anyhow!("unknown tool: {}", other));
358+
}
359+
}
236360
}
237361

238362
// Normal text response.

src/llm/mod.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ impl RichMessage {
9191
pub enum LLMEvent {
9292
/// Full text response — conversation continues normally.
9393
Response(String),
94-
/// Claude wants to run a command; user must confirm before we resume.
94+
/// Claude wants to run a shell command on the PTY; user must confirm before it runs.
9595
ToolCall {
9696
/// Tool-use block id — echoed back in the tool_result.
9797
id: String,
@@ -102,6 +102,13 @@ pub enum LLMEvent {
102102
/// Full assistant content blocks (text + tool_use) for rich history.
103103
assistant_blocks: Vec<ContentBlock>,
104104
},
105+
/// Claude invoked a tool that is resolved locally (no PTY needed).
106+
LocalTool {
107+
id: String,
108+
name: String,
109+
input: serde_json::Value,
110+
assistant_blocks: Vec<ContentBlock>,
111+
},
105112
/// An error occurred.
106113
Error(String),
107114
}

src/main.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ impl Sheesh {
9191

9292
let provider = build_provider(&self.llm_config);
9393
self.terminal = Some(terminal);
94-
self.llm = Some(LLMTab::new(provider, self.llm_config.system_prompt.clone()));
94+
self.llm = Some(LLMTab::new(provider, self.llm_config.system_prompt.clone(), conn.clone()));
9595
self.state = AppState::Connected {
9696
connection_name: name,
9797
focus: ConnectedFocus::Terminal,
@@ -148,6 +148,16 @@ impl Sheesh {
148148
self.send_context_to_llm();
149149
return true;
150150
}
151+
// F4 — toggle terminal user lock (works regardless of focused panel)
152+
crossterm::event::Event::Key(KeyEvent {
153+
code: KeyCode::F(4),
154+
..
155+
}) => {
156+
if let Some(t) = &mut self.terminal {
157+
t.toggle_user_lock();
158+
}
159+
return true;
160+
}
151161
// Mouse click — focus the panel that was clicked.
152162
// Do NOT return early for the terminal panel so the click also
153163
// reaches the terminal handler to start a text selection.
@@ -211,6 +221,7 @@ impl Sheesh {
211221
let snapshot = t.line_count();
212222
t.send_string(&cmd);
213223
t.send_string("\r");
224+
t.set_tool_locked(true);
214225
// Capture output for 1.5 s then forward it to Claude.
215226
self.pending_capture = Some(PendingCapture {
216227
snapshot,
@@ -294,7 +305,13 @@ impl Sheesh {
294305
let hints: Vec<(&str, &str)> = match &self.state {
295306
AppState::Listing => self.listing.key_hints(),
296307
AppState::Connected { focus, .. } => {
308+
let lock_hint = self.terminal.as_ref().map(|t| {
309+
if t.is_locked() { ("F4", "unlock terminal") } else { ("F4", "lock terminal") }
310+
});
297311
let mut hints = vec![("F2", "switch panel"), ("F3", "send context")];
312+
if let Some(h) = lock_hint {
313+
hints.push(h);
314+
}
298315
let panel_hints: Vec<(&str, &str)> = match focus {
299316
ConnectedFocus::Terminal => self
300317
.terminal
@@ -394,6 +411,13 @@ fn main() -> anyhow::Result<()> {
394411
}
395412
}
396413

414+
// Release the tool lock once the LLM finishes the tool-execution cycle.
415+
if let (Some(terminal), Some(llm)) = (&mut app.terminal, &app.llm) {
416+
if terminal.tool_locked && !llm.is_executing_tool() && !llm.waiting {
417+
terminal.set_tool_locked(false);
418+
}
419+
}
420+
397421
if poll(Duration::from_millis(5))? {
398422
let ev = read()?;
399423
if !app.handle_event(&ev) {

0 commit comments

Comments
 (0)