mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-17 15:34:54 +00:00
chore: commit pending changes on main (CLI, TUI, core, docs)
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
 
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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(¶ms.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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 5.6 KiB |
Reference in New Issue
Block a user