chore: commit pending changes on main (CLI, TUI, core, docs)

This commit is contained in:
James Peter
2025-08-17 18:55:58 +10:00
parent 53cda72e5c
commit e1f9079b50
19 changed files with 958 additions and 157 deletions
+5 -5
View File
@@ -41,7 +41,7 @@ jobs:
echo "Current version in package.json: $CURRENT_VERSION"
# Check latest published version
PUBLISHED_VERSION=$(npm view @just-every/coder version 2>/dev/null || echo "0.0.0")
PUBLISHED_VERSION=$(npm view @just-every/code version 2>/dev/null || echo "0.0.0")
echo "Latest published version: $PUBLISHED_VERSION"
# Determine the version to use for this build
@@ -291,7 +291,7 @@ jobs:
tag_name: v${{ steps.version.outputs.version }}
name: Release v${{ steps.version.outputs.version }}
body: |
## @just-every/coder v${{ steps.version.outputs.version }}
## @just-every/code v${{ steps.version.outputs.version }}
Fork of OpenAI Codex with additional features:
- Enhanced image handling (drag-and-drop, paste, file path detection)
@@ -299,14 +299,14 @@ jobs:
### Installation
```bash
npm install -g @just-every/coder
npm install -g @just-every/code
```
### Usage
```bash
coder
code
```
files: |
release-assets/*
env:
GITHUB_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
+10 -10
View File
@@ -27,20 +27,20 @@
### Install Code
```bash
npm install -g @just-every/coder
coder
npm install -g @just-every/code
code
```
**Authenticate** (one of the following):
- **Sign in with ChatGPT** (Plus/Pro/Team; uses models available to your plan)
- Run `coder` and pick "Sign in with ChatGPT"
- Run `code` and pick "Sign in with ChatGPT"
- Stores creds locally at `~/.coder/auth.json` (also reads legacy `~/.codex/auth.json`)
- **API key** (usage-based)
- Set `export OPENAI_API_KEY=xyz` and run `coder`
- Set `export OPENAI_API_KEY=xyz` and run `code`
### Install Claude & Gemini (optional)
Code supports orchestrating other AI CLI tools. Install these and config to use alongside coder.
Code supports orchestrating other AI CLI tools. Install these and config to use alongside Code.
```bash
@@ -52,7 +52,7 @@ npm install -g @anthropic-ai/claude-code @google/gemini-cli && claude "Just chec
### Browser
```bash
# Connect coder to external Chrome browser (running CDP)
# Connect code to external Chrome browser (running CDP)
/chrome # Connect with auto-detect port
/chrome 9222 # Connect to specific port
@@ -91,7 +91,7 @@ npm install -g @anthropic-ai/claude-code @google/gemini-cli && claude "Just chec
## CLI reference
```shell
coder [options] [prompt]
code [options] [prompt]
Options:
--model <name> Override the model (gpt-5, claude-opus, etc.)
@@ -134,13 +134,13 @@ For automation and CI/CD:
```shell
# Run a specific task
coder --no-approval "run tests and fix any failures"
code --no-approval "run tests and fix any failures"
# Generate reports
coder --read-only "analyze code quality and generate report"
code --read-only "analyze code quality and generate report"
# Batch processing
coder --config output_format=json "list all TODO comments"
code --config output_format=json "list all TODO comments"
```
&ensp;
+3 -3
View File
@@ -72,8 +72,8 @@ if (existsSync(binaryPath)) {
} else {
console.error(`Binary not found: ${binaryPath}`);
console.error(`Please try reinstalling the package:`);
console.error(` npm uninstall -g @just-every/coder`);
console.error(` npm install -g @just-every/coder`);
console.error(` npm uninstall -g @just-every/code`);
console.error(` npm install -g @just-every/code`);
process.exit(1);
}
@@ -94,7 +94,7 @@ child.on("error", (err) => {
if (err.code === 'EACCES') {
console.error(`Permission denied: ${binaryPath}`);
console.error(`Try running: chmod +x "${binaryPath}"`);
console.error(`Or reinstall the package with: npm install -g @just-every/coder`);
console.error(`Or reinstall the package with: npm install -g @just-every/code`);
} else {
console.error(err);
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "@just-every/coder",
"name": "@just-every/code",
"version": "0.1.13",
"license": "Apache-2.0",
"description": "Lightweight coding agent that runs in your terminal - fork of OpenAI Codex",
+1 -1
View File
@@ -72,7 +72,7 @@ async function main() {
// Binary names to download (Rust artifacts remain named 'coder*')
const binaries = ['coder', 'coder-tui', 'coder-exec'];
console.log(`Installing @just-every/coder v${version} for ${targetTriple}...`);
console.log(`Installing @just-every/code v${version} for ${targetTriple}...`);
for (const binary of binaries) {
const binaryName = `${binary}-${targetTriple}${binaryExt}`;
+8 -8
View File
@@ -38,7 +38,7 @@ Your agents are like having a team of expert peers at your disposal at any time.
If you get stuck on a problem and your first attempt has failed, race your agents to find the solution in read_only mode. Research shows that the fastest agent is frequently the most accurate.
Example;
run_agent {
agent_run {
"task": "Find and fix flaky test `test_refund_flow` in services/payments. Produce: (1) root cause, (2) minimal unified diff, (3) commands to reproduce and verify. Do not change business logic.",
"context": "Monorepo; CI fails intermittently on macOS since a1b2c3. Stripe sandbox used in tests.",
"files": ["services/payments", "services/payments/tests", "ci/config.yml"],
@@ -46,16 +46,16 @@ run_agent {
"output": "Root cause + minimal patch + reproducible verification",
"read_only": true
}
wait_for_agent {"batch_id":"<batch_id>","return_all":false} // will return as soon as first agent is complete
get_agent_result {"agent_id":"<first_agent_id>"}
agent_wait {"batch_id":"<batch_id>","return_all":false} // will return as soon as first agent is complete
agent_result {"agent_id":"<first_agent_id>"}
// Repeat and create new agents if needed until problem is resolved
cancel_agent {"batch_id":"<batch>"} // stop running agents ONLY once the task is tested and confirmed solved. Even if you think you have solved the problem it may be good to check the output of other agents
agent_cancel {"batch_id":"<batch>"} // stop running agents ONLY once the task is tested and confirmed solved. Even if you think you have solved the problem it may be good to check the output of other agents
### Plan
When starting a complicated task without an obvious direction, you can find multiple perspectives then merge into one plan with rationale.
Example;
run_agent {
agent_run {
"task": "Draft a phased rollout plan for feature flags across apps/web and services. Include migration steps, rollback, monitoring, ownership, and two safe defaults.",
"context": "apps/web (Next.js), services/api (Rust Axum). Existing env flags in `config/flags`. Goal: progressive delivery.",
"files": ["apps/web/package.json", "services/api/Cargo.toml", "config/flags"],
@@ -63,13 +63,13 @@ run_agent {
"output": "Numbered phases + risk matrix + observability plan",
"read_only": true
}
wait_for_agent { "batch_id": "<batch_id>", "return_all": true } // wait for all
agent_wait { "batch_id": "<batch_id>", "return_all": true } // wait for all
### Code
Implement multiple tasks at once, or see how multiple other peers would build a solution.
Example;
run_agent {
agent_run {
"task": "Implement JWT middleware (RS256) with key rotation and unit/integration tests. Preserve existing OAuth flows. Provide README usage snippet.",
"context": "Service: services/api (Rust Axum). Secrets via env. CI: `cargo test --all`.",
"files": ["services/api", "services/api/src", "services/api/Cargo.toml"],
@@ -77,4 +77,4 @@ run_agent {
"output": "Middleware + passing tests + README snippet",
"read_only": false // Allow changes - will launch every agent in a separate worktree
}
wait_for_agent {"batch_id":"<batch_id>","return_all":true,"timeout_seconds": 600 } // Long timeout or you can do separate work and check back later.
agent_wait {"batch_id":"<batch_id>","return_all":true,"timeout_seconds": 600 } // Long timeout or you can do separate work and check back later.
+6 -6
View File
@@ -679,7 +679,7 @@ pub fn create_run_agent_tool() -> OpenAiTool {
);
OpenAiTool::Function(ResponsesApiTool {
name: "run_agent".to_string(),
name: "agent_run".to_string(),
description: "Start a complex AI task asynchronously. Returns a agent ID immediately to check status and retrieve results.".to_string(),
strict: false,
parameters: JsonSchema::Object {
@@ -701,7 +701,7 @@ pub fn create_check_agent_status_tool() -> OpenAiTool {
);
OpenAiTool::Function(ResponsesApiTool {
name: "check_agent_status".to_string(),
name: "agent_check".to_string(),
description: "Check the status of a running agent. Returns current status, progress, and partial results if available.".to_string(),
strict: false,
parameters: JsonSchema::Object {
@@ -723,7 +723,7 @@ pub fn create_get_agent_result_tool() -> OpenAiTool {
);
OpenAiTool::Function(ResponsesApiTool {
name: "get_agent_result".to_string(),
name: "agent_result".to_string(),
description: "Get the final result of a completed agent.".to_string(),
strict: false,
parameters: JsonSchema::Object {
@@ -757,7 +757,7 @@ pub fn create_cancel_agent_tool() -> OpenAiTool {
);
OpenAiTool::Function(ResponsesApiTool {
name: "cancel_agent".to_string(),
name: "agent_cancel".to_string(),
description: "Cancel a pending or running agent, or all agents in a batch.".to_string(),
strict: false,
parameters: JsonSchema::Object {
@@ -808,7 +808,7 @@ pub fn create_wait_for_agent_tool() -> OpenAiTool {
);
OpenAiTool::Function(ResponsesApiTool {
name: "wait_for_agent".to_string(),
name: "agent_wait".to_string(),
description: "Wait for a agent or any agent in a batch to complete, fail, or be cancelled."
.to_string(),
strict: false,
@@ -847,7 +847,7 @@ pub fn create_list_agents_tool() -> OpenAiTool {
);
OpenAiTool::Function(ResponsesApiTool {
name: "list_agents".to_string(),
name: "agent_list".to_string(),
description: "List all agents with their current status.".to_string(),
strict: false,
parameters: JsonSchema::Object {
+117 -37
View File
@@ -2306,6 +2306,14 @@ async fn handle_function_call(
.await
}
"update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await,
// New agent_* names
"agent_run" => handle_run_agent(sess, arguments, sub_id, call_id).await,
"agent_check" => handle_check_agent_status(sess, arguments, sub_id, call_id).await,
"agent_result" => handle_get_agent_result(sess, arguments, sub_id, call_id).await,
"agent_cancel" => handle_cancel_agent(sess, arguments, sub_id, call_id).await,
"agent_wait" => handle_wait_for_agent(sess, arguments, sub_id, call_id).await,
"agent_list" => handle_list_agents(sess, arguments, sub_id, call_id).await,
// Back-compat for older tool names
"run_agent" => handle_run_agent(sess, arguments, sub_id, call_id).await,
"check_agent_status" => handle_check_agent_status(sess, arguments, sub_id, call_id).await,
"get_agent_result" => handle_get_agent_result(sess, arguments, sub_id, call_id).await,
@@ -2404,10 +2412,20 @@ fn maybe_run_with_user_profile(params: ExecParams, sess: &Session) -> ExecParams
async fn handle_run_agent(
sess: &Session,
arguments: String,
_sub_id: String,
sub_id: String,
call_id: String,
) -> ResponseInputItem {
match serde_json::from_str::<RunAgentParams>(&arguments) {
let params_for_event = serde_json::from_str(&arguments).ok();
let arguments_clone = arguments.clone();
let call_id_clone = call_id.clone();
execute_custom_tool(
sess,
&sub_id,
call_id,
"agent_run".to_string(),
params_for_event,
|| async move {
match serde_json::from_str::<RunAgentParams>(&arguments_clone) {
Ok(params) => {
let mut manager = AGENT_MANAGER.write().await;
@@ -2495,7 +2513,7 @@ async fn handle_run_agent(
};
ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: response.to_string(),
success: Some(true),
@@ -2503,22 +2521,34 @@ async fn handle_run_agent(
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Invalid run_agent arguments: {}", e),
success: None,
},
},
}
},
).await
}
async fn handle_check_agent_status(
_sess: &Session,
sess: &Session,
arguments: String,
_sub_id: String,
sub_id: String,
call_id: String,
) -> ResponseInputItem {
match serde_json::from_str::<CheckAgentStatusParams>(&arguments) {
let params_for_event = serde_json::from_str(&arguments).ok();
let arguments_clone = arguments.clone();
let call_id_clone = call_id.clone();
execute_custom_tool(
sess,
&sub_id,
call_id,
"agent_check".to_string(),
params_for_event,
|| async move {
match serde_json::from_str::<CheckAgentStatusParams>(&arguments_clone) {
Ok(params) => {
let manager = AGENT_MANAGER.read().await;
@@ -2537,7 +2567,7 @@ async fn handle_check_agent_status(
});
ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: response.to_string(),
success: Some(true),
@@ -2545,7 +2575,7 @@ async fn handle_check_agent_status(
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Agent not found: {}", params.agent_id),
success: Some(false),
@@ -2554,29 +2584,41 @@ async fn handle_check_agent_status(
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Invalid check_agent_status arguments: {}", e),
success: None,
},
},
}
},
).await
}
async fn handle_get_agent_result(
_sess: &Session,
sess: &Session,
arguments: String,
_sub_id: String,
sub_id: String,
call_id: String,
) -> ResponseInputItem {
match serde_json::from_str::<GetAgentResultParams>(&arguments) {
let params_for_event = serde_json::from_str(&arguments).ok();
let arguments_clone = arguments.clone();
let call_id_clone = call_id.clone();
execute_custom_tool(
sess,
&sub_id,
call_id,
"agent_result".to_string(),
params_for_event,
|| async move {
match serde_json::from_str::<GetAgentResultParams>(&arguments_clone) {
Ok(params) => {
let manager = AGENT_MANAGER.read().await;
if let Some(agent) = manager.get_agent(&params.agent_id) {
if agent.status == AgentStatus::Completed {
ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: agent
.result
@@ -2586,7 +2628,7 @@ async fn handle_get_agent_result(
}
} else if agent.status == AgentStatus::Failed {
ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!(
"Agent failed: {}",
@@ -2597,7 +2639,7 @@ async fn handle_get_agent_result(
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!(
"Agent is still {}: cannot get result yet",
@@ -2610,7 +2652,7 @@ async fn handle_get_agent_result(
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Agent not found: {}", params.agent_id),
success: Some(false),
@@ -2619,29 +2661,41 @@ async fn handle_get_agent_result(
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Invalid get_agent_result arguments: {}", e),
success: None,
},
},
}
},
).await
}
async fn handle_cancel_agent(
_sess: &Session,
sess: &Session,
arguments: String,
_sub_id: String,
sub_id: String,
call_id: String,
) -> ResponseInputItem {
match serde_json::from_str::<CancelAgentParams>(&arguments) {
let params_for_event = serde_json::from_str(&arguments).ok();
let arguments_clone = arguments.clone();
let call_id_clone = call_id.clone();
execute_custom_tool(
sess,
&sub_id,
call_id,
"agent_cancel".to_string(),
params_for_event,
|| async move {
match serde_json::from_str::<CancelAgentParams>(&arguments_clone) {
Ok(params) => {
let mut manager = AGENT_MANAGER.write().await;
if let Some(agent_id) = params.agent_id {
if manager.cancel_agent(&agent_id).await {
ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Agent {} cancelled", agent_id),
success: Some(true),
@@ -2649,7 +2703,7 @@ async fn handle_cancel_agent(
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Failed to cancel agent {}", agent_id),
success: Some(false),
@@ -2659,7 +2713,7 @@ async fn handle_cancel_agent(
} else if let Some(batch_id) = params.batch_id {
let count = manager.cancel_batch(&batch_id).await;
ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Cancelled {} agents in batch {}", count, batch_id),
success: Some(true),
@@ -2667,7 +2721,7 @@ async fn handle_cancel_agent(
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: "Either agent_id or batch_id must be provided".to_string(),
success: Some(false),
@@ -2676,22 +2730,34 @@ async fn handle_cancel_agent(
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Invalid cancel_agent arguments: {}", e),
success: None,
},
},
}
},
).await
}
async fn handle_wait_for_agent(
_sess: &Session,
sess: &Session,
arguments: String,
_sub_id: String,
sub_id: String,
call_id: String,
) -> ResponseInputItem {
match serde_json::from_str::<WaitForAgentParams>(&arguments) {
let params_for_event = serde_json::from_str(&arguments).ok();
let arguments_clone = arguments.clone();
let call_id_clone = call_id.clone();
execute_custom_tool(
sess,
&sub_id,
call_id,
"agent_wait".to_string(),
params_for_event,
|| async move {
match serde_json::from_str::<WaitForAgentParams>(&arguments_clone) {
Ok(params) => {
let timeout =
std::time::Duration::from_secs(params.timeout_seconds.unwrap_or(300).min(600));
@@ -2700,7 +2766,7 @@ async fn handle_wait_for_agent(
loop {
if start.elapsed() > timeout {
return ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: "Timeout waiting for agent completion".to_string(),
success: Some(false),
@@ -2722,7 +2788,7 @@ async fn handle_wait_for_agent(
"wait_time_seconds": start.elapsed().as_secs(),
});
return ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: response.to_string(),
success: Some(true),
@@ -2759,7 +2825,7 @@ async fn handle_wait_for_agent(
})
};
return ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: response.to_string(),
success: Some(true),
@@ -2773,22 +2839,34 @@ async fn handle_wait_for_agent(
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Invalid wait_for_agent arguments: {}", e),
success: None,
},
},
}
},
).await
}
async fn handle_list_agents(
sess: &Session,
arguments: String,
_sub_id: String,
sub_id: String,
call_id: String,
) -> ResponseInputItem {
match serde_json::from_str::<ListAgentsParams>(&arguments) {
let params_for_event = serde_json::from_str(&arguments).ok();
let arguments_clone = arguments.clone();
let call_id_clone = call_id.clone();
execute_custom_tool(
sess,
&sub_id,
call_id,
"agent_list".to_string(),
params_for_event,
|| async move {
match serde_json::from_str::<ListAgentsParams>(&arguments_clone) {
Ok(params) => {
let manager = AGENT_MANAGER.read().await;
@@ -2875,7 +2953,7 @@ async fn handle_list_agents(
});
ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: summary.to_string(),
success: Some(true),
@@ -2883,13 +2961,15 @@ async fn handle_list_agents(
}
}
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Invalid list_agents arguments: {}", e),
success: None,
},
},
}
},
).await
}
async fn handle_container_exec_with_params(
+7 -7
View File
@@ -546,7 +546,7 @@ mod tests {
);
let tools = get_openai_tools(&config, Some(HashMap::new()), false);
assert_eq_tool_names(&tools, &["local_shell", "update_plan", "browser_open", "browser_status", "run_agent", "check_agent_status", "get_agent_result", "cancel_agent", "wait_for_agent", "list_agents"]);
assert_eq_tool_names(&tools, &["local_shell", "update_plan", "browser_open", "browser_status", "agent_run", "agent_check", "agent_result", "agent_cancel", "agent_wait", "agent_list"]);
}
#[test]
@@ -560,7 +560,7 @@ mod tests {
);
let tools = get_openai_tools(&config, Some(HashMap::new()), false);
assert_eq_tool_names(&tools, &["shell", "update_plan", "browser_open", "browser_status", "run_agent", "check_agent_status", "get_agent_result", "cancel_agent", "wait_for_agent", "list_agents"]);
assert_eq_tool_names(&tools, &["shell", "update_plan", "browser_open", "browser_status", "agent_run", "agent_check", "agent_result", "agent_cancel", "agent_wait", "agent_list"]);
}
#[test]
@@ -611,7 +611,7 @@ mod tests {
false,
);
assert_eq_tool_names(&tools, &["shell", "browser_open", "browser_status", "run_agent", "check_agent_status", "get_agent_result", "cancel_agent", "wait_for_agent", "list_agents", "test_server/do_something_cool"]);
assert_eq_tool_names(&tools, &["shell", "browser_open", "browser_status", "agent_run", "agent_check", "agent_result", "agent_cancel", "agent_wait", "agent_list", "test_server/do_something_cool"]);
assert_eq!(
tools[1],
@@ -691,7 +691,7 @@ mod tests {
false,
);
assert_eq_tool_names(&tools, &["shell", "browser_open", "browser_status", "run_agent", "check_agent_status", "get_agent_result", "cancel_agent", "wait_for_agent", "list_agents", "dash/search"]);
assert_eq_tool_names(&tools, &["shell", "browser_open", "browser_status", "agent_run", "agent_check", "agent_result", "agent_cancel", "agent_wait", "agent_list", "dash/search"]);
assert_eq!(
tools[1],
@@ -745,7 +745,7 @@ mod tests {
false,
);
assert_eq_tool_names(&tools, &["shell", "browser_open", "browser_status", "run_agent", "check_agent_status", "get_agent_result", "cancel_agent", "wait_for_agent", "list_agents", "dash/paginate"]);
assert_eq_tool_names(&tools, &["shell", "browser_open", "browser_status", "agent_run", "agent_check", "agent_result", "agent_cancel", "agent_wait", "agent_list", "dash/paginate"]);
assert_eq!(
tools[1],
OpenAiTool::Function(ResponsesApiTool {
@@ -796,7 +796,7 @@ mod tests {
false,
);
assert_eq_tool_names(&tools, &["shell", "browser_open", "browser_status", "run_agent", "check_agent_status", "get_agent_result", "cancel_agent", "wait_for_agent", "list_agents", "dash/tags"]);
assert_eq_tool_names(&tools, &["shell", "browser_open", "browser_status", "agent_run", "agent_check", "agent_result", "agent_cancel", "agent_wait", "agent_list", "dash/tags"]);
assert_eq!(
tools[1],
OpenAiTool::Function(ResponsesApiTool {
@@ -850,7 +850,7 @@ mod tests {
false,
);
assert_eq_tool_names(&tools, &["shell", "browser_open", "browser_status", "run_agent", "check_agent_status", "get_agent_result", "cancel_agent", "wait_for_agent", "list_agents", "dash/value"]);
assert_eq_tool_names(&tools, &["shell", "browser_open", "browser_status", "agent_run", "agent_check", "agent_result", "agent_cancel", "agent_wait", "agent_list", "dash/value"]);
assert_eq!(
tools[1],
OpenAiTool::Function(ResponsesApiTool {
+180 -17
View File
@@ -78,6 +78,9 @@ pub(crate) struct ChatComposer {
use_shift_enter_hint: bool,
dismissed_file_popup_token: Option<String>,
current_file_query: Option<String>,
// Tracks a one-off Tab-triggered file search. When set, we will only
// create/show a popup if the results are non-empty to avoid flicker.
pending_tab_file_query: Option<String>,
pending_pastes: Vec<(String, String)>,
token_usage_info: Option<TokenUsageInfo>,
has_focus: bool,
@@ -94,6 +97,7 @@ pub(crate) struct ChatComposer {
// Footer hint visibility flags
show_reasoning_hint: bool,
show_diffs_hint: bool,
reasoning_shown: bool,
}
/// Popup state – at most one can be visible at any time.
@@ -122,6 +126,7 @@ impl ChatComposer {
use_shift_enter_hint,
dismissed_file_popup_token: None,
current_file_query: None,
pending_tab_file_query: None,
pending_pastes: Vec::new(),
token_usage_info: None,
has_focus: has_input_focus,
@@ -134,6 +139,7 @@ impl ChatComposer {
footer_notice: None,
show_reasoning_hint: false,
show_diffs_hint: false,
reasoning_shown: false,
}
}
@@ -190,6 +196,10 @@ impl ChatComposer {
}
}
pub fn set_reasoning_state(&mut self, shown: bool) {
self.reasoning_shown = shown;
}
// Map technical status messages to user-friendly ones
fn map_status_message(technical_message: &str) -> String {
let lower = technical_message.to_lowercase();
@@ -254,13 +264,15 @@ impl ChatComposer {
};
// Calculate actual content height of textarea
// Account for: 2 border + 2 horizontal padding (1 left + 1 right)
let content_width = width.saturating_sub(4); // 2 border + 2 horizontal padding
// Width adjustments must mirror render exactly:
// In render, we allocate the input block, then compute its inner area (borders = 2),
// then apply a horizontal Margin of 1 on each side (padding = 2). Total = 4 columns.
let content_width = width.saturating_sub(4);
let content_lines = self.textarea.desired_height(content_width).max(1); // At least 1 line
// Total input height: content + border (2) only, no vertical padding
// Minimum of 3 ensures at least 1 visible line with border
let input_height = (content_lines + 2).max(3).min(15);
let input_height = (content_lines + 2).max(3);
input_height + hint_height
}
@@ -279,7 +291,7 @@ impl ChatComposer {
// Calculate dynamic height based on content
let content_width = area.width.saturating_sub(4); // Account for border and padding
let content_lines = self.textarea.desired_height(content_width).max(1);
let desired_input_height = (content_lines + 2).max(3).min(15); // Dynamic with min/max
let desired_input_height = (content_lines + 2).max(3); // Parent layout enforces max
// Use desired height but don't exceed available space
let input_height = desired_input_height.min(area.height.saturating_sub(hint_height));
@@ -362,16 +374,35 @@ impl ChatComposer {
/// Integrate results from an asynchronous file search.
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
// Only apply if user is still editing a token starting with `query`.
let current_opt = Self::current_at_token(&self.textarea);
let Some(current_token) = current_opt else {
return;
};
// Handle one-off Tab-triggered case first: only open if matches exist.
if self.pending_tab_file_query.as_ref() == Some(&query) {
// Clear pending regardless of result to avoid repeats.
self.pending_tab_file_query = None;
if !current_token.starts_with(&query) {
if matches.is_empty() {
return; // do not open popup when no matches to avoid flicker
}
match &mut self.active_popup {
ActivePopup::File(popup) => popup.set_matches(&query, matches),
_ => {
let mut popup = FileSearchPopup::new();
popup.set_query(&query);
popup.set_matches(&query, matches);
self.active_popup = ActivePopup::File(popup);
}
}
self.current_file_query = Some(query);
self.dismissed_file_popup_token = None;
return;
}
// Otherwise, only apply if user is still editing a token matching the query
// and that token qualifies for auto-trigger (i.e., @ or ./).
let current_opt = Self::current_completion_token(&self.textarea);
let Some(current_token) = current_opt else { return; };
if !current_token.starts_with(&query) { return; }
if let ActivePopup::File(popup) = &mut self.active_popup {
popup.set_matches(&query, matches);
}
@@ -506,7 +537,7 @@ impl ChatComposer {
code: KeyCode::Esc, ..
} => {
// Hide popup without modifying text, remember token to avoid immediate reopen.
if let Some(tok) = Self::current_at_token(&self.textarea) {
if let Some(tok) = Self::current_completion_token(&self.textarea) {
self.dismissed_file_popup_token = Some(tok.to_string());
}
self.active_popup = ActivePopup::None;
@@ -538,8 +569,8 @@ impl ChatComposer {
/// The returned string **does not** include the leading `@`.
///
/// Behavior:
/// - The cursor may be anywhere *inside* the token (including on the
/// leading `@`). It does **not** need to be at the end of the line.
/// - The cursor may be anywhere inside the token (including on the
/// leading `@`). It does not need to be at the end of the line.
/// - A token is delimited by ASCII whitespace (space, tab, newline).
/// - If the token under the cursor starts with `@`, that token is
/// returned without the leading `@`. This includes the case where the
@@ -635,6 +666,103 @@ impl ChatComposer {
left_at.or(right_at)
}
/// Extract the completion token under the cursor for auto file search.
///
/// Auto-trigger only for:
/// - explicit @tokens (without the leading '@' in the return value)
/// - tokens starting with "./" (relative paths)
///
/// Returns the token text (without a leading '@' if present). Any other
/// tokens should not auto-trigger completion; they may be handled on Tab.
fn current_completion_token(textarea: &TextArea) -> Option<String> {
// Prefer explicit @tokens when present.
if let Some(tok) = Self::current_at_token(textarea) {
return Some(tok);
}
// Otherwise, consider the generic token under the cursor, but only
// auto-trigger for tokens starting with "./".
let cursor_offset = textarea.cursor();
let text = textarea.text();
let mut safe_cursor = cursor_offset.min(text.len());
if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) {
safe_cursor = text
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= cursor_offset)
.last()
.unwrap_or(0);
}
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;
if start_idx >= end_idx {
return None;
}
let token = &text[start_idx..end_idx];
// Strip a leading '@' if the user typed it but we didn't catch it
// (paranoia; current_at_token should have handled this case).
let token_stripped = token.strip_prefix('@').unwrap_or(token);
if token_stripped.starts_with("./") {
return Some(token_stripped.to_string());
}
None
}
/// Extract the generic token under the cursor (no special rules).
/// Used for Tab-triggered one-off file searches.
fn current_generic_token(textarea: &TextArea) -> Option<String> {
let cursor_offset = textarea.cursor();
let text = textarea.text();
let mut safe_cursor = cursor_offset.min(text.len());
if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) {
safe_cursor = text
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= cursor_offset)
.last()
.unwrap_or(0);
}
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;
if start_idx >= end_idx { return None; }
Some(text[start_idx..end_idx].trim().to_string())
}
/// Replace the active `@token` (the one under the cursor) with `path`.
///
/// The algorithm mirrors `current_at_token` so replacement works no matter
@@ -677,6 +805,40 @@ impl ChatComposer {
/// Handle key event when no popup is visible.
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
match key_event {
// -------------------------------------------------------------
// Tab-press file search when not using @ or ./ and not in slash cmd
// -------------------------------------------------------------
KeyEvent { code: KeyCode::Tab, .. } => {
// Do not trigger if composing a slash command
let first_line = self.textarea.text().lines().next().unwrap_or("");
let starts_with_slash_cmd = first_line.trim_start().starts_with('/');
if starts_with_slash_cmd {
return (InputResult::None, false);
}
// If already showing a file popup, let the dedicated handler manage Tab.
if matches!(self.active_popup, ActivePopup::File(_)) {
return (InputResult::None, false);
}
// If an @ token is present or token starts with ./, rely on auto-popup.
if Self::current_completion_token(&self.textarea).is_some() {
return (InputResult::None, false);
}
// Use the generic token under cursor for a one-off search.
if let Some(tok) = Self::current_generic_token(&self.textarea) {
if !tok.is_empty() {
self.pending_tab_file_query = Some(tok.clone());
self.app_event_tx.send(AppEvent::StartFileSearch(tok));
// Do not show a popup yet; wait for results and only
// show if there are matches to avoid flicker.
return (InputResult::None, true);
}
}
(InputResult::None, false)
}
// -------------------------------------------------------------
// Handle Esc key for double-press clearing
// -------------------------------------------------------------
@@ -898,8 +1060,8 @@ impl ChatComposer {
/// Synchronize `self.file_search_popup` with the current text in the textarea.
/// Note this is only called when self.active_popup is NOT Command.
fn sync_file_search_popup(&mut self) {
// Determine if there is an @token underneath the cursor.
let query = match Self::current_at_token(&self.textarea) {
// Determine if there is a token underneath the cursor worth completing.
let query = match Self::current_completion_token(&self.textarea) {
Some(token) => token,
None => {
self.active_popup = ActivePopup::None;
@@ -965,7 +1127,7 @@ impl WidgetRef for &ChatComposer {
// Calculate dynamic height based on content
let content_width = area.width.saturating_sub(4); // Account for border and padding
let content_lines = self.textarea.desired_height(content_width).max(1);
let desired_input_height = (content_lines + 2).max(3).min(15); // Dynamic with min/max
let desired_input_height = (content_lines + 2).max(3); // Parent layout enforces max
// Use desired height but don't exceed available space
let input_height = desired_input_height.min(area.height.saturating_sub(hint_height));
@@ -1013,7 +1175,8 @@ impl WidgetRef for &ChatComposer {
if self.show_reasoning_hint {
if !first_right { right_spans.push(Span::from(" • ").style(Style::default())); } else { first_right = false; }
right_spans.push(Span::from("Ctrl+R").style(key_hint_style));
right_spans.push(Span::from(" toggle reasoning").style(label_style));
let label = if self.reasoning_shown { " hide reasoning" } else { " show reasoning" };
right_spans.push(Span::from(label).style(label_style));
}
if self.show_diffs_hint {
if !first_right { right_spans.push(Span::from(" • ").style(Style::default())); } else { first_right = false; }
@@ -1,6 +1,7 @@
use codex_file_search::FileMatch;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::layout::Margin;
use ratatui::widgets::WidgetRef;
use super::popup_consts::MAX_POPUP_ROWS;
@@ -114,6 +115,9 @@ impl FileSearchPopup {
impl WidgetRef for &FileSearchPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Match the slash-command popup: add two spaces of left padding so
// rows align with the text inside the composer (border + inner pad).
let indented_area = area.inner(Margin::new(2, 0));
// Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.
let rows_all: Vec<GenericDisplayRow> = if self.matches.is_empty() {
Vec::new()
@@ -136,9 +140,9 @@ impl WidgetRef for &FileSearchPopup {
if self.waiting && rows_all.is_empty() {
// Render a minimal waiting stub using the shared renderer (no rows -> "no matches").
render_rows(area, buf, &[], &self.state, MAX_POPUP_ROWS);
render_rows(indented_area, buf, &[], &self.state, MAX_POPUP_ROWS);
} else {
render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS);
render_rows(indented_area, buf, &rows_all, &self.state, MAX_POPUP_ROWS);
}
}
}
+5
View File
@@ -399,6 +399,11 @@ impl BottomPane<'_> {
self.request_redraw();
}
pub(crate) fn set_reasoning_state(&mut self, shown: bool) {
self.composer.set_reasoning_state(shown);
self.request_redraw();
}
pub(crate) fn set_diffs_hint(&mut self, show: bool) {
self.composer.set_show_diffs_hint(show);
self.request_redraw();
+391 -45
View File
@@ -145,6 +145,7 @@ pub(crate) struct ChatWidget<'a> {
// Accumulated patch change sets for this session (latest last)
session_patch_sets: Vec<HashMap<PathBuf, codex_core::protocol::FileChange>>,
diff_overlay: Option<DiffOverlay>,
diff_confirm: Option<DiffConfirm>,
// Cache for expensive height calculations per cell and width
height_cache: std::cell::RefCell<std::collections::HashMap<(usize, u16), u16>>,
@@ -152,21 +153,32 @@ pub(crate) struct ChatWidget<'a> {
height_cache_last_width: std::cell::Cell<u16>,
// Track last viewport height of the history content area to stabilize scrolling
last_history_viewport_height: std::cell::Cell<u16>,
// Cached visible rows for the diff overlay body to clamp scrolling
diff_body_visible_rows: std::cell::Cell<u16>,
}
struct DiffOverlay {
tabs: Vec<(String, Vec<ratatui::text::Line<'static>>)>,
tabs: Vec<(String, Vec<DiffBlock>)>,
selected: usize,
scroll_offsets: Vec<u16>,
}
impl DiffOverlay {
fn new(tabs: Vec<(String, Vec<ratatui::text::Line<'static>>)>) -> Self {
fn new(tabs: Vec<(String, Vec<DiffBlock>)>) -> Self {
let n = tabs.len();
Self { tabs, selected: 0, scroll_offsets: vec![0; n] }
}
}
#[derive(Clone)]
struct DiffBlock {
lines: Vec<ratatui::text::Line<'static>>,
}
struct DiffConfirm {
text_to_submit: String,
}
struct UserMessage {
text: String,
image_paths: Vec<PathBuf>,
@@ -575,9 +587,12 @@ impl ChatWidget<'_> {
// Using 5 here keeps the input pinned in place when scrolling up,
// allowing history to extend into the reclaimed spacer row without
// shifting the input area upward.
// Cap bottom pane to 30% of the screen height (at least 5 rows)
let percent_cap: u16 = ((area.height as u32).saturating_mul(30) / 100) as u16;
let bottom_cap = percent_cap.max(5);
let bottom_height = 5u16
.max(self.bottom_pane.desired_height(area.width))
.min(15);
.min(bottom_cap);
if has_browser_screenshot || has_active_agents {
// match HUD padding used in render_browser_hud()
@@ -757,9 +772,11 @@ impl ChatWidget<'_> {
interrupts: interrupts::InterruptManager::new(),
session_patch_sets: Vec::new(),
diff_overlay: None,
diff_confirm: None,
height_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
height_cache_last_width: std::cell::Cell::new(0),
last_history_viewport_height: std::cell::Cell::new(0),
diff_body_visible_rows: std::cell::Cell::new(0),
};
// Note: Initial redraw needs to be triggered after widget is added to app_state
@@ -809,48 +826,141 @@ impl ChatWidget<'_> {
// Intercept keys for diff overlay when active
if let Some(ref mut overlay) = self.diff_overlay {
use crossterm::event::KeyCode;
// Handle confirmation dialog if active
if let Some(confirm) = self.diff_confirm.take() {
match key_event.code {
KeyCode::Enter => {
self.submit_user_message(confirm.text_to_submit.into());
self.diff_overlay = None;
self.request_redraw();
return;
}
KeyCode::Esc => {
// Cancel confirmation
self.diff_confirm = None;
self.request_redraw();
return;
}
_ => {
// Put it back for other keys
self.diff_confirm = Some(confirm);
}
}
}
match key_event.code {
KeyCode::Left => {
if overlay.selected > 0 { overlay.selected -= 1; }
if let Some(off) = overlay.scroll_offsets.get_mut(overlay.selected) { *off = 0; }
self.request_redraw();
return;
}
KeyCode::Right => {
if overlay.selected + 1 < overlay.tabs.len() { overlay.selected += 1; }
if let Some(off) = overlay.scroll_offsets.get_mut(overlay.selected) { *off = 0; }
self.request_redraw();
return;
}
KeyCode::Up => {
if let Some(off) = overlay.scroll_offsets.get_mut(overlay.selected) {
*off = off.saturating_sub(1);
// Clamp to current max offset
let visible_rows = self.diff_body_visible_rows.get() as usize;
let total_lines: usize = overlay
.tabs
.get(overlay.selected)
.map(|(_, blocks)| blocks.iter().map(|b| b.lines.len()).sum())
.unwrap_or(0);
let max_off = total_lines.saturating_sub(visible_rows.max(1));
// Ensure we don't keep overscrolled values
let cur = (*off).min(max_off as u16);
*off = cur.saturating_sub(1);
}
self.request_redraw();
return;
}
KeyCode::Down => {
if let Some(off) = overlay.scroll_offsets.get_mut(overlay.selected) {
*off = off.saturating_add(1);
let visible_rows = self.diff_body_visible_rows.get() as usize;
let total_lines: usize = overlay
.tabs
.get(overlay.selected)
.map(|(_, blocks)| blocks.iter().map(|b| b.lines.len()).sum())
.unwrap_or(0);
let max_off = total_lines.saturating_sub(visible_rows.max(1));
let next = (*off as usize).saturating_add(1).min(max_off);
*off = next as u16;
}
self.request_redraw();
return;
}
KeyCode::Char('u') => {
if let Some((_, lines)) = overlay.tabs.get(overlay.selected) {
let mut text = String::from("Please undo this:\n");
for l in lines {
let s: String = l.spans.iter().map(|sp| sp.content.clone()).collect();
text.push_str(&s);
text.push('\n');
if let Some((_, blocks)) = overlay.tabs.get(overlay.selected) {
// Determine selected block from scroll position
let visible_rows = self.diff_body_visible_rows.get() as usize;
let total_lines: usize = blocks.iter().map(|b| b.lines.len()).sum();
let max_off = total_lines.saturating_sub(visible_rows.max(1));
let skip_raw = overlay.scroll_offsets.get(overlay.selected).copied().unwrap_or(0) as usize;
let skip = skip_raw.min(max_off);
let mut start = 0usize;
let mut chosen: Option<&DiffBlock> = None;
for b in blocks {
let len = b.lines.len();
if start <= skip && skip < start + len {
chosen = Some(b);
}
start += len;
}
if let Some(block) = chosen {
let mut diff_text = String::new();
for l in &block.lines {
let s: String = l.spans.iter().map(|sp| sp.content.clone()).collect();
diff_text.push_str(&s);
diff_text.push('\n');
}
// Keep only the final command for submission, show simple confirm UI in render
let submit_text = format!("Please undo this:\n{}", diff_text);
self.diff_confirm = Some(DiffConfirm { text_to_submit: submit_text });
self.request_redraw();
}
}
return;
}
KeyCode::Char('e') => {
if let Some((_, blocks)) = overlay.tabs.get(overlay.selected) {
let visible_rows = self.diff_body_visible_rows.get() as usize;
let total_lines: usize = blocks.iter().map(|b| b.lines.len()).sum();
let max_off = total_lines.saturating_sub(visible_rows.max(1));
let skip_raw = overlay.scroll_offsets.get(overlay.selected).copied().unwrap_or(0) as usize;
let skip = skip_raw.min(max_off);
let mut start = 0usize;
let mut chosen: Option<&DiffBlock> = None;
for b in blocks {
let len = b.lines.len();
if start <= skip && skip < start + len {
chosen = Some(b);
}
start += len;
}
if let Some(block) = chosen {
let mut diff_text = String::new();
for l in &block.lines {
let s: String = l.spans.iter().map(|sp| sp.content.clone()).collect();
diff_text.push_str(&s);
diff_text.push('\n');
}
let prompt = format!(
"Can you please explain what this diff does and the reason behind it?\n\n{}",
diff_text
);
self.submit_user_message(prompt.into());
self.diff_overlay = None;
self.request_redraw();
}
self.bottom_pane.insert_str(&text);
self.bottom_pane.flash_footer_notice("Press Enter to confirm undo".to_string());
}
self.diff_overlay = None;
self.request_redraw();
return;
}
KeyCode::Esc => {
self.diff_overlay = None;
self.diff_confirm = None;
self.request_redraw();
return;
}
@@ -1053,6 +1163,8 @@ impl ChatWidget<'_> {
}
}
combined.extend(body);
// Coalesce adjacent Read entries of the same file with contiguous ranges
coalesce_read_ranges_in_lines(&mut combined);
*last_box = Box::new(history_cell::PlainHistoryCell { lines: combined, kind: history_cell::HistoryCellType::Plain });
self.autoscroll_if_near_bottom();
self.bottom_pane.set_has_chat_history(true);
@@ -1060,6 +1172,68 @@ impl ChatWidget<'_> {
self.app_event_tx.send(AppEvent::RequestRedraw);
return;
}
} else {
// Also allow merging into an already-merged PlainHistoryCell produced above
if let Some(new_exec) = (&*new_cell).as_any().downcast_ref::<history_cell::ExecCell>() {
// Compute the label for the incoming exec
let exec_label = |e: &history_cell::ExecCell| -> &'static str {
let action = history_cell::action_from_parsed(&e.parsed);
match (&e.output, action) {
(None, "read") => "Reading...",
(None, "search") => "Searching...",
(None, "list") => "Listing...",
(None, _) => "Running...",
(Some(o), "read") if o.exit_code == 0 => "Read",
(Some(o), "search") if o.exit_code == 0 => "Searched",
(Some(o), "list") if o.exit_code == 0 => "Listed",
(Some(o), _) if o.exit_code == 0 => "Ran",
_ => "",
}
};
let new_label = exec_label(new_exec);
let is_joinable_label = |s: &str| matches!(s, "Searched" | "Read" | "Listed" | "Ran" | "Reading..." | "Searching..." | "Listing..." | "Running...");
if let Some(last_plain) = last_box.as_any_mut().downcast_mut::<history_cell::PlainHistoryCell>() {
// Extract the header label from the first line (best-effort)
let last_lines_snapshot = last_plain.lines.clone();
let last_header = last_lines_snapshot.first().and_then(|l| l.spans.get(0)).map(|s| s.content.clone().to_string()).unwrap_or_default();
if !new_label.is_empty() && is_joinable_label(new_label) && last_header == new_label {
// Merge by appending the new body's content lines
let new_lines = new_cell.display_lines();
let mut combined = last_plain.lines.clone();
while combined
.last()
.map(|l| crate::render::line_utils::is_blank_line_trim(l))
.unwrap_or(false)
{
combined.pop();
}
let mut body: Vec<ratatui::text::Line<'static>> = new_lines.into_iter().skip(1).collect();
while body.first().map(|l| crate::render::line_utils::is_blank_line_trim(l)).unwrap_or(false) {
body.remove(0);
}
while body.last().map(|l| crate::render::line_utils::is_blank_line_trim(l)).unwrap_or(false) {
body.pop();
}
if let Some(first_line) = body.first_mut() {
if let Some(first_span) = first_line.spans.get_mut(0) {
if first_span.content == " └ " || first_span.content == "└ " {
first_span.content = " ".into();
}
}
}
combined.extend(body);
// Coalesce adjacent Read entries of the same file with contiguous ranges
coalesce_read_ranges_in_lines(&mut combined);
last_plain.lines = combined;
self.autoscroll_if_near_bottom();
self.bottom_pane.set_has_chat_history(true);
self.process_animation_cleanup();
self.app_event_tx.send(AppEvent::RequestRedraw);
return;
}
}
}
}
}
@@ -1614,10 +1788,13 @@ impl ChatWidget<'_> {
self.finalize_active_stream();
// Flush any queued interrupts when streaming ends
self.flush_interrupt_queue();
let params_string = parameters.map(|p| p.to_string());
self.add_to_history(history_cell::new_active_custom_tool_call(
tool_name, params_string,
));
// For browser_* and agent_* tools, suppress the separate begin entry to show a single combined section
if !(tool_name.starts_with("browser_") || tool_name.starts_with("agent_")) {
let params_string = parameters.map(|p| p.to_string());
self.add_to_history(history_cell::new_active_custom_tool_call(
tool_name, params_string,
));
}
}
EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
call_id: _,
@@ -1790,19 +1967,19 @@ impl ChatWidget<'_> {
}
}
// Build tabs: for each file, concatenate diffs from all change sets in chronological order
let mut tabs: Vec<(String, Vec<ratatui::text::Line<'static>>)> = Vec::new();
let mut tabs: Vec<(String, Vec<DiffBlock>)> = Vec::new();
for path in order {
let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new();
let mut blocks: Vec<DiffBlock> = Vec::new();
for changes in &self.session_patch_sets {
if let Some(change) = changes.get(&path) {
let mut single = HashMap::new();
single.insert(path.clone(), change.clone());
let mut detail = create_diff_summary("patch", &single, history_cell::PatchEventType::ApprovalRequest);
lines.append(&mut detail);
let detail = create_diff_summary("patch", &single, history_cell::PatchEventType::ApprovalRequest);
blocks.push(DiffBlock { lines: detail });
}
}
let title = path.file_name().and_then(|s| s.to_str()).map(|s| s.to_string()).unwrap_or_else(|| path.display().to_string());
tabs.push((title, lines));
tabs.push((title, blocks));
}
if tabs.is_empty() {
// Nothing to show — surface a small notice so Ctrl+D feels responsive
@@ -1810,6 +1987,7 @@ impl ChatWidget<'_> {
return;
}
self.diff_overlay = Some(DiffOverlay::new(tabs));
self.diff_confirm = None;
self.request_redraw();
}
@@ -2203,6 +2381,8 @@ impl ChatWidget<'_> {
self.clear_reasoning_in_progress();
// Ensure footer shows Ctrl+R hint when reasoning content is present
self.bottom_pane.set_reasoning_hint(true);
// Update footer label to reflect current visibility state
self.bottom_pane.set_reasoning_state(self.is_reasoning_shown());
// Append to last reasoning cell if present; else create a new one
if let Some(last) = self.history_cells.last_mut() {
if let Some(reasoning_cell) = last
@@ -2317,6 +2497,8 @@ impl ChatWidget<'_> {
// Brief status to confirm the toggle to the user
let status = if self.config.tui.show_reasoning { "Reasoning shown" } else { "Reasoning hidden" };
self.bottom_pane.update_status_text(status.to_string());
// Update footer label to reflect current state
self.bottom_pane.set_reasoning_state(self.config.tui.show_reasoning);
} else {
// No reasoning cells exist; inform the user
self.bottom_pane.update_status_text("No reasoning to toggle".to_string());
@@ -3419,7 +3601,7 @@ impl ChatWidget<'_> {
Span::styled("Model: ", Style::default().dim()),
Span::styled(
self.format_model_name(&self.config.model),
Style::default().fg(crate::colors::secondary()),
Style::default().fg(crate::colors::info()),
),
Span::raw(" • "),
Span::styled("Reasoning: ", Style::default().dim()),
@@ -4247,7 +4429,7 @@ impl WidgetRef for &ChatWidget<'_> {
if let Some(exec) = item.as_any().downcast_ref::<crate::history_cell::ExecCell>() {
match &exec.output {
None => crate::colors::primary(), // Running...
Some(o) if o.exit_code == 0 => crate::colors::primary(), // Ran
Some(o) if o.exit_code == 0 => crate::colors::text_bright(), // Ran
Some(_) => crate::colors::error(),
}
} else {
@@ -4262,12 +4444,12 @@ impl WidgetRef for &ChatWidget<'_> {
} else {
match symbol {
"›" => crate::colors::text(), // user
"⋮" => crate::colors::text_dim(), // thinking
"⋮" => crate::colors::primary(), // thinking
"•" => crate::colors::text_bright(), // codex/agent
"⚙" => crate::colors::primary(), // tool working
"✔" => crate::colors::success(), // tool complete
"✖" => crate::colors::error(), // error
"★" => crate::colors::primary(), // notice/popular
"★" => crate::colors::text_bright(), // notice/popular
_ => crate::colors::text_dim(),
}
};
@@ -4396,29 +4578,122 @@ impl WidgetRef for &ChatWidget<'_> {
// Add one cell padding around the entire inside of the window
let padded_inner = inner.inner(ratatui::layout::Margin::new(1, 1));
let [tabs_area, body_area] =
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(padded_inner);
Layout::vertical([Constraint::Length(3), Constraint::Fill(1)]).areas(padded_inner);
// Render tabs
let titles = overlay.tabs.iter().map(|(t, _)| ratatui::text::Line::from(format!(" {t} "))).collect::<Vec<_>>();
let tabs = ratatui::widgets::Tabs::new(titles)
.select(overlay.selected)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.divider(" ");
ratatui::widgets::Widget::render(tabs, tabs_area, buf);
// Render tabs with a more tab-like look (individual bordered blocks)
let labels: Vec<String> = overlay
.tabs
.iter()
.map(|(t, _)| format!(" {} ", t))
.collect();
let mut constraints: Vec<Constraint> = Vec::new();
let mut total: u16 = 0;
for label in &labels {
let w = (label.chars().count() as u16).min(tabs_area.width.saturating_sub(total));
constraints.push(Constraint::Length(w));
total = total.saturating_add(w);
if total >= tabs_area.width.saturating_sub(4) { break; }
}
constraints.push(Constraint::Fill(1));
let chunks = Layout::horizontal(constraints).split(tabs_area);
for i in 0..labels.len() { // last chunk is filler; guard below
if i >= chunks.len().saturating_sub(1) { break; }
let rect = chunks[i];
if rect.width == 0 { continue; }
let selected = i == overlay.selected;
let border_style = if selected {
Style::default().fg(crate::colors::primary())
} else {
Style::default().fg(crate::colors::border())
};
let mut b = Block::default()
.border_style(border_style)
.title(ratatui::text::Line::from(if selected {
ratatui::text::Span::styled(labels[i].clone(), Style::default().fg(crate::colors::text()).add_modifier(Modifier::BOLD))
} else {
ratatui::text::Span::styled(labels[i].clone(), Style::default().fg(crate::colors::text_dim()))
}));
if selected {
b = b.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT);
} else {
b = b.borders(Borders::ALL);
}
b.render(rect, buf);
}
// Render selected tab with vertical scroll
if let Some((_, lines)) = overlay.tabs.get(overlay.selected) {
let mut visible_lines: Vec<ratatui::text::Line<'static>> = Vec::new();
let skip = overlay.scroll_offsets.get(overlay.selected).copied().unwrap_or(0) as usize;
// Render selected tab with vertical scroll and highlight current diff block
if let Some((_, blocks)) = overlay.tabs.get(overlay.selected) {
// Flatten blocks into lines and record block start indices
let mut all_lines: Vec<ratatui::text::Line<'static>> = Vec::new();
let mut block_starts: Vec<(usize, usize)> = Vec::new(); // (start_index, len)
for b in blocks {
let start = all_lines.len();
block_starts.push((start, b.lines.len()));
all_lines.extend(b.lines.clone());
}
// Use full body area for content
let raw_skip = overlay.scroll_offsets.get(overlay.selected).copied().unwrap_or(0) as usize;
let visible_rows = body_area.height as usize;
// Cache visible rows so key handler can clamp
self.diff_body_visible_rows.set(body_area.height);
let max_off = all_lines.len().saturating_sub(visible_rows.max(1));
let skip = raw_skip.min(max_off);
let body_inner = body_area;
let visible_rows = body_inner.height as usize;
for l in lines.iter().skip(skip).take(visible_rows) {
visible_lines.push(l.clone());
}
let paragraph = Paragraph::new(RtText::from(visible_lines)).wrap(ratatui::widgets::Wrap { trim: false });
// Collect visible slice
let end = (skip + visible_rows).min(all_lines.len());
let visible = if skip < all_lines.len() { &all_lines[skip..end] } else { &[] };
let paragraph = Paragraph::new(RtText::from(visible.to_vec())).wrap(ratatui::widgets::Wrap { trim: false });
ratatui::widgets::Widget::render(paragraph, body_inner, buf);
// Determine selected block based on scroll position
let mut selected_block: Option<(usize, usize)> = None; // (start, len)
for (start, len) in &block_starts {
if *start <= skip && skip < *start + *len {
selected_block = Some((*start, *len));
}
}
if let Some((start, len)) = selected_block {
let top_in_view = start.saturating_sub(skip);
if top_in_view < visible_rows {
let height = len.min(visible_rows - top_in_view);
// Draw a subtle border around the current block
let highlight = Rect {
x: body_inner.x,
y: body_inner.y + top_in_view as u16,
width: body_inner.width,
height: height as u16,
};
let border_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::primary()));
border_block.render(highlight, buf);
}
}
// Render confirmation dialog if active
if self.diff_confirm.is_some() {
// Centered small box
let w = (body_inner.width as i16 - 10).max(20) as u16;
let h = 5u16;
let x = body_inner.x + (body_inner.width.saturating_sub(w)) / 2;
let y = body_inner.y + (body_inner.height.saturating_sub(h)) / 2;
let dialog = Rect { x, y, width: w, height: h };
Clear.render(dialog, buf);
let dlg_block = Block::default()
.borders(Borders::ALL)
.title("Confirm Undo")
.border_style(Style::default().fg(crate::colors::primary()));
let dlg_inner = dlg_block.inner(dialog);
dlg_block.render(dialog, buf);
let lines = vec![
ratatui::text::Line::from("Are you sure you want to undo this diff?"),
ratatui::text::Line::from("Press Enter to confirm • Esc to cancel".to_string().dim()),
];
let para = Paragraph::new(RtText::from(lines)).wrap(ratatui::widgets::Wrap { trim: true });
ratatui::widgets::Widget::render(para, dlg_inner, buf);
}
}
}
}
@@ -4451,3 +4726,74 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU
total_tokens: current_usage.total_tokens + new_usage.total_tokens,
}
}
// Coalesce adjacent Read entries of the same file with contiguous ranges in a rendered lines vector.
// Expects the vector to contain a header line at index 0 (e.g., "Read"). Modifies in place.
fn coalesce_read_ranges_in_lines(lines: &mut Vec<ratatui::text::Line<'static>>) {
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
if lines.len() <= 2 {
return;
}
// Helper to parse a content line into (filename, start, end, prefix)
fn parse_read_line(line: &Line<'_>) -> Option<(String, u32, u32, String)> {
if line.spans.is_empty() {
return None;
}
// First span should be prefix (dim): "└ " or " "
let prefix = line.spans[0].content.to_string();
// Only consider the two standard prefixes this renderer emits
if !(prefix == "└ " || prefix == " ") {
return None;
}
// Concatenate the rest of spans as one string for parsing
let rest: String = line
.spans
.iter()
.skip(1)
.map(|s| s.content.as_ref())
.collect();
// Expect format: "<fname> (lines X to Y)"
if let Some(idx) = rest.rfind(" (lines ") {
let fname = rest[..idx].to_string();
let tail = &rest[idx + 1..]; // starts with "(lines ..."
if tail.starts_with("(lines ") && tail.ends_with(")") {
let inner = &tail[7..tail.len() - 1]; // remove "(lines " and trailing ")"
if let Some((s1, s2)) = inner.split_once(" to ") {
if let (Ok(start), Ok(end)) = (s1.trim().parse::<u32>(), s2.trim().parse::<u32>()) {
return Some((fname, start, end, prefix));
}
}
}
}
None
}
let mut i: usize = 1; // start after header
while i + 1 < lines.len() {
let left = parse_read_line(&lines[i]);
let right = parse_read_line(&lines[i + 1]);
if let (Some((fname_a, mut a1, mut a2, prefix_a)), Some((fname_b, b1, b2, _prefix_b))) = (left, right) {
if fname_a == fname_b {
// Consider contiguous if next start equals prev end, or prev end + 1
if b1 == a2 || b1 == a2 + 1 {
a1 = a1.min(b1);
a2 = a2.max(b2);
// Rebuild line i with new range, preserving styling conventions
let new_spans: Vec<Span<'static>> = vec![
Span::styled(prefix_a, Style::default().add_modifier(Modifier::DIM)),
Span::styled(fname_a, Style::default().fg(crate::colors::text())),
Span::styled(format!(" (lines {} to {})", a1, a2), Style::default().fg(crate::colors::text_dim())),
];
lines[i] = Line::from(new_spans);
// Remove merged line and continue without advancing i to check further merges
lines.remove(i + 1);
continue;
}
}
}
i += 1;
}
}
+200 -8
View File
@@ -558,6 +558,7 @@ pub(crate) struct ToolCallCell {
impl HistoryCell for ToolCallCell {
fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
fn as_any(&self) -> &dyn std::any::Any { self }
fn kind(&self) -> HistoryCellType {
HistoryCellType::Tool {
status: match self.state {
@@ -568,12 +569,8 @@ impl HistoryCell for ToolCallCell {
}
}
fn display_lines(&self) -> Vec<Line<'static>> {
// Hide header line if gutter is present
if self.lines.len() > 1 {
self.lines[1..].to_vec()
} else {
Vec::new()
}
// Show all lines; header visibility aligns with exec-style sections
self.lines.clone()
}
}
@@ -746,6 +743,11 @@ impl HistoryCell for CollapsibleReasoningCell {
}
fn kind(&self) -> HistoryCellType { HistoryCellType::Reasoning }
// Ensure collapsed reasoning always gets standard spacing after it.
// Treating it as a title-only cell suppresses the inter-cell spacer,
// which causes the missing blank line effect users observed.
fn is_title_only(&self) -> bool { false }
fn display_lines(&self) -> Vec<Line<'static>> {
if self.lines.is_empty() {
return Vec::new();
@@ -938,7 +940,7 @@ pub(crate) fn new_session_info(
Line::from("notice".dim()),
Line::styled(
"Popular commands:",
Style::default().fg(crate::colors::primary()),
Style::default().fg(crate::colors::text_bright()),
),
Line::from(vec![
Span::styled("/chrome", Style::default().fg(crate::colors::primary())),
@@ -1396,7 +1398,7 @@ fn new_exec_command_generic(
}
Some(o) if o.exit_code == 0 => Line::styled(
"Ran",
Style::default().fg(crate::colors::success()).add_modifier(Modifier::BOLD),
Style::default().fg(crate::colors::text_bright()).add_modifier(Modifier::BOLD),
),
Some(o) => Line::styled(
format!("Error (exit {})", o.exit_code),
@@ -1471,6 +1473,14 @@ pub(crate) fn new_completed_custom_tool_call(
success: bool,
result: String,
) -> ToolCallCell {
// Special rendering for browser_* tools
if tool_name.starts_with("browser_") {
return new_completed_browser_tool_call(tool_name, args, duration, success, result);
}
// Special rendering for agent_* tools
if tool_name.starts_with("agent_") {
return new_completed_agent_tool_call(tool_name, args, duration, success, result);
}
let duration = format_duration(duration);
let status_str = if success { "Complete" } else { "Error" };
let title_line = if success {
@@ -1514,6 +1524,188 @@ pub(crate) fn new_completed_custom_tool_call(
ToolCallCell { lines, state: if success { ToolState::Success } else { ToolState::Failed } }
}
// Map `browser_*` tool names to friendly titles
fn browser_tool_title(tool_name: &str) -> &'static str {
match tool_name {
"browser_click" => "Browser Click",
"browser_type" => "Browser Type",
"browser_key" => "Browser Key",
"browser_javascript" => "Browser Run",
"browser_scroll" => "Browser Scroll",
"browser_open" => "Browser Open",
"browser_close" => "Browser Close",
"browser_status" => "Browser Status",
_ => "Browser Tool",
}
}
fn format_browser_args_line(args: &serde_json::Value) -> Vec<Line<'static>> {
use serde_json::Value;
let mut lines: Vec<Line<'static>> = Vec::new();
let dim = |s: &str| Span::styled(s.to_string(), Style::default().fg(crate::colors::text_dim()));
let text = |s: String| Span::styled(s, Style::default().fg(crate::colors::text()));
// Helper to one-line, truncated representation for values
fn short(v: &serde_json::Value, key: &str) -> String {
match v {
serde_json::Value::String(s) => {
let mut one = s.replace('\n', " ");
let max = if key == "code" { 80 } else { 80 };
if one.chars().count() > max {
let truncated: String = one.chars().take(max).collect();
format!("{}…", truncated)
} else {
one
}
}
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Array(a) => format!("[{} items]", a.len()),
serde_json::Value::Object(o) => format!("{{{} keys}}", o.len()),
serde_json::Value::Null => "null".to_string(),
}
}
match args {
Value::Object(map) => {
// Preserve insertion order (serde_json in this crate preserves order via feature)
for (k, v) in map {
let val = short(v, k);
lines.push(Line::from(vec![
dim("└ "),
dim(&format!("{}: ", k)),
text(val),
]));
}
}
Value::Null => {}
other => {
lines.push(Line::from(vec![dim("└ args: "), text(other.to_string())]));
}
}
lines
}
fn new_completed_browser_tool_call(
tool_name: String,
args: Option<String>,
duration: Duration,
success: bool,
result: String,
) -> ToolCallCell {
let title = browser_tool_title(&tool_name);
let duration = format_duration(duration);
// Title styled by status with duration dimmed
let title_line = if success {
Line::from(vec![
Span::styled(title, Style::default().fg(crate::colors::success()).add_modifier(Modifier::BOLD)),
format!(", duration: {duration}").dim(),
])
} else {
Line::from(vec![
Span::styled(title, Style::default().fg(crate::colors::error()).add_modifier(Modifier::BOLD)),
format!(", duration: {duration}").dim(),
])
};
// Parse args JSON (if provided)
let mut arg_lines: Vec<Line<'static>> = Vec::new();
if let Some(args_str) = args {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&args_str) {
arg_lines.extend(format_browser_args_line(&json));
}
}
// Result line (truncated)
let mut result_lines: Vec<Line<'static>> = Vec::new();
if !result.is_empty() {
let truncated = format_and_truncate_tool_result(&result, TOOL_CALL_MAX_LINES, 80);
result_lines.push(Line::from(vec![
Span::styled("Result: ", Style::default().fg(crate::colors::text_dim()).add_modifier(Modifier::BOLD)),
Span::styled(truncated, Style::default().fg(crate::colors::text())),
]));
}
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(title_line);
lines.extend(arg_lines);
if !result_lines.is_empty() {
lines.push(Line::from(""));
lines.extend(result_lines);
}
lines.push(Line::from(""));
ToolCallCell { lines, state: if success { ToolState::Success } else { ToolState::Failed } }
}
// Map `agent_*` tool names to friendly titles
fn agent_tool_title(tool_name: &str) -> &'static str {
match tool_name {
"agent_run" => "Agent Run",
"agent_check" => "Agent Check",
"agent_result" => "Agent Result",
"agent_cancel" => "Agent Cancel",
"agent_wait" => "Agent Wait",
"agent_list" => "Agent List",
_ => "Agent Tool",
}
}
fn new_completed_agent_tool_call(
tool_name: String,
args: Option<String>,
duration: Duration,
success: bool,
result: String,
) -> ToolCallCell {
let title = agent_tool_title(&tool_name);
let duration = format_duration(duration);
// Title styled by status with duration dimmed
let title_line = if success {
Line::from(vec![
Span::styled(title, Style::default().fg(crate::colors::success()).add_modifier(Modifier::BOLD)),
format!(", duration: {duration}").dim(),
])
} else {
Line::from(vec![
Span::styled(title, Style::default().fg(crate::colors::error()).add_modifier(Modifier::BOLD)),
format!(", duration: {duration}").dim(),
])
};
// Parse args JSON (if provided)
let mut arg_lines: Vec<Line<'static>> = Vec::new();
if let Some(args_str) = args {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&args_str) {
arg_lines.extend(format_browser_args_line(&json));
}
}
// Result line (truncated)
let mut result_lines: Vec<Line<'static>> = Vec::new();
if !result.is_empty() {
let truncated = format_and_truncate_tool_result(&result, TOOL_CALL_MAX_LINES, 80);
result_lines.push(Line::from(vec![
Span::styled("Result: ", Style::default().fg(crate::colors::text_dim()).add_modifier(Modifier::BOLD)),
Span::styled(truncated, Style::default().fg(crate::colors::text())),
]));
}
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(title_line);
lines.extend(arg_lines);
if !result_lines.is_empty() {
lines.push(Line::from(""));
lines.extend(result_lines);
}
lines.push(Line::from(""));
ToolCallCell { lines, state: if success { ToolState::Success } else { ToolState::Failed } }
}
// Try to create an image cell if the MCP result contains an image
fn try_new_completed_mcp_tool_call_with_image_output(
result: &Result<mcp_types::CallToolResult, String>,
+1 -1
View File
@@ -231,7 +231,7 @@ pub async fn run_main(
);
if managed_by_npm {
let npm_cmd = "npm install -g @just-every/coder@latest";
let npm_cmd = "npm install -g @just-every/code@latest";
eprintln!("Run {} to update.", npm_cmd.cyan().on_black());
} else if cfg!(target_os = "macos")
&& (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
+14 -3
View File
@@ -224,6 +224,11 @@ impl MarkdownRenderer {
fn process_inline_text(&mut self, text: &str) {
let spans = self.process_inline_spans(text);
self.current_line.extend(spans);
// If bold-first is enabled, restrict bolding to the first rendered line.
// Even when no sentence terminator is present, only the first line should be bold.
if self.bold_first_sentence && !self.first_sentence_done {
self.first_sentence_done = true;
}
self.flush_current_line();
}
@@ -282,10 +287,16 @@ impl MarkdownRenderer {
while j + 1 < chars.len() {
if chars[j] == marker && chars[j + 1] == marker {
// Found closing markers
// Assistant messages render with bright bold; others stay dim
let bold_color = if self.bold_first_sentence {
crate::colors::text_dim()
} else {
crate::colors::text_bright()
};
spans.push(Span::styled(
bold_content,
Style::default()
.fg(crate::colors::text_dim())
.fg(bold_color)
.add_modifier(Modifier::BOLD),
));
i = j + 2;
@@ -358,7 +369,7 @@ impl MarkdownRenderer {
spans.push(Span::styled(
first_sentence.to_string(),
Style::default()
.fg(crate::colors::text_dim())
.fg(crate::colors::text_bright())
.add_modifier(Modifier::BOLD),
));
self.first_sentence_done = true;
@@ -372,7 +383,7 @@ impl MarkdownRenderer {
spans.push(Span::styled(
current_text,
Style::default()
.fg(crate::colors::text_dim())
.fg(crate::colors::text_bright())
.add_modifier(Modifier::BOLD),
));
}
+1 -1
View File
@@ -196,7 +196,7 @@ impl StreamController {
}
}
if !newly_completed.is_empty() {
// Color reasoning as text_dim and answers as text_bright, preserving span modifiers (e.g., bold)
// Color reasoning as text_dim and answers as text_bright, preserving span modifiers
let color = match kind {
StreamKind::Reasoning => crate::colors::text_dim(),
StreamKind::Answer => crate::colors::text_bright(),
+2 -2
View File
@@ -157,10 +157,10 @@ impl HeaderEmitter {
fn render_header_line(kind: StreamKind) -> ratatui::text::Line<'static> {
match kind {
// Reasoning header in dim text
// Reasoning header uses theme primary color
StreamKind::Reasoning => ratatui::text::Line::styled(
"thinking",
ratatui::style::Style::default().fg(crate::colors::text_dim()),
ratatui::style::Style::default().fg(crate::colors::primary()),
),
// Agent/Codex header in bright text
StreamKind::Answer => ratatui::text::Line::styled(
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB