Skip to content

Commit 2096d09

Browse files
feat: add tool visibility, thinking blocks, GitHub integration, onboarding, MCP management, and slash commands
- Parse tool_use, thinking, and tool_result events from Claude Code stream-json - Add ToolUseCard and ThinkingBlock components with collapsible UI - Add structured ContentBlock system for rich message rendering - Add mid-response steering (send messages while agent is generating) - Add SetupWizard onboarding flow requiring at least one AI provider - Add GitHub integration: PR dashboard, issue linking with auto-context fetch - Add MCP server management panel in sidebar - Add slash command autocomplete in composer - Add conversation history context when resuming sessions - Add git repo detection with GitHub/git icons on sidebar groups - Add PR badges and worktree indicators on thread items - Conditionally show diff editor only for git-activated projects - Fix auto-scroll to track streaming content and respect user scroll position - Fix model selector to use CLI aliases with custom model ID input - Use bypassPermissions for Claude Code (no TTY for approval prompts) - Fix Windows build by adding icon.ico to bundle config - Update e2e test helpers with mock handlers for all new IPC commands
1 parent 5652fee commit 2096d09

32 files changed

Lines changed: 4318 additions & 220 deletions

crates/session/src/claude.rs

Lines changed: 170 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ impl ClaudeSession {
2525
"--verbose",
2626
"--input-format", "stream-json",
2727
"--include-partial-messages",
28+
// bypassPermissions since there's no TTY for Claude Code's
29+
// own approval TUI — commands would silently hang otherwise.
30+
"--permission-mode", "bypassPermissions",
2831
];
2932
if let Some(m) = model {
3033
args.push("--model");
@@ -67,6 +70,10 @@ impl ClaudeSession {
6770
// Track whether we've received stream_event deltas for the current turn.
6871
// If so, skip `assistant` text processing to avoid duplicate content.
6972
let mut got_stream_deltas = false;
73+
// Track active content blocks by their stream index for tool use / thinking.
74+
let mut active_blocks = std::collections::HashMap::<u64, BlockInfo>::new();
75+
// Track model name from message_start so we can use it in result.
76+
let mut current_model: Option<String> = None;
7077

7178
while let Ok(Some(line)) = lines.next_line().await {
7279
if line.trim().is_empty() {
@@ -88,10 +95,12 @@ impl ClaudeSession {
8895
}
8996
}
9097
"stream_event" => {
91-
let evts = handle_stream_event(&obj, &mut turn_started);
98+
let evts = handle_stream_event(&obj, &mut turn_started, &mut active_blocks, &mut current_model);
9299
// Mark that we got real streaming deltas so we skip
93100
// the `assistant` snapshot which would duplicate them.
94-
if evts.iter().any(|e| matches!(e, AgentEvent::ContentDelta { .. })) {
101+
if evts.iter().any(|e| matches!(e, AgentEvent::ContentDelta { .. }
102+
| AgentEvent::ToolUseStart { .. }
103+
| AgentEvent::ThinkingDelta { .. })) {
95104
got_stream_deltas = true;
96105
}
97106
evts
@@ -131,9 +140,68 @@ impl ClaudeSession {
131140
last_len = 0;
132141
turn_started = false;
133142
got_stream_deltas = false;
134-
handle_result(&obj)
143+
active_blocks.clear();
144+
let evts = handle_result(&obj, &current_model);
145+
current_model = None;
146+
evts
147+
}
148+
"user" => {
149+
// Tool results come back as "user" type messages
150+
// with content array containing tool_result blocks
151+
let content = obj
152+
.get("message")
153+
.and_then(|m| m.get("content"))
154+
.and_then(|c| c.as_array());
155+
156+
if let Some(blocks) = content {
157+
let mut evts = Vec::new();
158+
for block in blocks {
159+
if block.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
160+
let tool_use_id = block
161+
.get("tool_use_id")
162+
.and_then(|id| id.as_str())
163+
.unwrap_or("")
164+
.to_string();
165+
let result_content = block
166+
.get("content")
167+
.map(|c| {
168+
if let Some(s) = c.as_str() {
169+
s.to_string()
170+
} else {
171+
serde_json::to_string_pretty(c).unwrap_or_default()
172+
}
173+
})
174+
.unwrap_or_default();
175+
let is_error = block
176+
.get("is_error")
177+
.and_then(|e| e.as_bool())
178+
.unwrap_or(false);
179+
180+
// Try to get the tool name from tool_use_result
181+
let tool_name = obj
182+
.get("tool_use_result")
183+
.and_then(|r| r.get("stdout"))
184+
.and_then(|s| s.as_str())
185+
.map(|_| "Bash".to_string())
186+
.unwrap_or_default();
187+
188+
evts.push(AgentEvent::ToolResult {
189+
tool_id: tool_use_id,
190+
tool_name,
191+
content: result_content,
192+
is_error,
193+
});
194+
}
195+
}
196+
evts
197+
} else {
198+
vec![]
199+
}
200+
}
201+
_ => {
202+
debug!("Unhandled Claude event type: {event_type}");
203+
vec![]
135204
}
136-
_ => vec![],
137205
};
138206

139207
for event in events {
@@ -224,7 +292,20 @@ fn handle_assistant(obj: &Value, last_len: &mut usize, turn_started: &mut bool)
224292
events
225293
}
226294

227-
fn handle_stream_event(obj: &Value, turn_started: &mut bool) -> Vec<AgentEvent> {
295+
/// Tracks active content blocks by their stream index.
296+
#[derive(Debug, Clone)]
297+
struct BlockInfo {
298+
block_type: String,
299+
tool_id: Option<String>,
300+
tool_name: Option<String>,
301+
}
302+
303+
fn handle_stream_event(
304+
obj: &Value,
305+
turn_started: &mut bool,
306+
active_blocks: &mut std::collections::HashMap<u64, BlockInfo>,
307+
current_model: &mut Option<String>,
308+
) -> Vec<AgentEvent> {
228309
let event = match obj.get("event") {
229310
Some(e) => e,
230311
None => return vec![],
@@ -233,6 +314,15 @@ fn handle_stream_event(obj: &Value, turn_started: &mut bool) -> Vec<AgentEvent>
233314

234315
match inner {
235316
"message_start" => {
317+
// Capture model from message_start for later use in usage report
318+
if let Some(model) = event
319+
.get("message")
320+
.and_then(|m| m.get("model"))
321+
.and_then(|m| m.as_str())
322+
{
323+
*current_model = Some(model.to_string());
324+
}
325+
236326
if !*turn_started {
237327
*turn_started = true;
238328
let id = event
@@ -246,33 +336,92 @@ fn handle_stream_event(obj: &Value, turn_started: &mut bool) -> Vec<AgentEvent>
246336
}
247337
}
248338
"content_block_start" => {
339+
let index = event.get("index").and_then(|i| i.as_u64()).unwrap_or(0);
249340
let block = event.get("content_block").unwrap_or(&Value::Null);
250-
if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
251-
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("tool");
252-
vec![AgentEvent::ContentDelta {
253-
text: format!("\n> *Using {name}...*\n"),
254-
}]
255-
} else {
256-
vec![]
341+
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("text");
342+
343+
match block_type {
344+
"tool_use" => {
345+
let tool_id = block.get("id").and_then(|id| id.as_str()).unwrap_or("").to_string();
346+
let tool_name = block.get("name").and_then(|n| n.as_str()).unwrap_or("tool").to_string();
347+
active_blocks.insert(index, BlockInfo {
348+
block_type: "tool_use".into(),
349+
tool_id: Some(tool_id.clone()),
350+
tool_name: Some(tool_name.clone()),
351+
});
352+
vec![AgentEvent::ToolUseStart { tool_id, tool_name }]
353+
}
354+
"thinking" => {
355+
active_blocks.insert(index, BlockInfo {
356+
block_type: "thinking".into(),
357+
tool_id: None,
358+
tool_name: None,
359+
});
360+
vec![]
361+
}
362+
_ => {
363+
active_blocks.insert(index, BlockInfo {
364+
block_type: block_type.into(),
365+
tool_id: None,
366+
tool_name: None,
367+
});
368+
vec![]
369+
}
257370
}
258371
}
259372
"content_block_delta" => {
373+
let index = event.get("index").and_then(|i| i.as_u64()).unwrap_or(0);
260374
let delta = event.get("delta").unwrap_or(&Value::Null);
261375
let dtype = delta.get("type").and_then(|t| t.as_str()).unwrap_or("");
262-
if dtype == "text_delta" {
263-
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("");
264-
if text.is_empty() { vec![] } else {
265-
vec![AgentEvent::ContentDelta { text: text.to_string() }]
376+
377+
match dtype {
378+
"text_delta" => {
379+
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("");
380+
if text.is_empty() {
381+
vec![]
382+
} else {
383+
vec![AgentEvent::ContentDelta { text: text.to_string() }]
384+
}
385+
}
386+
"input_json_delta" => {
387+
let json_str = delta.get("partial_json").and_then(|j| j.as_str()).unwrap_or("");
388+
if let Some(block) = active_blocks.get(&index) {
389+
if let Some(tool_id) = &block.tool_id {
390+
return vec![AgentEvent::ToolInputDelta {
391+
tool_id: tool_id.clone(),
392+
input_json: json_str.to_string(),
393+
}];
394+
}
395+
}
396+
vec![]
397+
}
398+
"thinking_delta" => {
399+
let text = delta.get("thinking").and_then(|t| t.as_str()).unwrap_or("");
400+
if text.is_empty() {
401+
vec![]
402+
} else {
403+
vec![AgentEvent::ThinkingDelta { text: text.to_string() }]
404+
}
405+
}
406+
_ => vec![],
407+
}
408+
}
409+
"content_block_end" => {
410+
let index = event.get("index").and_then(|i| i.as_u64()).unwrap_or(0);
411+
if let Some(block) = active_blocks.remove(&index) {
412+
if block.block_type == "tool_use" {
413+
if let Some(tool_id) = block.tool_id {
414+
return vec![AgentEvent::ToolUseEnd { tool_id }];
415+
}
266416
}
267-
} else {
268-
vec![]
269417
}
418+
vec![]
270419
}
271420
_ => vec![],
272421
}
273422
}
274423

275-
fn handle_result(obj: &Value) -> Vec<AgentEvent> {
424+
fn handle_result(obj: &Value, current_model: &Option<String>) -> Vec<AgentEvent> {
276425
let mut events = Vec::new();
277426

278427
// Extract usage data if present
@@ -300,9 +449,11 @@ fn handle_result(obj: &Value) -> Vec<AgentEvent> {
300449
.or_else(|| usage.and_then(|u| u.get("cache_write_tokens")))
301450
.and_then(|v| v.as_u64())
302451
.unwrap_or(0);
452+
// Use model from result, fall back to model captured from message_start
303453
let model = obj
304454
.get("model")
305455
.and_then(|m| m.as_str())
456+
.or_else(|| current_model.as_deref())
306457
.unwrap_or("unknown")
307458
.to_string();
308459

crates/session/src/codex.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,75 @@ async fn handle_jsonrpc_message(
343343
.to_string();
344344
let _ = event_tx.send(AgentEvent::TurnAborted { reason });
345345
}
346+
"tool_use.start" | "tool/start" => {
347+
let tool_id = notif
348+
.params
349+
.get("toolId")
350+
.or_else(|| notif.params.get("id"))
351+
.and_then(|v| v.as_str())
352+
.unwrap_or("unknown")
353+
.to_string();
354+
let tool_name = notif
355+
.params
356+
.get("name")
357+
.or_else(|| notif.params.get("toolName"))
358+
.and_then(|v| v.as_str())
359+
.unwrap_or("tool")
360+
.to_string();
361+
let _ = event_tx.send(AgentEvent::ToolUseStart { tool_id, tool_name });
362+
}
363+
"tool_use.end" | "tool/end" => {
364+
let tool_id = notif
365+
.params
366+
.get("toolId")
367+
.or_else(|| notif.params.get("id"))
368+
.and_then(|v| v.as_str())
369+
.unwrap_or("unknown")
370+
.to_string();
371+
let tool_name = notif
372+
.params
373+
.get("name")
374+
.or_else(|| notif.params.get("toolName"))
375+
.and_then(|v| v.as_str())
376+
.unwrap_or("tool")
377+
.to_string();
378+
let content = notif
379+
.params
380+
.get("output")
381+
.or_else(|| notif.params.get("result"))
382+
.map(|v| {
383+
if let Some(s) = v.as_str() {
384+
s.to_string()
385+
} else {
386+
serde_json::to_string_pretty(v).unwrap_or_default()
387+
}
388+
})
389+
.unwrap_or_default();
390+
let is_error = notif
391+
.params
392+
.get("isError")
393+
.and_then(|v| v.as_bool())
394+
.unwrap_or(false);
395+
let _ = event_tx.send(AgentEvent::ToolUseEnd { tool_id: tool_id.clone() });
396+
let _ = event_tx.send(AgentEvent::ToolResult {
397+
tool_id,
398+
tool_name,
399+
content,
400+
is_error,
401+
});
402+
}
403+
"thinking.delta" | "thinking/delta" => {
404+
let text = notif
405+
.params
406+
.get("delta")
407+
.or_else(|| notif.params.get("text"))
408+
.and_then(|v| v.as_str())
409+
.unwrap_or("")
410+
.to_string();
411+
if !text.is_empty() {
412+
let _ = event_tx.send(AgentEvent::ThinkingDelta { text });
413+
}
414+
}
346415
_ => {
347416
debug!("Unhandled codex notification: {method}");
348417
}

crates/session/src/types.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,29 @@ pub enum AgentEvent {
5454
cost_usd: f64,
5555
model: String,
5656
},
57+
/// A tool use block has started (agent is calling a tool).
58+
ToolUseStart {
59+
tool_id: String,
60+
tool_name: String,
61+
},
62+
/// Incremental JSON input being generated for a tool call.
63+
ToolInputDelta {
64+
tool_id: String,
65+
input_json: String,
66+
},
67+
/// Tool input is complete; the tool is now executing.
68+
ToolUseEnd {
69+
tool_id: String,
70+
},
71+
/// Result returned from a tool execution.
72+
ToolResult {
73+
tool_id: String,
74+
tool_name: String,
75+
content: String,
76+
is_error: bool,
77+
},
78+
/// Incremental thinking/reasoning output from the agent.
79+
ThinkingDelta { text: String },
5780
}
5881

5982
/// Metadata for an active session.

0 commit comments

Comments
 (0)