mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat: add debug logging system and improve TUI conversation history
- Add comprehensive debug logging system with file output - Implement ScrollView component for better scrolling behavior - Enhance conversation history with proper text wrapping - Add browser debugging capabilities - Improve ephemeral context handling - Fix message duplication issues - Add test scripts for debugging Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -14,3 +14,34 @@ Completion/build step
|
||||
- Do not run additional format/lint/test commands on completion (e.g., `just fmt`, `just fix`, `cargo test`) unless explicitly requested for a specific task.
|
||||
|
||||
When making individual changes prefer running tests on individual files or projects first if asked, but otherwise rely on `./build-fast.sh` at the end.
|
||||
|
||||
## Command Execution Architecture
|
||||
|
||||
The command execution flow in Codex follows an event-driven pattern:
|
||||
|
||||
1. **Core Layer** (`codex-core/src/codex.rs`):
|
||||
- `on_exec_command_begin()` initiates command execution
|
||||
- Creates `EventMsg::ExecCommandBegin` events with command details
|
||||
|
||||
2. **TUI Layer** (`codex-tui/src/chatwidget.rs`):
|
||||
- `handle_codex_event()` processes execution events
|
||||
- Manages `RunningCommand` state for active commands
|
||||
- Creates `HistoryCell::Exec` for UI rendering
|
||||
|
||||
3. **History Cell** (`codex-tui/src/history_cell.rs`):
|
||||
- `new_active_exec_command()` - Creates cell for running command
|
||||
- `new_completed_exec_command()` - Updates with final output
|
||||
- Handles syntax highlighting via `ParsedCommand`
|
||||
|
||||
This architecture separates concerns between execution logic (core), UI state management (chatwidget), and rendering (history_cell).
|
||||
|
||||
## Ephemeral Context Injection
|
||||
|
||||
Codex automatically injects fresh context at the end of each model request:
|
||||
|
||||
- **Location**: Ephemeral items are appended as the last items in the request via `get_formatted_input()`
|
||||
- **Content**: System status (cwd, git branch, reasoning level), browser info (URL, type), screenshots
|
||||
- **Generation**: `build_turn_ephemeral_items()` creates fresh context for each retry attempt
|
||||
- **Storage**: Stored in `Prompt.ephemeral_items` field, ensuring they're regenerated per-request
|
||||
|
||||
This design ensures the model always receives current environment context without complex refresh logic or state management.
|
||||
|
||||
@@ -59,7 +59,7 @@ impl BrowserInjector {
|
||||
.unwrap_or_else(|_| "about:blank".to_string());
|
||||
|
||||
let system_hint = format!(
|
||||
"A fresh screenshot of the active page ({}) is attached; use browser.* tools to navigate or capture more.",
|
||||
"A fresh screenshot of the active page ({}) is attached; use browser_* tools to navigate or capture more.",
|
||||
current_url
|
||||
);
|
||||
|
||||
|
||||
@@ -507,6 +507,18 @@ impl BrowserManager {
|
||||
self.config.try_read().map(|c| c.enabled).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get a description of the browser connection type
|
||||
pub async fn get_browser_type(&self) -> String {
|
||||
let config = self.config.read().await;
|
||||
if config.connect_ws.is_some() || config.connect_port.is_some() {
|
||||
"CDP-connected to user's Chrome browser".to_string()
|
||||
} else if config.headless {
|
||||
"internal headless Chrome browser".to_string()
|
||||
} else {
|
||||
"internal Chrome browser (headed mode)".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_enabled(&self, enabled: bool) -> Result<()> {
|
||||
let mut config = self.config.write().await;
|
||||
config.enabled = enabled;
|
||||
@@ -898,6 +910,24 @@ impl BrowserManager {
|
||||
page.execute_javascript(code).await
|
||||
}
|
||||
|
||||
/// Scroll the page by the given delta in pixels
|
||||
pub async fn scroll_by(&self, dx: f64, dy: f64) -> Result<()> {
|
||||
let page = self.get_or_create_page().await?;
|
||||
page.scroll_by(dx, dy).await
|
||||
}
|
||||
|
||||
/// Navigate browser history backward one entry
|
||||
pub async fn history_back(&self) -> Result<()> {
|
||||
let page = self.get_or_create_page().await?;
|
||||
page.go_back().await
|
||||
}
|
||||
|
||||
/// Navigate browser history forward one entry
|
||||
pub async fn history_forward(&self) -> Result<()> {
|
||||
let page = self.get_or_create_page().await?;
|
||||
page.go_forward().await
|
||||
}
|
||||
|
||||
/// Set a callback to be called when navigation occurs
|
||||
pub async fn set_navigation_callback<F>(&self, callback: F)
|
||||
where
|
||||
|
||||
@@ -354,9 +354,11 @@ impl Page {
|
||||
|
||||
/// Type text into the currently focused element
|
||||
pub async fn type_text(&self, text: &str) -> Result<()> {
|
||||
debug!("Typing text: {}", text);
|
||||
// Replace em dashes with regular dashes
|
||||
let processed_text = text.replace('—', " - ");
|
||||
debug!("Typing text: {}", processed_text);
|
||||
|
||||
for ch in text.chars() {
|
||||
for ch in processed_text.chars() {
|
||||
let params = DispatchKeyEventParams::builder()
|
||||
.r#type(DispatchKeyEventType::Char)
|
||||
.text(ch.to_string())
|
||||
@@ -544,6 +546,30 @@ impl Page {
|
||||
|
||||
Ok(result_value)
|
||||
}
|
||||
|
||||
/// Scroll the page by the given delta in pixels
|
||||
pub async fn scroll_by(&self, dx: f64, dy: f64) -> Result<()> {
|
||||
debug!("Scrolling by ({}, {})", dx, dy);
|
||||
let js = format!(
|
||||
"(function() {{ window.scrollBy({dx}, {dy}); return {{ x: window.scrollX, y: window.scrollY }}; }})()"
|
||||
);
|
||||
let _ = self.execute_javascript(&js).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Navigate browser history backward one entry
|
||||
pub async fn go_back(&self) -> Result<()> {
|
||||
debug!("History back");
|
||||
let _ = self.execute_javascript("history.back();").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Navigate browser history forward one entry
|
||||
pub async fn go_forward(&self) -> Result<()> {
|
||||
debug!("History forward");
|
||||
let _ = self.execute_javascript("history.forward();").await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
In this environment, you are running as `coder` and your name is Coder. Coder is a fork Codex CLI, an open source project led by OpenAI.
|
||||
|
||||
Coder is a fast, community-driven fork focused on key developer ergonomics: Browser control, multi-agent flows, live theming, and on-the-fly reasoning control - all while staying compatible with upstream.
|
||||
|
||||
# Testing
|
||||
With your additional browser tools you can validate web UIs visually. For code that generates a web interface, test with browser tools after changes and use visual judgment to improve UX and aesthetics.
|
||||
|
||||
# Tools
|
||||
|
||||
## Shell tools (via shell)
|
||||
|
||||
You have access to CLI tools through the shell function. Use it for any command-line work (e.g., git, builds, tests, codegen). apply_patch is one of these CLI helpers and must be invoked via shell to edit files safely and atomically.
|
||||
|
||||
{"command":["git","status"]}
|
||||
{"command":["rg","-n","--glob","**/package.json","^\\s*\\\"(name|scripts)\\\""],"workdir":"./repo"}
|
||||
{"command":["fd","-H","-I","-t","f"],"workdir":"./src","timeout":10000}
|
||||
{"command":["sh","-lc","git log --since='14 days ago' --stat"]}
|
||||
{"command":["apply_patch","*** Begin Patch\n*** Add File: hello.txt\n+Hello, world!\n*** End Patch\n"]}
|
||||
|
||||
Editing files with apply_patch (via shell):
|
||||
|
||||
Use apply_patch for all file modifications; it validates the patch envelope and applies changes predictably.
|
||||
|
||||
## `apply_patch` (via shell)
|
||||
|
||||
apply_patch consumes a simple, file-oriented diff wrapped in a clear envelope:
|
||||
|
||||
### Formal shape
|
||||
|
||||
Patch := Begin { FileOp } End
|
||||
Begin := "*** Begin Patch" NEWLINE
|
||||
End := "*** End Patch" NEWLINE
|
||||
FileOp := AddFile | DeleteFile | UpdateFile
|
||||
AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
|
||||
DeleteFile := "*** Delete File: " path NEWLINE
|
||||
UpdateFile := "*** Update File: " path NEWLINE [ "*** Move to: " newPath NEWLINE ] { Hunk }
|
||||
Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
|
||||
HunkLine := (" " | "-" | "+") text NEWLINE
|
||||
|
||||
|
||||
## Browser tools
|
||||
|
||||
Use the browser tools to open a live page, interact with it, and harvest results. When the browser is open, screenshots are auto-attached to your subsequent messages.
|
||||
|
||||
The browser will either be an internal headless browser, or a CPD connection to the user's active Chrome browser. Your screenshots will be 1024×768 which exactly matches the viewport.
|
||||
|
||||
|
||||
## Agent tools
|
||||
|
||||
Your agents are like having a team of expert peers at your disposal at any time. Use them for non-trivial work in these types of situations;
|
||||
|
||||
### Solve
|
||||
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 {
|
||||
"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"],
|
||||
"model": ["claude","gemini","codex"],
|
||||
"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>"}
|
||||
// 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
|
||||
|
||||
### 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 {
|
||||
"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"],
|
||||
"model": ["claude","gemini","codex"],
|
||||
"output": "Numbered phases + risk matrix + observability plan",
|
||||
"read_only": true
|
||||
}
|
||||
wait_for_agent { "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 {
|
||||
"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"],
|
||||
"model": ["claude","gemini","codex"],
|
||||
"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.
|
||||
@@ -629,9 +629,9 @@ pub fn create_run_agent_tool() -> OpenAiTool {
|
||||
let mut properties = BTreeMap::new();
|
||||
|
||||
properties.insert(
|
||||
"agent".to_string(),
|
||||
"task".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("The agent prompt - what to perform (required)".to_string()),
|
||||
description: Some("The task prompt - what to perform (required)".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -680,11 +680,11 @@ pub fn create_run_agent_tool() -> OpenAiTool {
|
||||
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "run_agent".to_string(),
|
||||
description: "Start a complex AI agent asynchronously. Returns a agent ID immediately to check status and retrieve results.".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 {
|
||||
properties,
|
||||
required: Some(vec!["agent".to_string()]),
|
||||
required: Some(vec!["task".to_string()]),
|
||||
additional_properties: Some(false),
|
||||
},
|
||||
})
|
||||
@@ -861,7 +861,7 @@ pub fn create_list_agents_tool() -> OpenAiTool {
|
||||
// Parameter structs for handlers
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RunAgentParams {
|
||||
pub agent: String,
|
||||
pub task: String,
|
||||
pub model: Option<serde_json::Value>, // Can be string or array
|
||||
pub context: Option<String>,
|
||||
pub output: Option<String>,
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::ModelProviderInfo;
|
||||
use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::client_common::ResponseStream;
|
||||
use crate::debug_logger::DebugLogger;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
use crate::model_family::ModelFamily;
|
||||
@@ -27,6 +28,7 @@ use crate::models::ReasoningItemContent;
|
||||
use crate::models::ResponseItem;
|
||||
use crate::openai_tools::create_tools_json_for_chat_completions_api;
|
||||
use crate::util::backoff;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Implementation for the classic Chat Completions API.
|
||||
pub(crate) async fn stream_chat_completions(
|
||||
@@ -34,6 +36,7 @@ pub(crate) async fn stream_chat_completions(
|
||||
model_family: &ModelFamily,
|
||||
client: &reqwest::Client,
|
||||
provider: &ModelProviderInfo,
|
||||
debug_logger: &Arc<Mutex<DebugLogger>>,
|
||||
) -> Result<ResponseStream> {
|
||||
// Build messages array
|
||||
let mut messages = Vec::<serde_json::Value>::new();
|
||||
@@ -58,10 +61,11 @@ pub(crate) async fn stream_chat_completions(
|
||||
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
|
||||
parts.push(json!({ "type": "text", "text": text }));
|
||||
}
|
||||
ContentItem::InputImage { image_url } => {
|
||||
ContentItem::InputImage { image_url, detail } => {
|
||||
parts.push(json!({
|
||||
"type": "image_url",
|
||||
"image_url": { "url": image_url }
|
||||
"image_url": { "url": image_url },
|
||||
"detail": detail.clone().unwrap_or_else(|| "auto".to_string())
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -142,12 +146,18 @@ pub(crate) async fn stream_chat_completions(
|
||||
"tools": tools_json,
|
||||
});
|
||||
|
||||
let endpoint = provider.get_full_url(&None);
|
||||
debug!(
|
||||
"POST to {}: {}",
|
||||
provider.get_full_url(&None),
|
||||
endpoint,
|
||||
serde_json::to_string_pretty(&payload).unwrap_or_default()
|
||||
);
|
||||
|
||||
// Log the request if debug is enabled
|
||||
if let Ok(logger) = debug_logger.lock() {
|
||||
let _ = logger.log_request(&endpoint, &payload);
|
||||
}
|
||||
|
||||
let mut attempt = 0;
|
||||
let max_retries = provider.request_max_retries();
|
||||
loop {
|
||||
@@ -163,6 +173,14 @@ pub(crate) async fn stream_chat_completions(
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
// Log successful response initiation (streaming)
|
||||
if let Ok(logger) = debug_logger.lock() {
|
||||
let _ = logger.log_response(&endpoint, &serde_json::json!({
|
||||
"status": "success",
|
||||
"type": "stream_initiated",
|
||||
"status_code": resp.status().as_u16()
|
||||
}));
|
||||
}
|
||||
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(1600);
|
||||
let stream = resp.bytes_stream().map_err(CodexErr::Reqwest);
|
||||
tokio::spawn(process_chat_sse(
|
||||
@@ -176,6 +194,10 @@ pub(crate) async fn stream_chat_completions(
|
||||
let status = res.status();
|
||||
if !(status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) {
|
||||
let body = (res.text().await).unwrap_or_default();
|
||||
// Log error response
|
||||
if let Ok(logger) = debug_logger.lock() {
|
||||
let _ = logger.log_error(&endpoint, &format!("Status: {}, Body: {}", status, body));
|
||||
}
|
||||
return Err(CodexErr::UnexpectedStatus(status, body));
|
||||
}
|
||||
|
||||
@@ -196,6 +218,10 @@ pub(crate) async fn stream_chat_completions(
|
||||
}
|
||||
Err(e) => {
|
||||
if attempt > max_retries {
|
||||
// Log network error
|
||||
if let Ok(logger) = debug_logger.lock() {
|
||||
let _ = logger.log_error(&endpoint, &format!("Network error: {}", e));
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
let delay = backoff(attempt);
|
||||
|
||||
@@ -40,6 +40,8 @@ use crate::openai_tools::create_tools_json_for_responses_api;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::util::backoff;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use crate::debug_logger::DebugLogger;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ErrorResponse {
|
||||
@@ -60,6 +62,7 @@ pub struct ModelClient {
|
||||
session_id: Uuid,
|
||||
effort: ReasoningEffortConfig,
|
||||
summary: ReasoningSummaryConfig,
|
||||
debug_logger: Arc<Mutex<DebugLogger>>,
|
||||
}
|
||||
|
||||
impl ModelClient {
|
||||
@@ -70,6 +73,7 @@ impl ModelClient {
|
||||
effort: ReasoningEffortConfig,
|
||||
summary: ReasoningSummaryConfig,
|
||||
session_id: Uuid,
|
||||
debug_logger: Arc<Mutex<DebugLogger>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
@@ -79,9 +83,15 @@ impl ModelClient {
|
||||
session_id,
|
||||
effort,
|
||||
summary,
|
||||
debug_logger,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the reasoning effort configuration
|
||||
pub fn get_reasoning_effort(&self) -> ReasoningEffortConfig {
|
||||
self.effort
|
||||
}
|
||||
|
||||
/// Dispatches to either the Responses or Chat implementation depending on
|
||||
/// the provider config. Public callers always invoke `stream()` – the
|
||||
/// specialised helpers are private to avoid accidental misuse.
|
||||
@@ -95,6 +105,7 @@ impl ModelClient {
|
||||
&self.config.model_family,
|
||||
&self.client,
|
||||
&self.provider,
|
||||
&self.debug_logger,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -164,7 +175,7 @@ impl ModelClient {
|
||||
input: &input_with_instructions,
|
||||
tools: &tools_json,
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: false,
|
||||
parallel_tool_calls: true,
|
||||
reasoning,
|
||||
store,
|
||||
stream: true,
|
||||
@@ -175,12 +186,18 @@ impl ModelClient {
|
||||
let mut attempt = 0;
|
||||
let max_retries = self.provider.request_max_retries();
|
||||
|
||||
let endpoint = self.provider.get_full_url(&auth);
|
||||
trace!(
|
||||
"POST to {}: {}",
|
||||
self.provider.get_full_url(&auth),
|
||||
endpoint,
|
||||
serde_json::to_string(&payload)?
|
||||
);
|
||||
|
||||
// Log the request if debug is enabled
|
||||
if let Ok(logger) = self.debug_logger.lock() {
|
||||
let _ = logger.log_request(&endpoint, &serde_json::to_value(&payload)?);
|
||||
}
|
||||
|
||||
loop {
|
||||
attempt += 1;
|
||||
|
||||
@@ -225,6 +242,18 @@ impl ModelClient {
|
||||
|
||||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
// Log successful response initiation (streaming)
|
||||
if let Ok(logger) = self.debug_logger.lock() {
|
||||
let _ = logger.log_response(&endpoint, &serde_json::json!({
|
||||
"status": "success",
|
||||
"type": "stream_initiated",
|
||||
"status_code": resp.status().as_u16(),
|
||||
"request_id": resp.headers()
|
||||
.get("x-request-id")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
}));
|
||||
}
|
||||
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(1600);
|
||||
|
||||
// spawn task to process SSE
|
||||
@@ -257,6 +286,10 @@ impl ModelClient {
|
||||
if !(status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) {
|
||||
// Surface the error body to callers. Use `unwrap_or_default` per Clippy.
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
// Log error response
|
||||
if let Ok(logger) = self.debug_logger.lock() {
|
||||
let _ = logger.log_error(&endpoint, &format!("Status: {}, Body: {}", status, body));
|
||||
}
|
||||
return Err(CodexErr::UnexpectedStatus(status, body));
|
||||
}
|
||||
|
||||
@@ -291,6 +324,10 @@ impl ModelClient {
|
||||
}
|
||||
Err(e) => {
|
||||
if attempt > max_retries {
|
||||
// Log network error
|
||||
if let Ok(logger) = self.debug_logger.lock() {
|
||||
let _ = logger.log_error(&endpoint, &format!("Network error: {}", e));
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
let delay = backoff(attempt);
|
||||
|
||||
@@ -23,6 +23,9 @@ use tokio::sync::mpsc;
|
||||
/// with this content.
|
||||
const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md");
|
||||
|
||||
/// Additional prompt for Coder. Can not edit Codex instructions.
|
||||
const ADDITIONAL_INSTRUCTIONS: &str = include_str!("../prompt_coder.md");
|
||||
|
||||
/// wraps environment context message in a tag for the model to parse more easily.
|
||||
const ENVIRONMENT_CONTEXT_START: &str = "<environment_context>\n\n";
|
||||
const ENVIRONMENT_CONTEXT_END: &str = "\n\n</environment_context>";
|
||||
@@ -83,6 +86,10 @@ pub struct Prompt {
|
||||
/// external MCP servers.
|
||||
pub tools: Vec<OpenAiTool>,
|
||||
|
||||
/// Ephemeral items to be added at the end of the input
|
||||
/// These are generated fresh for each request
|
||||
pub ephemeral_items: Vec<ResponseItem>,
|
||||
|
||||
/// Optional override for the built-in BASE_INSTRUCTIONS.
|
||||
pub base_instructions_override: Option<String>,
|
||||
}
|
||||
@@ -113,7 +120,12 @@ impl Prompt {
|
||||
}
|
||||
|
||||
pub(crate) fn get_formatted_input(&self) -> Vec<ResponseItem> {
|
||||
let mut input_with_instructions = Vec::with_capacity(self.input.len() + 2);
|
||||
let mut input_with_instructions = Vec::with_capacity(self.input.len() + self.ephemeral_items.len() + 3);
|
||||
input_with_instructions.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText { text: ADDITIONAL_INSTRUCTIONS.to_string() }],
|
||||
});
|
||||
if let Some(ec) = self.get_formatted_environment_context() {
|
||||
input_with_instructions.push(ResponseItem::Message {
|
||||
id: None,
|
||||
@@ -129,6 +141,8 @@ impl Prompt {
|
||||
});
|
||||
}
|
||||
input_with_instructions.extend(self.input.clone());
|
||||
// Add ephemeral items at the end so they're fresh for each request
|
||||
input_with_instructions.extend(self.ephemeral_items.clone());
|
||||
input_with_instructions
|
||||
}
|
||||
}
|
||||
|
||||
+304
-103
@@ -65,6 +65,7 @@ async fn build_turn_ephemeral_items(sess: &Session) -> Vec<ResponseItem> {
|
||||
// Collect environment context
|
||||
let cwd = sess.cwd.to_string_lossy().to_string();
|
||||
let branch = get_git_branch(&sess.cwd).unwrap_or_else(|| "unknown".to_string());
|
||||
let reasoning_effort = sess.client.get_reasoning_effort();
|
||||
|
||||
// Prepare browser context + optional screenshot
|
||||
let mut browser_text = None::<String>;
|
||||
@@ -72,45 +73,70 @@ async fn build_turn_ephemeral_items(sess: &Session) -> Vec<ResponseItem> {
|
||||
|
||||
if let Some(browser_manager) = codex_browser::global::get_browser_manager().await {
|
||||
if browser_manager.is_enabled().await {
|
||||
// Capture screenshot and discover URL
|
||||
if let Some((screenshot_path, url)) = capture_browser_screenshot(sess).await {
|
||||
if let Ok(bytes) = std::fs::read(&screenshot_path) {
|
||||
let mime = mime_guess::from_path(&screenshot_path)
|
||||
.first()
|
||||
.map(|m| m.to_string())
|
||||
.unwrap_or_else(|| "image/png".to_string());
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
screenshot_content = Some(ContentItem::InputImage {
|
||||
image_url: format!("data:{mime};base64,{encoded}"),
|
||||
});
|
||||
// Get current URL and browser info
|
||||
let url = browser_manager
|
||||
.get_current_url()
|
||||
.await
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// Try to get a tab title if available
|
||||
let title = match browser_manager.get_or_create_page().await {
|
||||
Ok(page) => page.get_title().await,
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
// Get browser type description
|
||||
let browser_type = browser_manager.get_browser_type().await;
|
||||
|
||||
// Try to capture screenshot
|
||||
let screenshot_status = match capture_browser_screenshot(sess).await {
|
||||
Ok((screenshot_path, _url)) => {
|
||||
if let Ok(bytes) = std::fs::read(&screenshot_path) {
|
||||
let mime = mime_guess::from_path(&screenshot_path)
|
||||
.first()
|
||||
.map(|m| m.to_string())
|
||||
.unwrap_or_else(|| "image/png".to_string());
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
screenshot_content = Some(ContentItem::InputImage {
|
||||
image_url: format!("data:{mime};base64,{encoded}"),
|
||||
detail: Some("high".to_string()),
|
||||
});
|
||||
""
|
||||
} else {
|
||||
" [Screenshot file read failed]"
|
||||
}
|
||||
}
|
||||
Err(err_msg) => {
|
||||
// Include error message so LLM knows screenshot failed
|
||||
format!(" [Screenshot unavailable: {}]", err_msg).leak()
|
||||
}
|
||||
};
|
||||
|
||||
// Try to get a tab title if available
|
||||
let title = match browser_manager.get_or_create_page().await {
|
||||
Ok(page) => page.get_title().await,
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
let status_line = if let Some(t) = title {
|
||||
format!(
|
||||
"Browser is open at {} — {}. You can interact with it using browser.* tools.",
|
||||
url, t
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Browser is open at {}. You can interact with it using browser.* tools.",
|
||||
url
|
||||
)
|
||||
};
|
||||
browser_text = Some(status_line);
|
||||
}
|
||||
let status_line = if let Some(t) = title {
|
||||
format!(
|
||||
"Browser is open at {} — {} ({}){}. You can interact with it using browser_* tools.",
|
||||
url, t, browser_type, screenshot_status
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Browser is open at {} ({}){}. You can interact with it using browser_* tools.",
|
||||
url, browser_type, screenshot_status
|
||||
)
|
||||
};
|
||||
browser_text = Some(status_line);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a single ephemeral message conveying env + browser context + screenshot
|
||||
let mut content: Vec<ContentItem> = Vec::new();
|
||||
content.push(ContentItem::InputText {
|
||||
text: format!("[EPHEMERAL:turn_context] cwd: {}, branch: {}", cwd, branch),
|
||||
text: format!(r#"== System Status ==
|
||||
This ephemeral message is added to the current turn to provide context about your environment. You only see the current version of it.
|
||||
Use this message only as context and work from the previous user or assistant message.
|
||||
|
||||
cwd: {}
|
||||
branch: {}
|
||||
reasoning: {:?}
|
||||
"#, cwd, branch, reasoning_effort),
|
||||
});
|
||||
if let Some(text) = browser_text {
|
||||
content.push(ContentItem::InputText { text });
|
||||
@@ -122,7 +148,6 @@ async fn build_turn_ephemeral_items(sess: &Session) -> Vec<ResponseItem> {
|
||||
if !content.is_empty() {
|
||||
jar.items.push(ResponseItem::Message {
|
||||
id: None,
|
||||
// Use "user" for broad compatibility across providers.
|
||||
role: "user".to_string(),
|
||||
content,
|
||||
});
|
||||
@@ -1047,6 +1072,18 @@ async fn submission_loop(
|
||||
}
|
||||
};
|
||||
|
||||
// Create debug logger based on config
|
||||
let debug_logger = match crate::debug_logger::DebugLogger::new(config.debug) {
|
||||
Ok(logger) => std::sync::Arc::new(std::sync::Mutex::new(logger)),
|
||||
Err(e) => {
|
||||
warn!("Failed to create debug logger: {}", e);
|
||||
// Create a disabled logger as fallback
|
||||
std::sync::Arc::new(std::sync::Mutex::new(
|
||||
crate::debug_logger::DebugLogger::new(false).unwrap()
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let client = ModelClient::new(
|
||||
config.clone(),
|
||||
auth.clone(),
|
||||
@@ -1054,6 +1091,7 @@ async fn submission_loop(
|
||||
model_reasoning_effort,
|
||||
model_reasoning_summary,
|
||||
session_id,
|
||||
debug_logger,
|
||||
);
|
||||
|
||||
// abort any current running session and clone its state
|
||||
@@ -1364,7 +1402,7 @@ async fn run_agent(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
|
||||
// Note that pending_input would be something like a message the user
|
||||
// submitted through the UI while the model was running. Though the UI
|
||||
// may support this, the model might not.
|
||||
let mut pending_input = sess
|
||||
let pending_input = sess
|
||||
.get_pending_input()
|
||||
.into_iter()
|
||||
.map(ResponseItem::from)
|
||||
@@ -1379,10 +1417,6 @@ async fn run_agent(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
|
||||
sess.record_conversation_items(&pending_input).await;
|
||||
}
|
||||
|
||||
// Append ephemeral per-turn context (env + browser status + optional screenshot)
|
||||
let ephemeral_items = build_turn_ephemeral_items(&sess).await;
|
||||
pending_input.extend(ephemeral_items);
|
||||
|
||||
// Construct the input that we will send to the model. When using the
|
||||
// Chat completions API (or ZDR clients), the model needs the full
|
||||
// conversation history on each turn. The rollout file, however, should
|
||||
@@ -1491,8 +1525,26 @@ async fn run_agent(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
|
||||
|
||||
// Only attempt to take the lock if there is something to record.
|
||||
if !items_to_record_in_conversation_history.is_empty() {
|
||||
sess.record_conversation_items(&items_to_record_in_conversation_history)
|
||||
.await;
|
||||
// To ensure streamed assistant deltas (already appended into
|
||||
// history during OutputTextDelta) merge cleanly with the final
|
||||
// assistant message, record assistant messages first. This
|
||||
// preserves merge-adjacency even if the provider delivered
|
||||
// other output items (e.g., reasoning) before the final
|
||||
// assistant message.
|
||||
let mut assistants = Vec::new();
|
||||
let mut others = Vec::new();
|
||||
for item in items_to_record_in_conversation_history.drain(..) {
|
||||
match &item {
|
||||
ResponseItem::Message { role, .. } if role == "assistant" => {
|
||||
assistants.push(item)
|
||||
}
|
||||
_ => others.push(item),
|
||||
}
|
||||
}
|
||||
// Maintain relative order within each group while ensuring
|
||||
// assistants are recorded first for proper merge behaviour.
|
||||
assistants.extend(others.into_iter());
|
||||
sess.record_conversation_items(&assistants).await;
|
||||
}
|
||||
|
||||
// If there are responses, add them to pending input for the next iteration
|
||||
@@ -1550,21 +1602,25 @@ async fn run_turn(
|
||||
Some(sess.mcp_connection_manager.list_all_tools()),
|
||||
);
|
||||
|
||||
let prompt = Prompt {
|
||||
input,
|
||||
user_instructions: sess.user_instructions.clone(),
|
||||
store: !sess.disable_response_storage,
|
||||
tools,
|
||||
base_instructions_override: sess.base_instructions.clone(),
|
||||
environment_context: Some(EnvironmentContext {
|
||||
cwd: sess.cwd.clone(),
|
||||
approval_policy: sess.approval_policy,
|
||||
sandbox_policy: sess.sandbox_policy.clone(),
|
||||
}),
|
||||
};
|
||||
|
||||
let mut retries = 0;
|
||||
loop {
|
||||
// Build ephemeral items fresh for each attempt
|
||||
let ephemeral_items = build_turn_ephemeral_items(sess).await;
|
||||
let prompt = Prompt {
|
||||
input: input.clone(),
|
||||
user_instructions: sess.user_instructions.clone(),
|
||||
store: !sess.disable_response_storage,
|
||||
tools: tools.clone(),
|
||||
base_instructions_override: sess.base_instructions.clone(),
|
||||
environment_context: Some(EnvironmentContext {
|
||||
cwd: sess.cwd.clone(),
|
||||
approval_policy: sess.approval_policy,
|
||||
sandbox_policy: sess.sandbox_policy.clone(),
|
||||
}),
|
||||
ephemeral_items,
|
||||
};
|
||||
|
||||
match try_run_turn(sess, turn_diff_tracker, &sub_id, &prompt).await {
|
||||
Ok(output) => return Ok(output),
|
||||
Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted),
|
||||
@@ -1785,19 +1841,22 @@ async fn run_compact_agent(
|
||||
let turn_input: Vec<ResponseItem> =
|
||||
sess.turn_input_with_history(vec![initial_input_for_turn.clone().into()]);
|
||||
|
||||
let prompt = Prompt {
|
||||
input: turn_input,
|
||||
user_instructions: None,
|
||||
store: !sess.disable_response_storage,
|
||||
environment_context: None,
|
||||
tools: Vec::new(),
|
||||
base_instructions_override: Some(compact_instructions.clone()),
|
||||
};
|
||||
|
||||
let max_retries = sess.client.get_provider().stream_max_retries();
|
||||
let mut retries = 0;
|
||||
|
||||
loop {
|
||||
// Build ephemeral items fresh for each attempt
|
||||
let ephemeral_items = build_turn_ephemeral_items(&sess).await;
|
||||
let prompt = Prompt {
|
||||
input: turn_input.clone(),
|
||||
user_instructions: None,
|
||||
store: !sess.disable_response_storage,
|
||||
environment_context: None,
|
||||
tools: Vec::new(),
|
||||
base_instructions_override: Some(compact_instructions.clone()),
|
||||
ephemeral_items,
|
||||
};
|
||||
|
||||
let attempt_result = drain_to_completed(&sess, &sub_id, &prompt).await;
|
||||
|
||||
match attempt_result {
|
||||
@@ -2006,6 +2065,8 @@ async fn handle_function_call(
|
||||
"browser_type" => handle_browser_type(sess, arguments, sub_id, call_id).await,
|
||||
"browser_key" => handle_browser_key(sess, arguments, sub_id, call_id).await,
|
||||
"browser_javascript" => handle_browser_javascript(sess, arguments, sub_id, call_id).await,
|
||||
"browser_scroll" => handle_browser_scroll(sess, arguments, sub_id, call_id).await,
|
||||
"browser_history" => handle_browser_history(sess, arguments, sub_id, call_id).await,
|
||||
_ => {
|
||||
match sess.mcp_connection_manager.parse_tool_name(&name) {
|
||||
Some((server, tool_name)) => {
|
||||
@@ -2130,7 +2191,7 @@ async fn handle_run_agent(
|
||||
let agent_id = manager
|
||||
.create_agent_with_config(
|
||||
model,
|
||||
params.agent.clone(),
|
||||
params.task.clone(),
|
||||
params.context.clone(),
|
||||
params.output.clone(),
|
||||
params.files.clone().unwrap_or_default(),
|
||||
@@ -2145,7 +2206,7 @@ async fn handle_run_agent(
|
||||
let agent_id = manager
|
||||
.create_agent(
|
||||
model,
|
||||
params.agent.clone(),
|
||||
params.task.clone(),
|
||||
params.context.clone(),
|
||||
params.output.clone(),
|
||||
params.files.clone().unwrap_or_default(),
|
||||
@@ -3033,41 +3094,42 @@ async fn drain_to_completed(sess: &Session, sub_id: &str, prompt: &Prompt) -> Co
|
||||
}
|
||||
|
||||
/// Capture a screenshot from the browser and store it for the next model request
|
||||
async fn capture_browser_screenshot(_sess: &Session) -> Option<(PathBuf, String)> {
|
||||
let browser_manager = codex_browser::global::get_browser_manager().await;
|
||||
if let Some(browser_manager) = browser_manager {
|
||||
if browser_manager.is_enabled().await {
|
||||
// Get current URL first
|
||||
let url = browser_manager
|
||||
.get_current_url()
|
||||
.await
|
||||
.unwrap_or_else(|| "Browser".to_string());
|
||||
tracing::debug!("Attempting to capture screenshot at URL: {}", url);
|
||||
async fn capture_browser_screenshot(_sess: &Session) -> Result<(PathBuf, String), String> {
|
||||
let browser_manager = codex_browser::global::get_browser_manager().await
|
||||
.ok_or_else(|| "No browser manager available".to_string())?;
|
||||
|
||||
if !browser_manager.is_enabled().await {
|
||||
return Err("Browser manager is not enabled".to_string());
|
||||
}
|
||||
|
||||
match browser_manager.capture_screenshot().await {
|
||||
Ok(screenshots) => {
|
||||
if let Some(first_screenshot) = screenshots.first() {
|
||||
tracing::info!(
|
||||
"Captured browser screenshot: {} at URL: {}",
|
||||
first_screenshot.display(),
|
||||
url
|
||||
);
|
||||
return Some((first_screenshot.clone(), url));
|
||||
} else {
|
||||
tracing::warn!("Screenshot capture returned empty results at URL: {}", url);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to capture browser screenshot at URL {}: {}", url, e);
|
||||
}
|
||||
// Get current URL first
|
||||
let url = browser_manager
|
||||
.get_current_url()
|
||||
.await
|
||||
.unwrap_or_else(|| "Browser".to_string());
|
||||
tracing::debug!("Attempting to capture screenshot at URL: {}", url);
|
||||
|
||||
match browser_manager.capture_screenshot().await {
|
||||
Ok(screenshots) => {
|
||||
if let Some(first_screenshot) = screenshots.first() {
|
||||
tracing::info!(
|
||||
"Captured browser screenshot: {} at URL: {}",
|
||||
first_screenshot.display(),
|
||||
url
|
||||
);
|
||||
Ok((first_screenshot.clone(), url))
|
||||
} else {
|
||||
let msg = format!("Screenshot capture returned empty results at URL: {}", url);
|
||||
tracing::warn!("{}", msg);
|
||||
Err(msg)
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("Browser manager is not enabled, skipping screenshot capture");
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("Failed to capture screenshot at {}: {}", url, e);
|
||||
tracing::warn!("{}", msg);
|
||||
Err(msg)
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("No browser manager available for screenshot capture");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Send agent status update event to the TUI
|
||||
@@ -3167,6 +3229,7 @@ fn consume_pending_screenshots(sess: &Session) -> Vec<ResponseInputItem> {
|
||||
ContentItem::InputText { text: metadata },
|
||||
ContentItem::InputImage {
|
||||
image_url: format!("data:{mime};base64,{encoded}"),
|
||||
detail: Some("high".to_string()),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -3222,15 +3285,18 @@ async fn handle_browser_open(
|
||||
Ok(_) => {
|
||||
tracing::info!("Browser navigation to {} completed successfully", url);
|
||||
// Capture screenshot after navigation
|
||||
if let Some((screenshot_path, updated_url)) =
|
||||
capture_browser_screenshot(sess).await
|
||||
{
|
||||
tracing::info!(
|
||||
"Screenshot captured after navigation: {} at URL: {}",
|
||||
screenshot_path.display(),
|
||||
updated_url
|
||||
);
|
||||
add_pending_screenshot(sess, screenshot_path, updated_url);
|
||||
match capture_browser_screenshot(sess).await {
|
||||
Ok((screenshot_path, updated_url)) => {
|
||||
tracing::info!(
|
||||
"Screenshot captured after navigation: {} at URL: {}",
|
||||
screenshot_path.display(),
|
||||
updated_url
|
||||
);
|
||||
add_pending_screenshot(sess, screenshot_path, updated_url);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Screenshot capture failed after navigation: {}", e);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Failed to capture screenshot after navigation to {}",
|
||||
@@ -3642,3 +3708,138 @@ async fn handle_browser_javascript(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_browser_scroll(
|
||||
sess: &Session,
|
||||
arguments: String,
|
||||
_sub_id: String,
|
||||
call_id: String,
|
||||
) -> ResponseInputItem {
|
||||
let browser_manager = get_browser_manager_for_session(sess).await;
|
||||
if let Some(browser_manager) = browser_manager {
|
||||
let args: Result<Value, _> = serde_json::from_str(&arguments);
|
||||
match args {
|
||||
Ok(json) => {
|
||||
let dx = json.get("dx").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let dy = json.get("dy").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
|
||||
match browser_manager.scroll_by(dx, dy).await {
|
||||
Ok(_) => {
|
||||
if let Some((screenshot_path, url)) = capture_browser_screenshot(sess).await
|
||||
{
|
||||
add_pending_screenshot(sess, screenshot_path, url);
|
||||
}
|
||||
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
content: format!("Scrolled by ({}, {})", dx, dy),
|
||||
success: Some(true),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
content: format!("Failed to scroll: {}", e),
|
||||
success: Some(false),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
content: format!("Failed to parse browser_scroll arguments: {}", e),
|
||||
success: Some(false),
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
content: "Browser is not initialized. Use browser_open to start the browser.".to_string(),
|
||||
success: Some(false),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_browser_history(
|
||||
sess: &Session,
|
||||
arguments: String,
|
||||
_sub_id: String,
|
||||
call_id: String,
|
||||
) -> ResponseInputItem {
|
||||
let browser_manager = get_browser_manager_for_session(sess).await;
|
||||
if let Some(browser_manager) = browser_manager {
|
||||
let args: Result<Value, _> = serde_json::from_str(&arguments);
|
||||
match args {
|
||||
Ok(json) => {
|
||||
let direction = json
|
||||
.get("direction")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if direction != "back" && direction != "forward" {
|
||||
return ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
content: format!(
|
||||
"Unsupported direction: {} (expected 'back' or 'forward')",
|
||||
direction
|
||||
),
|
||||
success: Some(false),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let action_res = if direction == "back" {
|
||||
browser_manager.history_back().await
|
||||
} else {
|
||||
browser_manager.history_forward().await
|
||||
};
|
||||
|
||||
match action_res {
|
||||
Ok(_) => {
|
||||
if let Some((screenshot_path, url)) = capture_browser_screenshot(sess).await
|
||||
{
|
||||
add_pending_screenshot(sess, screenshot_path, url);
|
||||
}
|
||||
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
content: format!("History {} triggered", direction),
|
||||
success: Some(true),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
content: format!("Failed to navigate history: {}", e),
|
||||
success: Some(false),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
content: format!("Failed to parse browser_history arguments: {}", e),
|
||||
success: Some(false),
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
content: "Browser is not initialized. Use browser_open to start the browser.".to_string(),
|
||||
success: Some(false),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,9 @@ pub struct Config {
|
||||
|
||||
/// The value for the `originator` header included with Responses API requests.
|
||||
pub internal_originator: Option<String>,
|
||||
|
||||
/// Enable debug logging of LLM requests and responses
|
||||
pub debug: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -494,6 +497,7 @@ pub struct ConfigOverrides {
|
||||
pub include_plan_tool: Option<bool>,
|
||||
pub disable_response_storage: Option<bool>,
|
||||
pub show_raw_agent_reasoning: Option<bool>,
|
||||
pub debug: Option<bool>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -519,6 +523,7 @@ impl Config {
|
||||
include_plan_tool,
|
||||
disable_response_storage,
|
||||
show_raw_agent_reasoning,
|
||||
debug,
|
||||
} = overrides;
|
||||
|
||||
let config_profile = match config_profile_key.as_ref().or(cfg.profile.as_ref()) {
|
||||
@@ -673,6 +678,7 @@ impl Config {
|
||||
experimental_resume,
|
||||
include_plan_tool: include_plan_tool.unwrap_or(false),
|
||||
internal_originator: cfg.internal_originator,
|
||||
debug: debug.unwrap_or(false),
|
||||
};
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -28,24 +28,45 @@ impl ConversationHistory {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Merge adjacent assistant messages into a single history entry.
|
||||
// This prevents duplicates when a partial assistant message was
|
||||
// streamed into history earlier in the turn and the final full
|
||||
// message is recorded at turn end.
|
||||
match (&*item, self.items.last_mut()) {
|
||||
(
|
||||
ResponseItem::Message {
|
||||
role: new_role,
|
||||
content: new_content,
|
||||
..
|
||||
},
|
||||
Some(ResponseItem::Message {
|
||||
role: last_role,
|
||||
content: last_content,
|
||||
..
|
||||
}),
|
||||
) if new_role == "assistant" && last_role == "assistant" => {
|
||||
append_text_content(last_content, new_content);
|
||||
// De-duplicate assistant messages more robustly by leveraging IDs
|
||||
// when available and falling back to adjacency/prefix merging.
|
||||
match &*item {
|
||||
ResponseItem::Message {
|
||||
id: new_id,
|
||||
role: new_role,
|
||||
content: new_content,
|
||||
} if new_role == "assistant" => {
|
||||
// 1) If this assistant message has an ID, try to find an existing
|
||||
// assistant entry with the same ID anywhere in history and
|
||||
// replace its text with the new content.
|
||||
if let Some(id) = new_id.as_ref() {
|
||||
if let Some(idx) = find_assistant_index_by_id(&self.items, id) {
|
||||
if let ResponseItem::Message { id: existing_id, content, .. } = &mut self.items[idx] {
|
||||
// Ensure the existing entry carries the id
|
||||
if existing_id.is_none() {
|
||||
*existing_id = Some(id.clone());
|
||||
}
|
||||
replace_or_append_text(content, new_content);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Otherwise, attempt to merge with the immediately previous
|
||||
// assistant message (streamed deltas case).
|
||||
if let Some(ResponseItem::Message { id: last_id, role: last_role, content: last_content }) = self.items.last_mut() {
|
||||
if last_role == "assistant" {
|
||||
// If new item has an ID, propagate it to the existing entry
|
||||
if last_id.is_none() {
|
||||
if let Some(id) = new_id.clone() { *last_id = Some(id); }
|
||||
}
|
||||
replace_or_append_text(last_content, new_content);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) No merge opportunity – push as a new assistant message.
|
||||
self.items.push(item.clone());
|
||||
}
|
||||
_ => {
|
||||
self.items.push(item.clone());
|
||||
@@ -144,6 +165,48 @@ fn append_text_delta(content: &mut Vec<crate::models::ContentItem>, delta: &str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Concatenate all OutputText segments into a single string.
|
||||
fn collect_text_content(content: &Vec<crate::models::ContentItem>) -> String {
|
||||
let mut out = String::new();
|
||||
for c in content {
|
||||
if let crate::models::ContentItem::OutputText { text } = c {
|
||||
out.push_str(text);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace the text content with a single OutputText item containing `text`.
|
||||
fn replace_text_content(content: &mut Vec<crate::models::ContentItem>, text: &str) {
|
||||
content.clear();
|
||||
content.push(crate::models::ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Merge strategy: if the new text starts with the old, replace; otherwise append deltas.
|
||||
fn replace_or_append_text(existing: &mut Vec<crate::models::ContentItem>, new_content: &Vec<crate::models::ContentItem>) {
|
||||
let prev = collect_text_content(existing);
|
||||
let new_full = collect_text_content(new_content);
|
||||
if !prev.is_empty() && new_full.starts_with(&prev) {
|
||||
replace_text_content(existing, &new_full);
|
||||
} else {
|
||||
append_text_content(existing, new_content);
|
||||
}
|
||||
}
|
||||
|
||||
fn find_assistant_index_by_id(items: &Vec<ResponseItem>, id: &str) -> Option<usize> {
|
||||
// Search from the end (most recent first) for better performance in practice.
|
||||
for (idx, it) in items.iter().enumerate().rev() {
|
||||
if let ResponseItem::Message { id: Some(existing), role, .. } = it {
|
||||
if role == "assistant" && existing == id {
|
||||
return Some(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use chrono::Local;
|
||||
use serde_json::Value;
|
||||
|
||||
pub struct DebugLogger {
|
||||
enabled: bool,
|
||||
log_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl DebugLogger {
|
||||
pub fn new(enabled: bool) -> Result<Self, std::io::Error> {
|
||||
if !enabled {
|
||||
return Ok(Self {
|
||||
enabled: false,
|
||||
log_dir: PathBuf::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let log_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".codex")
|
||||
.join("debug_logs");
|
||||
|
||||
fs::create_dir_all(&log_dir)?;
|
||||
|
||||
Ok(Self {
|
||||
enabled,
|
||||
log_dir,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn log_request(&self, endpoint: &str, payload: &Value) -> Result<(), std::io::Error> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let timestamp = Local::now();
|
||||
let filename = format!(
|
||||
"{}_request_{}.json",
|
||||
timestamp.format("%Y%m%d_%H%M%S%.3f"),
|
||||
sanitize_endpoint(endpoint)
|
||||
);
|
||||
|
||||
let file_path = self.log_dir.join(filename);
|
||||
|
||||
let log_entry = serde_json::json!({
|
||||
"timestamp": timestamp.to_rfc3339(),
|
||||
"type": "request",
|
||||
"endpoint": endpoint,
|
||||
"payload": payload
|
||||
});
|
||||
|
||||
let formatted = serde_json::to_string_pretty(&log_entry)?;
|
||||
fs::write(file_path, formatted)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn log_response(&self, endpoint: &str, response: &Value) -> Result<(), std::io::Error> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let timestamp = Local::now();
|
||||
let filename = format!(
|
||||
"{}_response_{}.json",
|
||||
timestamp.format("%Y%m%d_%H%M%S%.3f"),
|
||||
sanitize_endpoint(endpoint)
|
||||
);
|
||||
|
||||
let file_path = self.log_dir.join(filename);
|
||||
|
||||
let log_entry = serde_json::json!({
|
||||
"timestamp": timestamp.to_rfc3339(),
|
||||
"type": "response",
|
||||
"endpoint": endpoint,
|
||||
"response": response
|
||||
});
|
||||
|
||||
let formatted = serde_json::to_string_pretty(&log_entry)?;
|
||||
fs::write(file_path, formatted)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn log_stream_chunk(&self, endpoint: &str, chunk: &str) -> Result<(), std::io::Error> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let timestamp = Local::now();
|
||||
let filename = format!(
|
||||
"{}_stream_{}.txt",
|
||||
timestamp.format("%Y%m%d_%H%M%S%.3f"),
|
||||
sanitize_endpoint(endpoint)
|
||||
);
|
||||
|
||||
let file_path = self.log_dir.join(filename);
|
||||
|
||||
let log_entry = format!(
|
||||
"=== Stream Chunk at {} ===\nEndpoint: {}\n\n{}\n",
|
||||
timestamp.to_rfc3339(),
|
||||
endpoint,
|
||||
chunk
|
||||
);
|
||||
|
||||
fs::write(file_path, log_entry)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn log_error(&self, endpoint: &str, error: &str) -> Result<(), std::io::Error> {
|
||||
if !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let timestamp = Local::now();
|
||||
let filename = format!(
|
||||
"{}_error_{}.txt",
|
||||
timestamp.format("%Y%m%d_%H%M%S%.3f"),
|
||||
sanitize_endpoint(endpoint)
|
||||
);
|
||||
|
||||
let file_path = self.log_dir.join(filename);
|
||||
|
||||
let log_entry = format!(
|
||||
"=== Error at {} ===\nEndpoint: {}\n\n{}\n",
|
||||
timestamp.to_rfc3339(),
|
||||
endpoint,
|
||||
error
|
||||
);
|
||||
|
||||
fs::write(file_path, log_entry)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_endpoint(endpoint: &str) -> String {
|
||||
endpoint
|
||||
.replace('/', "_")
|
||||
.replace('\\', "_")
|
||||
.replace(':', "_")
|
||||
.replace(' ', "_")
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
|
||||
.collect()
|
||||
}
|
||||
@@ -12,6 +12,7 @@ mod chat_completions;
|
||||
mod client;
|
||||
mod client_common;
|
||||
pub mod codex;
|
||||
pub mod debug_logger;
|
||||
pub mod slash_commands;
|
||||
pub use codex::Codex;
|
||||
pub use codex::CodexSpawnOk;
|
||||
|
||||
@@ -30,7 +30,7 @@ pub enum ResponseInputItem {
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentItem {
|
||||
InputText { text: String },
|
||||
InputImage { image_url: String },
|
||||
InputImage { image_url: String, detail: Option<String> },
|
||||
OutputText { text: String },
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ impl From<Vec<InputItem>> for ResponseInputItem {
|
||||
content_items.push(ContentItem::InputText { text });
|
||||
}
|
||||
InputItem::Image { image_url } => {
|
||||
content_items.push(ContentItem::InputImage { image_url });
|
||||
content_items.push(ContentItem::InputImage { image_url, detail: None });
|
||||
}
|
||||
InputItem::LocalImage { path } => match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
@@ -165,6 +165,7 @@ impl From<Vec<InputItem>> for ResponseInputItem {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
content_items.push(ContentItem::InputImage {
|
||||
image_url: format!("data:{mime};base64,{encoded}"),
|
||||
detail: None,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -199,6 +200,7 @@ impl From<Vec<InputItem>> for ResponseInputItem {
|
||||
tracing::info!("Created ephemeral image data URL with mime: {}", mime);
|
||||
content_items.push(ContentItem::InputImage {
|
||||
image_url: format!("data:{mime};base64,{encoded}"),
|
||||
detail: Some("high".to_string()),
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
|
||||
@@ -469,6 +469,8 @@ pub(crate) fn get_openai_tools(
|
||||
tools.push(create_browser_type_tool());
|
||||
tools.push(create_browser_key_tool());
|
||||
tools.push(create_browser_javascript_tool());
|
||||
tools.push(create_browser_scroll_tool());
|
||||
tools.push(create_browser_history_tool());
|
||||
|
||||
// Add agent management tools for calling external LLMs asynchronously
|
||||
tools.push(create_run_agent_tool());
|
||||
@@ -996,3 +998,51 @@ fn create_browser_javascript_tool() -> OpenAiTool {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_browser_scroll_tool() -> OpenAiTool {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"dx".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("Horizontal scroll delta in pixels (positive = right)".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"dy".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("Vertical scroll delta in pixels (positive = down)".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "browser_scroll".to_string(),
|
||||
description: "Scrolls the page by the specified pixel deltas.".to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec![]),
|
||||
additional_properties: Some(false),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_browser_history_tool() -> OpenAiTool {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"direction".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("History direction: 'back' or 'forward'".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "browser_history".to_string(),
|
||||
description: "Navigates browser history backward or forward.".to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["direction".to_string()]),
|
||||
additional_properties: Some(false),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ pub struct Cli {
|
||||
/// Tell the agent to use the specified directory as its working root.
|
||||
#[clap(long = "cd", short = 'C', value_name = "DIR")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
|
||||
/// Enable debug logging of all LLM requests and responses to files.
|
||||
#[clap(long = "debug", short = 'd', default_value_t = false)]
|
||||
pub debug: bool,
|
||||
|
||||
/// Allow running Codex outside a Git repository.
|
||||
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
||||
|
||||
@@ -42,6 +42,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
full_auto,
|
||||
dangerously_bypass_approvals_and_sandbox,
|
||||
cwd,
|
||||
debug,
|
||||
skip_git_repo_check,
|
||||
color,
|
||||
last_message_file,
|
||||
@@ -149,6 +150,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
include_plan_tool: None,
|
||||
disable_response_storage: oss.then_some(true),
|
||||
show_raw_agent_reasoning: oss.then_some(true),
|
||||
debug: Some(debug),
|
||||
};
|
||||
// Parse `-c` overrides.
|
||||
let cli_kv_overrides = match config_overrides.parse_overrides() {
|
||||
|
||||
@@ -162,6 +162,7 @@ impl CodexToolCallParam {
|
||||
include_plan_tool,
|
||||
disable_response_storage: None,
|
||||
show_raw_agent_reasoning: None,
|
||||
debug: None,
|
||||
};
|
||||
|
||||
let cli_overrides = cli_overrides
|
||||
|
||||
@@ -61,6 +61,7 @@ pub(crate) async fn handle_create_conversation(
|
||||
include_plan_tool: None,
|
||||
disable_response_storage: None,
|
||||
show_raw_agent_reasoning: None,
|
||||
debug: None,
|
||||
};
|
||||
|
||||
let cfg: CodexConfig = match CodexConfig::load_with_cli_overrides(cli_overrides, overrides) {
|
||||
|
||||
@@ -58,6 +58,9 @@ pub(crate) struct App<'a> {
|
||||
pending_redraw: Arc<AtomicBool>,
|
||||
|
||||
enhanced_keys_supported: bool,
|
||||
|
||||
/// Debug flag for logging LLM requests/responses
|
||||
_debug: bool,
|
||||
}
|
||||
|
||||
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
|
||||
@@ -76,6 +79,7 @@ impl App<'_> {
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<std::path::PathBuf>,
|
||||
show_trust_screen: bool,
|
||||
debug: bool,
|
||||
) -> Self {
|
||||
let (app_event_tx, app_event_rx) = channel();
|
||||
let app_event_tx = AppEventSender::new(app_event_tx);
|
||||
@@ -169,6 +173,7 @@ impl App<'_> {
|
||||
file_search,
|
||||
pending_redraw,
|
||||
enhanced_keys_supported,
|
||||
_debug: debug,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+118
-25
@@ -89,6 +89,10 @@ pub(crate) struct ChatWidget<'a> {
|
||||
answer_buffer: String,
|
||||
// Cache of the last finalized assistant message to suppress immediate duplicates
|
||||
last_assistant_message: Option<String>,
|
||||
// Track the ID of the current streaming message to prevent duplicates
|
||||
current_stream_message_id: Option<String>,
|
||||
// Track the ID of the current streaming reasoning to prevent duplicates
|
||||
current_stream_reasoning_id: Option<String>,
|
||||
running_commands: HashMap<String, RunningCommand>,
|
||||
live_builder: RowBuilder,
|
||||
current_stream: Option<StreamKind>,
|
||||
@@ -357,8 +361,7 @@ impl ChatWidget<'_> {
|
||||
StreamKind::Reasoning => {
|
||||
// Always add space above reasoning (unless history is completely empty)
|
||||
if !self.history_cells.is_empty() {
|
||||
self.history_cells
|
||||
.push(HistoryCell::new_text_line(RLine::from("")));
|
||||
self.add_to_history(HistoryCell::new_text_line(RLine::from("")));
|
||||
}
|
||||
self.stream_header_emitted = true;
|
||||
self.request_redraw();
|
||||
@@ -386,8 +389,7 @@ impl ChatWidget<'_> {
|
||||
};
|
||||
|
||||
if needs_space {
|
||||
self.history_cells
|
||||
.push(HistoryCell::new_text_line(RLine::from("")));
|
||||
self.add_to_history(HistoryCell::new_text_line(RLine::from("")));
|
||||
}
|
||||
|
||||
// Add GPT-5 header with secondary color
|
||||
@@ -395,15 +397,14 @@ impl ChatWidget<'_> {
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Span;
|
||||
self.history_cells
|
||||
.push(HistoryCell::new_styled_text_line(RLine::from(
|
||||
Span::styled(
|
||||
formatted_model,
|
||||
Style::default()
|
||||
.fg(crate::colors::secondary())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
)));
|
||||
self.add_to_history(HistoryCell::new_styled_text_line(RLine::from(
|
||||
Span::styled(
|
||||
formatted_model,
|
||||
Style::default()
|
||||
.fg(crate::colors::secondary())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
)));
|
||||
|
||||
self.stream_header_emitted = true;
|
||||
self.request_redraw();
|
||||
@@ -495,6 +496,8 @@ impl ChatWidget<'_> {
|
||||
content_buffer: String::new(),
|
||||
answer_buffer: String::new(),
|
||||
last_assistant_message: None,
|
||||
current_stream_message_id: None,
|
||||
current_stream_reasoning_id: None,
|
||||
running_commands: HashMap::new(),
|
||||
// Use max width to disable wrapping during streaming
|
||||
// Text will be properly wrapped when displayed based on terminal width
|
||||
@@ -974,6 +977,10 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_codex_event(&mut self, event: Event) {
|
||||
tracing::info!(
|
||||
"handle_codex_event({})",
|
||||
serde_json::to_string_pretty(&event).unwrap_or_default()
|
||||
);
|
||||
let Event { id, msg } = event;
|
||||
match msg {
|
||||
EventMsg::SessionConfigured(event) => {
|
||||
@@ -996,7 +1003,23 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
// Check if we've already processed this message (duplicate detection)
|
||||
// Check if this is the final message for a stream we've been processing
|
||||
if let Some(stream_id) = &self.current_stream_message_id {
|
||||
if stream_id == &id {
|
||||
// This is the final message for the current stream
|
||||
// Just finalize without adding duplicate content
|
||||
if self.current_stream == Some(StreamKind::Answer) {
|
||||
self.finalize_stream(StreamKind::Answer);
|
||||
self.last_assistant_message = Some(message.clone());
|
||||
self.answer_buffer.clear();
|
||||
self.current_stream_message_id = None;
|
||||
}
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Check if we've already processed this message (duplicate detection)
|
||||
let message_trimmed = message.trim();
|
||||
if !message_trimmed.is_empty() {
|
||||
if let Some(prev) = &self.last_assistant_message {
|
||||
@@ -1015,6 +1038,7 @@ impl ChatWidget<'_> {
|
||||
self.finalize_stream(StreamKind::Answer);
|
||||
self.last_assistant_message = Some(message.clone());
|
||||
self.answer_buffer.clear();
|
||||
self.current_stream_message_id = None;
|
||||
}
|
||||
self.request_redraw();
|
||||
return;
|
||||
@@ -1035,11 +1059,14 @@ impl ChatWidget<'_> {
|
||||
self.last_assistant_message = Some(message.clone());
|
||||
}
|
||||
self.answer_buffer.clear();
|
||||
self.current_stream_message_id = None;
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||||
self.begin_stream(StreamKind::Answer);
|
||||
// Track the message ID for this stream
|
||||
self.current_stream_message_id = Some(id.clone());
|
||||
// Update status to show we're generating a response
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.bottom_pane
|
||||
@@ -1053,6 +1080,8 @@ impl ChatWidget<'_> {
|
||||
// Stream CoT into the live pane; keep input visible and commit
|
||||
// overflow rows incrementally to scrollback.
|
||||
self.begin_stream(StreamKind::Reasoning);
|
||||
// Track the reasoning ID for this stream
|
||||
self.current_stream_reasoning_id = Some(id.clone());
|
||||
// Update status to show we're thinking/reasoning
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.bottom_pane.update_status_text("reasoning".to_string());
|
||||
@@ -1062,6 +1091,35 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
|
||||
// Check if this is the final message for a stream we've been processing
|
||||
if let Some(stream_id) = &self.current_stream_reasoning_id {
|
||||
if stream_id == &id {
|
||||
// This is the final message for the current stream
|
||||
// Just finalize without adding duplicate content
|
||||
if self.current_stream == Some(StreamKind::Reasoning) {
|
||||
self.finalize_stream(StreamKind::Reasoning);
|
||||
self.reasoning_buffer.clear();
|
||||
self.current_stream_reasoning_id = None;
|
||||
}
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Check if we've already streamed this content via deltas
|
||||
let text_trimmed = text.trim();
|
||||
if !self.reasoning_buffer.is_empty() && self.reasoning_buffer.trim() == text_trimmed {
|
||||
// We've already streamed this content via deltas
|
||||
// Just finalize the stream without adding duplicate content
|
||||
if self.current_stream == Some(StreamKind::Reasoning) {
|
||||
self.finalize_stream(StreamKind::Reasoning);
|
||||
self.reasoning_buffer.clear();
|
||||
self.current_stream_reasoning_id = None;
|
||||
}
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
// Final reasoning: if no deltas were streamed, render the final text.
|
||||
if self.current_stream != Some(StreamKind::Reasoning) && !text.is_empty() {
|
||||
self.begin_stream(StreamKind::Reasoning);
|
||||
@@ -1070,6 +1128,8 @@ impl ChatWidget<'_> {
|
||||
// Only finalize if we actually had a stream
|
||||
if self.current_stream == Some(StreamKind::Reasoning) {
|
||||
self.finalize_stream(StreamKind::Reasoning);
|
||||
self.reasoning_buffer.clear();
|
||||
self.current_stream_reasoning_id = None;
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -1078,6 +1138,8 @@ impl ChatWidget<'_> {
|
||||
}) => {
|
||||
// Treat raw reasoning content the same as summarized reasoning for UI flow.
|
||||
self.begin_stream(StreamKind::Reasoning);
|
||||
// Track the reasoning ID for this stream
|
||||
self.current_stream_reasoning_id = Some(id.clone());
|
||||
// Update status to show we're thinking/reasoning
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.bottom_pane.update_status_text("reasoning".to_string());
|
||||
@@ -1087,6 +1149,35 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
|
||||
// Check if this is the final message for a stream we've been processing
|
||||
if let Some(stream_id) = &self.current_stream_reasoning_id {
|
||||
if stream_id == &id {
|
||||
// This is the final message for the current stream
|
||||
// Just finalize without adding duplicate content
|
||||
if self.current_stream == Some(StreamKind::Reasoning) {
|
||||
self.finalize_stream(StreamKind::Reasoning);
|
||||
self.reasoning_buffer.clear();
|
||||
self.current_stream_reasoning_id = None;
|
||||
}
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Check if we've already streamed this content via deltas
|
||||
let text_trimmed = text.trim();
|
||||
if !self.reasoning_buffer.is_empty() && self.reasoning_buffer.trim() == text_trimmed {
|
||||
// We've already streamed this content via deltas
|
||||
// Just finalize the stream without adding duplicate content
|
||||
if self.current_stream == Some(StreamKind::Reasoning) {
|
||||
self.finalize_stream(StreamKind::Reasoning);
|
||||
self.reasoning_buffer.clear();
|
||||
self.current_stream_reasoning_id = None;
|
||||
}
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
// Final raw reasoning: if no deltas were streamed, render the final text.
|
||||
if self.current_stream != Some(StreamKind::Reasoning) && !text.is_empty() {
|
||||
self.begin_stream(StreamKind::Reasoning);
|
||||
@@ -1095,6 +1186,8 @@ impl ChatWidget<'_> {
|
||||
// Only finalize if we actually had a stream
|
||||
if self.current_stream == Some(StreamKind::Reasoning) {
|
||||
self.finalize_stream(StreamKind::Reasoning);
|
||||
self.reasoning_buffer.clear();
|
||||
self.current_stream_reasoning_id = None;
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -3134,10 +3227,9 @@ impl ChatWidget<'_> {
|
||||
for line in lines {
|
||||
// Use dimmed reasoning for thinking to get both markdown and dimming
|
||||
if matches!(self.current_stream, Some(StreamKind::Reasoning)) {
|
||||
self.history_cells
|
||||
.push(HistoryCell::new_dimmed_reasoning_line(line));
|
||||
self.add_to_history(HistoryCell::new_dimmed_reasoning_line(line));
|
||||
} else {
|
||||
self.history_cells.push(HistoryCell::new_text_line(line));
|
||||
self.add_to_history(HistoryCell::new_text_line(line));
|
||||
}
|
||||
}
|
||||
// Auto-follow if near bottom so streaming stays visible
|
||||
@@ -3187,10 +3279,9 @@ impl ChatWidget<'_> {
|
||||
for line in lines {
|
||||
// Use dimmed reasoning for thinking to get both markdown and dimming
|
||||
if matches!(self.current_stream, Some(StreamKind::Reasoning)) {
|
||||
self.history_cells
|
||||
.push(HistoryCell::new_dimmed_reasoning_line(line));
|
||||
self.add_to_history(HistoryCell::new_dimmed_reasoning_line(line));
|
||||
} else {
|
||||
self.history_cells.push(HistoryCell::new_text_line(line));
|
||||
self.add_to_history(HistoryCell::new_text_line(line));
|
||||
}
|
||||
}
|
||||
// Auto-follow if near bottom when finalizing a stream
|
||||
@@ -3354,13 +3445,14 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
(start_y, 0u16) // No scrolling needed
|
||||
} else {
|
||||
// Content overflows - calculate scroll position
|
||||
// scroll_offset of 0 = show newest at bottom
|
||||
// scroll_offset > 0 = scroll up to see older content
|
||||
// scroll_offset is measured from the bottom (0 = bottom/newest)
|
||||
// Convert to distance from the top for rendering math.
|
||||
let max_scroll = total_height.saturating_sub(content_area.height);
|
||||
// Update cache and clamp for display only
|
||||
self.last_max_scroll.set(max_scroll);
|
||||
let clamped_scroll_offset = self.scroll_offset.min(max_scroll);
|
||||
(content_area.y, clamped_scroll_offset)
|
||||
let scroll_from_top = max_scroll.saturating_sub(clamped_scroll_offset);
|
||||
(content_area.y, scroll_from_top)
|
||||
};
|
||||
|
||||
// Render the scrollable content
|
||||
@@ -3400,8 +3492,9 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
height: visible_height,
|
||||
};
|
||||
|
||||
// Render the item
|
||||
item.render_ref(item_area, buf);
|
||||
// Render only the visible window of the item using vertical skip
|
||||
let skip_rows = skip_top;
|
||||
item.render_with_skip(item_area, buf, skip_rows);
|
||||
screen_y += visible_height;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,10 @@ pub struct Cli {
|
||||
#[clap(long = "cd", short = 'C', value_name = "DIR")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
|
||||
/// Enable debug logging of all LLM requests and responses to files.
|
||||
#[clap(long = "debug", short = 'd', default_value_t = false)]
|
||||
pub debug: bool,
|
||||
|
||||
#[clap(skip)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::diff_render::create_diff_summary;
|
||||
use crate::exec_command::relativize_to_home;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::scroll_view::ScrollView;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::text_block::TextBlock;
|
||||
use crate::text_formatting::format_and_truncate_tool_result;
|
||||
@@ -319,7 +320,11 @@ impl HistoryCell {
|
||||
HistoryCell::BackgroundEvent { view: _ } => {
|
||||
// For background events (LLM responses), use proper word wrapping
|
||||
let processed_lines = self.get_processed_lines(width);
|
||||
processed_lines.len() as u16
|
||||
Paragraph::new(Text::from(processed_lines))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width)
|
||||
.try_into()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
HistoryCell::StyledText { view } => {
|
||||
// For styled text, respect wrapping to the available width
|
||||
@@ -330,9 +335,13 @@ impl HistoryCell {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
HistoryCell::DimmedReasoning { view: _ } => {
|
||||
// For dimmed reasoning, use dimmed markdown processing
|
||||
// For dimmed reasoning, use dimmed markdown processing with wrapping
|
||||
let processed_lines = self.get_processed_lines(width);
|
||||
processed_lines.len() as u16
|
||||
Paragraph::new(Text::from(processed_lines))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width)
|
||||
.try_into()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
_ => Paragraph::new(Text::from(self.plain_lines()))
|
||||
.wrap(Wrap { trim: false })
|
||||
@@ -438,15 +447,27 @@ impl HistoryCell {
|
||||
) -> Vec<Line<'static>> {
|
||||
match parsed.is_empty() {
|
||||
true => HistoryCell::new_exec_command_generic(command, output),
|
||||
false => HistoryCell::new_parsed_command(parsed, output),
|
||||
false => HistoryCell::new_parsed_command(command, parsed, output),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_parsed_command(
|
||||
command: &[String],
|
||||
parsed_commands: &[ParsedCommand],
|
||||
output: Option<&CommandOutput>,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line> = vec![Line::from("⚙︎ Working")];
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if output.is_some() {
|
||||
// Completed: show the command that ran
|
||||
let cmd_text = shlex_join_safe(command);
|
||||
lines.push(Line::from(vec![
|
||||
"⚡ Ran command ".magenta(),
|
||||
cmd_text.into(),
|
||||
]));
|
||||
} else {
|
||||
// In progress
|
||||
lines.push(Line::from("⚙︎ Working"));
|
||||
}
|
||||
|
||||
for (i, parsed) in parsed_commands.iter().enumerate() {
|
||||
let text = match parsed {
|
||||
@@ -1218,6 +1239,110 @@ impl WidgetRef for &HistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell {
|
||||
/// Render this history cell with a vertical skip of `skip_top` rows.
|
||||
/// This is used by the outer scroller to render only the visible window
|
||||
/// of a potentially tall cell (e.g., large diffs).
|
||||
pub(crate) fn render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_top: u16) {
|
||||
match self {
|
||||
// Animated items ignore vertical skip due to custom rendering.
|
||||
HistoryCell::AnimatedWelcome { .. } => self.render_ref(area, buf),
|
||||
HistoryCell::BackgroundEvent { .. }
|
||||
| HistoryCell::StyledText { .. }
|
||||
| HistoryCell::WelcomeMessage { .. }
|
||||
| HistoryCell::DimmedReasoning { .. }
|
||||
| HistoryCell::GitDiffOutput { .. }
|
||||
| HistoryCell::ReasoningOutput { .. }
|
||||
| HistoryCell::StatusOutput { .. }
|
||||
| HistoryCell::PromptsOutput { .. }
|
||||
| HistoryCell::ErrorEvent { .. }
|
||||
| HistoryCell::SessionInfo { .. }
|
||||
| HistoryCell::PendingPatch { .. }
|
||||
| HistoryCell::PlanUpdate { .. }
|
||||
| HistoryCell::PatchApplyResult { .. }
|
||||
| HistoryCell::CompletedMcpToolCall { .. }
|
||||
| HistoryCell::ActiveMcpToolCall { .. } => {
|
||||
// Use the same processed/plain lines as desired_height/render_ref but apply a vertical scroll.
|
||||
let lines = match self {
|
||||
HistoryCell::BackgroundEvent { .. }
|
||||
| HistoryCell::DimmedReasoning { .. } => self.get_processed_lines(area.width),
|
||||
HistoryCell::StyledText { view }
|
||||
| HistoryCell::WelcomeMessage { view }
|
||||
| HistoryCell::GitDiffOutput { view }
|
||||
| HistoryCell::ReasoningOutput { view }
|
||||
| HistoryCell::StatusOutput { view }
|
||||
| HistoryCell::PromptsOutput { view }
|
||||
| HistoryCell::ErrorEvent { view }
|
||||
| HistoryCell::SessionInfo { view }
|
||||
| HistoryCell::PendingPatch { view }
|
||||
| HistoryCell::PlanUpdate { view }
|
||||
| HistoryCell::PatchApplyResult { view }
|
||||
| HistoryCell::CompletedMcpToolCall { view }
|
||||
| HistoryCell::ActiveMcpToolCall { view } => view.lines.clone(),
|
||||
_ => self.plain_lines(),
|
||||
};
|
||||
Paragraph::new(Text::from(lines))
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((skip_top, 0))
|
||||
.style(
|
||||
Style::default()
|
||||
.fg(crate::colors::text())
|
||||
.bg(crate::colors::background()),
|
||||
)
|
||||
.render(area, buf);
|
||||
}
|
||||
HistoryCell::UserPrompt { view } => {
|
||||
// Keep the left border and apply scroll inside the text area.
|
||||
for y in area.top()..area.bottom() {
|
||||
if area.left() < area.right() {
|
||||
buf[(area.left(), y)]
|
||||
.set_char('│')
|
||||
.set_fg(crate::colors::border_focused());
|
||||
}
|
||||
}
|
||||
let text_area = Rect {
|
||||
x: area.x + 2,
|
||||
y: area.y,
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height,
|
||||
};
|
||||
Paragraph::new(Text::from(
|
||||
view.lines.iter().map(line_to_static).collect::<Vec<_>>(),
|
||||
))
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((skip_top, 0))
|
||||
.style(Style::default().bg(crate::colors::background()))
|
||||
.render(text_area, buf);
|
||||
}
|
||||
HistoryCell::Exec(_) | HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => {
|
||||
// Use ScrollView for complex widgets that don't support native scrolling
|
||||
let content_height = self.desired_height(area.width);
|
||||
|
||||
// Create a wrapper that implements Widget
|
||||
#[derive(Clone)]
|
||||
struct CellRenderer<'a> {
|
||||
cell: &'a HistoryCell,
|
||||
}
|
||||
|
||||
impl<'a> Widget for CellRenderer<'a> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
self.cell.render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply ScrollView with the vertical skip
|
||||
let scroll_view = ScrollView::new(
|
||||
CellRenderer { cell: self },
|
||||
content_height as usize,
|
||||
)
|
||||
.scroll_y(skip_top as usize);
|
||||
|
||||
scroll_view.render(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
|
||||
let args_str = invocation
|
||||
.arguments
|
||||
|
||||
@@ -42,6 +42,7 @@ pub mod live_wrap;
|
||||
mod log_layer;
|
||||
mod markdown;
|
||||
pub mod onboarding;
|
||||
mod scroll_view;
|
||||
mod shimmer;
|
||||
mod slash_command;
|
||||
mod terminal_info;
|
||||
@@ -112,6 +113,7 @@ pub async fn run_main(
|
||||
include_plan_tool: Some(true),
|
||||
disable_response_storage: cli.oss.then_some(true),
|
||||
show_raw_agent_reasoning: cli.oss.then_some(true),
|
||||
debug: Some(cli.debug),
|
||||
};
|
||||
|
||||
// Parse `-c` overrides from the CLI.
|
||||
@@ -269,8 +271,8 @@ fn run_ratatui_app(
|
||||
let mut terminal = tui::init(&config)?;
|
||||
terminal.clear()?;
|
||||
|
||||
let Cli { prompt, images, .. } = cli;
|
||||
let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen);
|
||||
let Cli { prompt, images, debug, .. } = cli;
|
||||
let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen, debug);
|
||||
|
||||
// Bridge log receiver into the AppEvent channel so latest log lines update the UI.
|
||||
{
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
widgets::{Block, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget},
|
||||
style::Style,
|
||||
};
|
||||
|
||||
/// A generic scrollable viewport for any Widget.
|
||||
/// You must provide the content height (in rows) for the current width.
|
||||
#[derive(Clone)]
|
||||
pub struct ScrollView<W> {
|
||||
inner: W,
|
||||
/// total logical content size
|
||||
pub content_height: usize,
|
||||
pub content_width: Option<usize>, // if you can measure it; otherwise None
|
||||
/// scroll offsets
|
||||
pub scroll_y: usize,
|
||||
pub scroll_x: usize,
|
||||
/// optional decorations
|
||||
pub block: Option<Block<'static>>,
|
||||
pub show_scrollbar: bool,
|
||||
pub scrollbar_style: Style,
|
||||
}
|
||||
|
||||
impl<W> ScrollView<W> {
|
||||
pub fn new(inner: W, content_height: usize) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
content_height,
|
||||
content_width: None,
|
||||
scroll_y: 0,
|
||||
scroll_x: 0,
|
||||
block: None,
|
||||
show_scrollbar: false, // We handle scrollbar separately in ChatWidget
|
||||
scrollbar_style: Style::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn block(mut self, block: Block<'static>) -> Self {
|
||||
self.block = Some(block);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn scroll_y(mut self, y: usize) -> Self {
|
||||
self.scroll_y = y;
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn scroll_x(mut self, x: usize) -> Self {
|
||||
self.scroll_x = x;
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn show_scrollbar(mut self, yes: bool) -> Self {
|
||||
self.show_scrollbar = yes;
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn scrollbar_style(mut self, style: Style) -> Self {
|
||||
self.scrollbar_style = style;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<W> Widget for ScrollView<W>
|
||||
where
|
||||
W: Widget + Clone, // render takes ownership; Clone keeps ergonomics
|
||||
{
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
// Apply outer block (optional)
|
||||
let mut inner_area = area;
|
||||
if let Some(block) = &self.block {
|
||||
block.clone().render(area, buf);
|
||||
inner_area = block.inner(area);
|
||||
if inner_area.height == 0 || inner_area.width == 0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let viewport_h = inner_area.height as usize;
|
||||
let viewport_w = inner_area.width as usize;
|
||||
|
||||
// Clamp scroll positions
|
||||
let max_y = self.content_height.saturating_sub(viewport_h);
|
||||
let sy = self.scroll_y.min(max_y);
|
||||
let sx = self.scroll_x.min(self.content_width.unwrap_or(0).saturating_sub(viewport_w));
|
||||
|
||||
// Offscreen render: pretend we have a huge height = content_height
|
||||
// (Width = viewport width so we don't waste memory horizontally)
|
||||
let virtual_h = self.content_height.max(viewport_h) as u16;
|
||||
let virtual_area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: inner_area.width,
|
||||
height: virtual_h,
|
||||
};
|
||||
|
||||
// Create an offscreen buffer and render the child into it
|
||||
let mut off = Buffer::empty(virtual_area);
|
||||
self.inner.clone().render(virtual_area, &mut off);
|
||||
|
||||
// Blit the visible window [sy..sy+viewport_h] and [sx..sx+viewport_w] into real buffer
|
||||
for row in 0..viewport_h.min(self.content_height.saturating_sub(sy)) {
|
||||
for col in 0..viewport_w {
|
||||
let src_x = sx + col;
|
||||
// If content_width is unknown, assume child draws within viewport width
|
||||
if let Some(cw) = self.content_width {
|
||||
if src_x >= cw {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let src_y = sy + row;
|
||||
if let Some(src_cell) = off.cell((src_x as u16, src_y as u16)) {
|
||||
let dst_x = inner_area.x + col as u16;
|
||||
let dst_y = inner_area.y + row as u16;
|
||||
if let Some(dst_cell) = buf.cell_mut((dst_x, dst_y)) {
|
||||
*dst_cell = src_cell.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional scrollbar on the right
|
||||
if self.show_scrollbar && self.content_height > viewport_h {
|
||||
let mut state = ScrollbarState::new(self.content_height)
|
||||
.position(sy)
|
||||
.viewport_content_length(viewport_h);
|
||||
Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.style(self.scrollbar_style)
|
||||
.render(inner_area, buf, &mut state);
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test the debug flag
|
||||
|
||||
echo "Testing debug flag..."
|
||||
|
||||
# Check if debug logs directory gets created
|
||||
DEBUG_LOG_DIR="$HOME/.codex/debug_logs"
|
||||
|
||||
# Remove old debug logs if they exist
|
||||
if [ -d "$DEBUG_LOG_DIR" ]; then
|
||||
echo "Removing old debug logs..."
|
||||
rm -rf "$DEBUG_LOG_DIR"
|
||||
fi
|
||||
|
||||
# Run coder with debug flag in exec mode (non-interactive)
|
||||
echo "Running coder with --debug flag..."
|
||||
./target/dev-fast/coder exec --debug "What is 2+2?" 2>&1 | head -20
|
||||
|
||||
# Check if debug logs were created
|
||||
if [ -d "$DEBUG_LOG_DIR" ]; then
|
||||
echo ""
|
||||
echo "Debug logs directory created: $DEBUG_LOG_DIR"
|
||||
echo "Contents:"
|
||||
ls -la "$DEBUG_LOG_DIR" 2>/dev/null | head -10
|
||||
|
||||
# Show a sample log file
|
||||
echo ""
|
||||
echo "Sample log file:"
|
||||
FIRST_LOG=$(ls "$DEBUG_LOG_DIR" 2>/dev/null | head -1)
|
||||
if [ -n "$FIRST_LOG" ]; then
|
||||
echo "Contents of $FIRST_LOG:"
|
||||
head -20 "$DEBUG_LOG_DIR/$FIRST_LOG"
|
||||
fi
|
||||
else
|
||||
echo "Debug logs directory NOT created"
|
||||
fi
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Simple test for debug logging
|
||||
|
||||
echo "Testing debug logging..."
|
||||
|
||||
# Set the debug log directory
|
||||
DEBUG_LOG_DIR="$HOME/.codex/debug_logs"
|
||||
|
||||
# Clean up any existing debug logs
|
||||
if [ -d "$DEBUG_LOG_DIR" ]; then
|
||||
echo "Cleaning up old debug logs..."
|
||||
rm -rf "$DEBUG_LOG_DIR"
|
||||
fi
|
||||
|
||||
# Run coder exec with debug flag
|
||||
cd /Users/zemaj/www/just-every/coder
|
||||
echo "Running: ./codex-rs/target/dev-fast/coder exec --debug 'What is 2+2?'"
|
||||
OUTPUT=$(./codex-rs/target/dev-fast/coder exec --debug "What is 2+2?" 2>&1)
|
||||
|
||||
echo "Command output:"
|
||||
echo "$OUTPUT" | head -20
|
||||
|
||||
# Check if debug logs were created
|
||||
echo ""
|
||||
if [ -d "$DEBUG_LOG_DIR" ]; then
|
||||
echo "✅ Debug logs directory created: $DEBUG_LOG_DIR"
|
||||
|
||||
# Count log files
|
||||
LOG_COUNT=$(find "$DEBUG_LOG_DIR" -type f | wc -l)
|
||||
echo "Number of log files created: $LOG_COUNT"
|
||||
|
||||
# List log files
|
||||
echo ""
|
||||
echo "Log files:"
|
||||
ls -la "$DEBUG_LOG_DIR" | tail -n +2 | head -10
|
||||
|
||||
# Show a sample request log
|
||||
echo ""
|
||||
REQUEST_LOG=$(find "$DEBUG_LOG_DIR" -name "*request*.json" | head -1)
|
||||
if [ -n "$REQUEST_LOG" ]; then
|
||||
echo "Sample request log (first 30 lines of $(basename "$REQUEST_LOG")):"
|
||||
cat "$REQUEST_LOG" | head -30
|
||||
fi
|
||||
else
|
||||
echo "❌ Debug logs directory NOT created - debug logging may not be working"
|
||||
fi
|
||||
Reference in New Issue
Block a user