feat(tui/file-completion): prioritize CWD matches; related UX fixes

- TUI: prioritize files from current working directory in completions;
  refine redraw debounce for lower latency; history/markdown rendering tweaks.
- Core/web_fetch: safer HTML sanitization, basic GitHub issue extraction,
  and trimmed payload heuristics.
- Core/agent_tool: more robust command_exists on unix/windows; improve fallbacks.
- Fix: remove unused var in JSON-LD scan to silence warning.

Build: verified with ./build-fast.sh (no warnings).
This commit is contained in:
James Peter
2025-08-30 06:19:34 +10:00
parent e12f31c020
commit 7d4cf9bf54
9 changed files with 1163 additions and 764 deletions
+103 -77
View File
@@ -413,23 +413,19 @@ async function main() {
if (isGlobal && !isNpx) try {
const isTTY = process.stdout && process.stdout.isTTY;
const isWindows = platform() === 'win32';
const ua = process.env.npm_config_user_agent || '';
const isBun = ua.includes('bun') || !!process.env.BUN_INSTALL;
let globalBin = '';
try {
globalBin = execSync('npm bin -g', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
} catch {}
const installedCmds = new Set(['coder']); // global install always exposes coder via package manager
const skippedCmds = [];
const ourShim = join(globalBin || '', isWindows ? 'code.cmd' : 'code');
// Resolve all 'code' candidates on PATH (so we detect collisions even if
// our npm global bin currently appears first).
// Helper to resolve all 'code' on PATH
const resolveAllOnPath = () => {
try {
if (isWindows) {
const out = execSync('where code', { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
return out.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
}
// Prefer 'which -a' if available; fall back to 'command -v'
let out = '';
try {
out = execSync('bash -lc "which -a code 2>/dev/null"', { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
@@ -444,81 +440,111 @@ async function main() {
}
};
const candidates = resolveAllOnPath();
const others = candidates.filter(p => p && ourShim && p !== ourShim);
const collision = others.length > 0;
const ensureWrapper = (name, args) => {
if (!globalBin) return;
if (isBun) {
// Bun creates shims for every bin; if another 'code' exists elsewhere on PATH, remove Bun's shim
let bunBin = '';
try {
const wrapperPath = join(globalBin, isWindows ? `${name}.cmd` : name);
if (isWindows) {
const content = `@echo off\r\n"%~dp0${collision ? 'coder' : 'code'}" ${args} %*\r\n`;
writeFileSync(wrapperPath, content);
} else {
const content = `#!/bin/sh\nexec "$(dirname \"$0\")/${collision ? 'coder' : 'code'}" ${args} "$@"\n`;
writeFileSync(wrapperPath, content);
chmodSync(wrapperPath, 0o755);
}
console.log(`✓ Created wrapper '${name}' -> ${collision ? 'coder' : 'code'} ${args}`);
} catch (e) {
console.log(`⚠ Failed to create '${name}' wrapper: ${e.message}`);
}
};
const home = process.env.HOME || process.env.USERPROFILE || '';
const bunBase = process.env.BUN_INSTALL || join(home, '.bun');
bunBin = join(bunBase, 'bin');
} catch {}
// Always create legacy wrappers so existing scripts keep working
ensureWrapper('code-tui', '');
ensureWrapper('code-exec', 'exec');
if (collision) {
console.log('⚠ Detected existing `code` on PATH:');
for (const p of others) console.log(` - ${p}`);
if (globalBin) {
// Remove our global `code` shim to avoid shadowing editors like VS Code
const bunShim = join(bunBin || '', isWindows ? 'code.cmd' : 'code');
const candidates = resolveAllOnPath();
const other = candidates.find(p => p && (!bunBin || !p.startsWith(bunBin)));
if (other && existsSync(bunShim)) {
try {
if (existsSync(ourShim)) {
unlinkSync(ourShim);
console.log(`✓ Skipped global 'code' shim (removed ${ourShim})`);
}
unlinkSync(bunShim);
console.log(`✓ Skipped global 'code' shim under Bun (existing: ${other})`);
skippedCmds.push({ name: 'code', reason: `existing: ${other}` });
} catch (e) {
console.log(`⚠ Could not remove npm shim '${ourShim}': ${e.message}`);
console.log(`⚠ Could not remove Bun shim '${bunShim}': ${e.message}`);
}
} else if (existsSync(bunShim)) {
installedCmds.add('code');
}
// Offer to create a 'vscode' alias that points to the existing system VS Code
const primaryOther = others[0];
if (isTTY && primaryOther) {
const prompt = (msg) => {
process.stdout.write(msg);
try {
const buf = Buffer.alloc(1024);
const bytes = readSync(0, buf, 0, 1024, null);
const ans = buf.slice(0, bytes).toString('utf8').trim().toLowerCase();
return ans;
} catch { return 'n'; }
};
const ans = prompt('Create a `vscode` alias for your existing editor? [y/N] ');
if (ans === 'y' || ans === 'yes') {
try {
const vscodeShim = join(globalBin, isWindows ? 'vscode.cmd' : 'vscode');
if (isWindows) {
const content = `@echo off\r\n"${primaryOther}" %*\r\n`;
writeFileSync(vscodeShim, content);
} else {
const content = `#!/bin/sh\nexec "${primaryOther}" "$@"\n`;
writeFileSync(vscodeShim, content);
chmodSync(vscodeShim, 0o755);
}
console.log('✓ Created `vscode` alias for your editor');
} catch (e) {
console.log(`⚠ Failed to create 'vscode' alias: ${e.message}`);
}
} else {
console.log('Skipping creation of `vscode` alias.');
}
}
console.log('→ Use `coder` to run this tool, and `vscode` (if created) for your editor.');
// Print summary for Bun
const list = Array.from(installedCmds).sort().join(', ');
console.log(`Commands installed (bun): ${list}`);
if (skippedCmds.length) {
for (const s of skippedCmds) console.log(`Commands skipped: ${s.name} (${s.reason})`);
console.log('→ Use `coder` to run this tool.');
}
// Final friendly usage hint
if (installedCmds.has('code')) {
console.log("Use 'code' to launch Code.");
} else {
console.log('Note: could not determine npm global bin; skipping alias creation.');
console.log("Use 'coder' to launch Code.");
}
} else {
// npm/pnpm/yarn path
let globalBin = '';
try {
globalBin = execSync('npm bin -g', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
} catch {}
const ourShim = join(globalBin || '', isWindows ? 'code.cmd' : 'code');
const candidates = resolveAllOnPath();
const others = candidates.filter(p => p && ourShim && p !== ourShim);
const collision = others.length > 0;
const ensureWrapper = (name, args) => {
if (!globalBin) return;
try {
const wrapperPath = join(globalBin, isWindows ? `${name}.cmd` : name);
if (isWindows) {
const content = `@echo off\r\n"%~dp0${collision ? 'coder' : 'code'}" ${args} %*\r\n`;
writeFileSync(wrapperPath, content);
} else {
const content = `#!/bin/sh\nexec "$(dirname \"$0\")/${collision ? 'coder' : 'code'}" ${args} "$@"\n`;
writeFileSync(wrapperPath, content);
chmodSync(wrapperPath, 0o755);
}
console.log(`✓ Created wrapper '${name}' -> ${collision ? 'coder' : 'code'} ${args}`);
installedCmds.add(name);
} catch (e) {
console.log(`⚠ Failed to create '${name}' wrapper: ${e.message}`);
}
};
// Always create legacy wrappers so existing scripts keep working
ensureWrapper('code-tui', '');
ensureWrapper('code-exec', 'exec');
if (collision) {
console.log('⚠ Detected existing `code` on PATH:');
for (const p of others) console.log(` - ${p}`);
if (globalBin) {
try {
if (existsSync(ourShim)) {
unlinkSync(ourShim);
console.log(`✓ Skipped global 'code' shim (removed ${ourShim})`);
skippedCmds.push({ name: 'code', reason: `existing: ${others[0]}` });
}
} catch (e) {
console.log(`⚠ Could not remove npm shim '${ourShim}': ${e.message}`);
}
console.log('→ Use `coder` to run this tool.');
} else {
console.log('Note: could not determine npm global bin; skipping alias creation.');
}
} else {
// No collision; npm created a 'code' shim for us.
if (globalBin && existsSync(ourShim)) installedCmds.add('code');
}
// Print summary for npm/pnpm/yarn
const list = Array.from(installedCmds).sort().join(', ');
console.log(`Commands installed: ${list}`);
if (skippedCmds.length) {
for (const s of skippedCmds) console.log(`Commands skipped: ${s.name} (${s.reason})`);
}
// Final friendly usage hint
if (installedCmds.has('code')) {
console.log("Use 'code' to launch Code.");
} else {
console.log("Use 'coder' to launch Code.");
}
}
} catch {
+47 -33
View File
@@ -544,47 +544,32 @@ async fn execute_model_with_permissions(
) -> Result<String, String> {
// Helper: cross‑platform check whether an executable is available in PATH.
fn command_exists(cmd: &str) -> bool {
// Absolute/relative path with separators: check directly.
// Absolute/relative path with separators: check directly (files only).
if cmd.contains(std::path::MAIN_SEPARATOR) || cmd.contains('/') || cmd.contains('\\') {
return std::fs::metadata(cmd).is_ok();
return std::fs::metadata(cmd).map(|m| m.is_file()).unwrap_or(false);
}
let path = std::env::var_os("PATH");
if path.is_none() {
return false;
#[cfg(target_os = "windows")]
{
return which::which(cmd).map(|p| p.is_file()).unwrap_or(false);
}
let pathext: Vec<String> = if cfg!(windows) {
// Default Windows PATHEXT set; also respect env if provided.
let env_ext = std::env::var("PATHEXT").unwrap_or(".COM;.EXE;.BAT;.CMD;.MSI;.PS1".to_string());
env_ext.split(';').filter_map(|e| {
let e = e.trim();
if e.is_empty() { None } else { Some(e.to_string()) }
}).collect()
} else {
Vec::new()
};
for dir in std::env::split_paths(&path.unwrap()) {
if dir.as_os_str().is_empty() {
continue;
}
let base = dir.join(cmd);
if cfg!(windows) {
// Try with PATHEXT variants
if std::fs::metadata(&base).is_ok() {
return true;
}
for ext in &pathext {
let candidate = base.with_extension(ext.trim_start_matches('.'));
if std::fs::metadata(&candidate).is_ok() {
return true;
#[cfg(not(target_os = "windows"))]
{
use std::os::unix::fs::PermissionsExt;
let Some(path_os) = std::env::var_os("PATH") else { return false; };
for dir in std::env::split_paths(&path_os) {
if dir.as_os_str().is_empty() { continue; }
let candidate = dir.join(cmd);
if let Ok(meta) = std::fs::metadata(&candidate) {
if meta.is_file() {
let mode = meta.permissions().mode();
if mode & 0o111 != 0 { return true; }
}
}
} else if std::fs::metadata(&base).is_ok() {
return true;
}
false
}
false
}
// Use config command if provided, otherwise use model name
@@ -670,7 +655,36 @@ async fn execute_model_with_permissions(
return Err(format!("Required agent '{}' is not installed or not in PATH", command));
}
let output = cmd.output().await.map_err(|e| format!("Failed to execute {}: {}", model, e))?;
// Attempt to spawn the external command; if it fails due to NotFound or a
// similar OS error, fall back to invoking the current executable (built‑in)
// for better portability, especially on Windows with PATH shims.
let output = match cmd.output().await {
Ok(o) => o,
Err(e) => {
// Only fall back for external CLIs (not the built-in code/codex path)
if model_name == "codex" || model_name == "code" {
return Err(format!("Failed to execute {}: {}", model, e));
}
let mut fb = match std::env::current_exe() {
Ok(p) => Command::new(p),
Err(e2) => return Err(format!(
"Failed to execute {} and could not resolve built-in fallback: {} / {}",
model, e, e2
)),
};
if read_only {
fb.args(["-s", "read-only", "-a", "never", "exec", prompt]);
} else {
fb.args(["-s", "workspace-write", "-a", "never", "exec", prompt]);
}
fb.output().await.map_err(|e2| {
format!(
"Failed to execute {} ({}). Built-in fallback also failed: {}",
model, e, e2
)
})?
}
};
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
+359 -24
View File
@@ -2866,6 +2866,9 @@ async fn handle_web_fetch(
}
// Helper: remove obvious noisy blocks before markdown conversion.
// This uses a lightweight ASCII-insensitive scan to drop whole
// elements whose contents should never be surfaced to the model
// (scripts, styles, templates, headers/footers/navigation, etc.).
fn strip_noisy_tags(mut html: String) -> String {
// Remove <script>, <style>, and <noscript> blocks with a simple
// ASCII case-insensitive scan that preserves UTF-8 boundaries.
@@ -2931,6 +2934,7 @@ async fn handle_web_fetch(
None
}
// Keep this conservative to avoid dropping content.
let tags = ["script", "style", "noscript"];
for tag in tags.iter() {
let mut guard = 0;
@@ -2952,6 +2956,278 @@ async fn handle_web_fetch(
html
}
// Try to keep only <main> content if present; drastically reduces
// boilerplate from navigation and login banners on many sites.
fn extract_main(html: &str) -> Option<String> {
// Find opening <main ...>
let bytes = html.as_bytes();
let open = {
let mut i = 0usize;
let tag = b"main";
while i + 5 < bytes.len() { // < m a i n > (min)
if bytes[i] == b'<' {
// skip '<' and whitespace
let mut j = i + 1;
while j < bytes.len() && bytes[j].is_ascii_whitespace() { j += 1; }
if j + tag.len() <= bytes.len() && bytes[j..j+tag.len()].eq_ignore_ascii_case(tag) {
// Found '<main'; now find '>'
while j < bytes.len() && bytes[j] != b'>' { j += 1; }
if j < bytes.len() { Some((i, j + 1)) } else { None }
} else { None }
} else { None }
.map(|pair| return pair);
i += 1;
}
None
};
let (start, after_open) = open?;
// Find closing </main>
let mut i = after_open;
let tag_close = b"</main";
while i + tag_close.len() + 1 < bytes.len() {
if bytes[i] == b'<' && bytes[i+1] == b'/' {
if bytes[i..].len() >= tag_close.len() && bytes[i..i+tag_close.len()].eq_ignore_ascii_case(tag_close) {
// Find closing '>'
let mut j = i + tag_close.len();
while j < bytes.len() && bytes[j] != b'>' { j += 1; }
if j < bytes.len() {
return Some(html[start..j+1].to_string());
} else {
return Some(html[start..].to_string());
}
}
}
i += 1;
}
Some(html[start..].to_string())
}
// Inside fenced code blocks, collapse massively-escaped Windows paths like
// `C:\\Users\\...` to `C:\Users\...`. Only applies to drive-rooted paths.
fn unescape_windows_paths(line: &str) -> String {
let bytes = line.as_bytes();
let mut out = String::with_capacity(line.len());
let mut i = 0usize;
while i < bytes.len() {
// Pattern: [A-Za-z] : \\+
if i + 3 < bytes.len()
&& bytes[i].is_ascii_alphabetic()
&& bytes[i+1] == b':'
&& bytes[i+2] == b'\\'
&& bytes[i+3] == b'\\'
{
// Emit drive and a single backslash
out.push(bytes[i] as char);
out.push(':');
out.push('\\');
// Skip all following backslashes in this run
i += 4;
while i < bytes.len() && bytes[i] == b'\\' { i += 1; }
continue;
}
out.push(bytes[i] as char);
i += 1;
}
out
}
// Lightweight cleanup on the resulting markdown to remove leaked
// JSON blobs and obvious client boot payloads that sometimes escape
// the <script> filter on complex sites. Avoids touching fenced code.
fn postprocess_markdown(md: &str) -> String {
let mut out: Vec<String> = Vec::with_capacity(md.len() / 64 + 1);
let mut in_fence = false;
let mut empty_run = 0usize;
for line in md.lines() {
// Track fenced code blocks
if let Some(rest) = line.trim_start().strip_prefix("```") {
in_fence = !in_fence;
let _lang = if in_fence { Some(rest.trim()) } else { None };
out.push(line.to_string());
empty_run = 0;
continue;
}
if in_fence {
// Only normalize Windows path over-escaping; do not alter other content.
let normalized = unescape_windows_paths(line);
out.push(normalized);
continue;
}
let trimmed = line.trim();
// Drop extremely long single lines only if they're likely SPA boot payloads
if trimmed.len() > 8000 { continue; }
// Common SPA boot keys that shouldn't appear in human output.
// Keep this list tight to avoid dropping legitimate examples.
if trimmed.contains("\"payload\"") || trimmed.contains("\"props\"") || trimmed.contains("\"preloaded_records\"") || trimmed.contains("\"appPayload\"") || trimmed.contains("\"preloadedQueries\"") {
continue;
}
if trimmed.is_empty() {
// Collapse multiple empty lines to max 1
if empty_run == 0 {
out.push(String::new());
}
empty_run += 1;
} else {
out.push(line.to_string());
empty_run = 0;
}
}
// Trim leading/trailing blank lines
let mut s = out.join("\n");
while s.starts_with('\n') { s.remove(0); }
while s.ends_with('\n') { s.pop(); }
s
}
// Domain-specific: extract rich content from GitHub issue/PR pages
// without requiring a JS-capable browser. We parse JSON-LD and the
// inlined GraphQL payload (preloadedQueries) to reconstruct the
// issue body and comments into readable markdown.
fn try_extract_github_issue_markdown(html: &str) -> Option<String> {
// Helper: extract the first <script type="application/ld+json"> block
fn extract_ld_json(html: &str) -> Option<serde_json::Value> {
let mut s = html;
loop {
let start = s.find("<script").map(|i| i)?;
let rest = &s[start + 7..];
if rest.to_lowercase().contains("type=\"application/ld+json\"") {
// Find end of script open tag
let open_end_rel = rest.find('>')?;
let open_end = start + 7 + open_end_rel + 1;
let after_open = &s[open_end..];
// Find closing </script>
if let Some(close_rel) = after_open.to_lowercase().find("</script>") {
let json_str = &after_open[..close_rel];
if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_str) {
return Some(v);
}
// Some pages JSON-encode the JSON-LD; try to unescape once
if let Ok(un) = serde_json::from_str::<String>(json_str) {
if let Ok(v2) = serde_json::from_str::<serde_json::Value>(&un) {
return Some(v2);
}
}
// Advance after this script to search for next
s = &after_open[close_rel + 9..];
continue;
}
}
// Advance and continue search
s = &rest[1..];
}
}
// Helper: extract substring for the JSON array that follows key
fn extract_json_array_after(html: &str, key: &str) -> Option<String> {
let idx = html.find(key)?;
let bytes = html.as_bytes();
// Find the first '[' after key
let mut i = idx + key.len();
while i < bytes.len() && bytes[i] != b'[' { i += 1; }
if i >= bytes.len() { return None; }
let start = i;
// Scan to matching ']' accounting for strings and escapes
let mut depth: i32 = 0;
let mut in_str = false;
let mut escape = false;
while i < bytes.len() {
let c = bytes[i] as char;
if in_str {
if escape { escape = false; }
else if c == '\\' { escape = true; }
else if c == '"' { in_str = false; }
i += 1; continue;
}
match c {
'"' => { in_str = true; },
'[' => { depth += 1; },
']' => { depth -= 1; if depth == 0 { let end = i + 1; return Some(html[start..end].to_string()); } },
_ => {}
}
i += 1;
}
None
}
// Parse JSON-LD for headline, articleBody, author, date
let mut title: Option<String> = None;
let mut issue_body_md: Option<String> = None;
let mut opened_by: Option<String> = None;
let mut opened_at: Option<String> = None;
if let Some(ld) = extract_ld_json(html) {
if ld.get("@type").and_then(|v| v.as_str()) == Some("DiscussionForumPosting") {
title = ld.get("headline").and_then(|v| v.as_str()).map(|s| s.to_string());
issue_body_md = ld.get("articleBody").and_then(|v| v.as_str()).map(|s| s.to_string());
opened_by = ld.get("author").and_then(|a| a.get("name")).and_then(|v| v.as_str()).map(|s| s.to_string());
opened_at = ld.get("datePublished").and_then(|v| v.as_str()).map(|s| s.to_string());
}
}
// Parse GraphQL payload for comments and state
let arr_str = extract_json_array_after(html, "\"preloadedQueries\"")?;
let arr: serde_json::Value = serde_json::from_str(&arr_str).ok()?;
let mut comments: Vec<(String, String, String)> = Vec::new();
let mut state: Option<String> = None;
let mut state_reason: Option<String> = None;
if let Some(items) = arr.as_array() {
for item in items {
let repo = item.get("result").and_then(|v| v.get("data")).and_then(|v| v.get("repository"));
let issue = repo.and_then(|r| r.get("issue"));
if let Some(issue) = issue {
if state.is_none() {
state = issue.get("state").and_then(|v| v.as_str()).map(|s| s.to_string());
state_reason = issue.get("stateReason").and_then(|v| v.as_str()).map(|s| s.to_string());
}
if let Some(edges) = issue.get("frontTimelineItems").and_then(|v| v.get("edges")).and_then(|v| v.as_array()) {
for e in edges {
let node = e.get("node");
let typename = node.and_then(|n| n.get("__typename")).and_then(|v| v.as_str()).unwrap_or("");
if typename == "IssueComment" {
let author = node.and_then(|n| n.get("author")).and_then(|a| a.get("login")).and_then(|v| v.as_str()).unwrap_or("");
let created = node.and_then(|n| n.get("createdAt")).and_then(|v| v.as_str()).unwrap_or("");
let body = node.and_then(|n| n.get("body")).and_then(|v| v.as_str()).unwrap_or("");
if !body.is_empty() {
comments.push((author.to_string(), created.to_string(), body.to_string()));
} else {
let body_html = node.and_then(|n| n.get("bodyHTML")).and_then(|v| v.as_str()).unwrap_or("");
if !body_html.is_empty() {
// Minimal HTML→MD for comments if body missing
let options = htmd::options::Options { heading_style: htmd::options::HeadingStyle::Atx, code_block_style: htmd::options::CodeBlockStyle::Fenced, link_style: htmd::options::LinkStyle::Inlined, ..Default::default() };
let conv = htmd::HtmlToMarkdown::builder().options(options).build();
if let Ok(md) = conv.convert(body_html) {
comments.push((author.to_string(), created.to_string(), md));
}
}
}
}
}
}
}
}
}
// If nothing meaningful extracted, bail out.
if title.is_none() && comments.is_empty() && issue_body_md.is_none() {
return None;
}
// Compose readable markdown
let mut out = String::new();
if let Some(t) = title { out.push_str(&format!("# {}\n\n", t)); }
if let (Some(by), Some(at)) = (opened_by, opened_at) { out.push_str(&format!("Opened by {} on {}\n\n", by, at)); }
if let (Some(s), _) = (state.clone(), state_reason.clone()) { out.push_str(&format!("State: {}\n\n", s)); }
if let Some(body) = issue_body_md { out.push_str(&format!("{}\n\n", body)); }
if !comments.is_empty() {
out.push_str("## Comments\n\n");
for (author, created, body) in comments {
out.push_str(&format!("- {} — {}\n\n{}\n\n", author, created, body));
}
}
Some(out)
}
// Helper: convert HTML to markdown and truncate if too large.
fn convert_html_to_markdown_trimmed(html: String, max_chars: usize) -> crate::error::Result<(String, bool)> {
let options = htmd::options::Options {
@@ -2961,8 +3237,10 @@ async fn handle_web_fetch(
..Default::default()
};
let converter = htmd::HtmlToMarkdown::builder().options(options).build();
let sanitized = strip_noisy_tags(html);
let reduced = extract_main(&html).unwrap_or(html);
let sanitized = strip_noisy_tags(reduced);
let markdown = converter.convert(&sanitized)?;
let markdown = postprocess_markdown(&markdown);
let mut truncated = false;
let rendered = {
let char_count = markdown.chars().count();
@@ -3013,6 +3291,49 @@ async fn handle_web_fetch(
let timeout = Duration::from_millis(params.timeout_ms.unwrap_or(15000));
let codex_ua = crate::user_agent::get_codex_user_agent(Some("web_fetch"));
// Heuristic: some domains render key content client-side. Prefer a
// quick browser render first so we capture comments/timelines.
let is_dynamic_domain = {
let u = params.url.to_lowercase();
u.contains("github.com/")
};
if !matches!(params.mode.as_deref(), Some("http")) && is_dynamic_domain {
let browser_manager = codex_browser::global::get_or_create_browser_manager().await;
if let Ok(res) = browser_manager.goto(&params.url).await {
// Poll briefly for discussion/timeline elements to appear
for _ in 0..6 {
let js = r#"(function(){ const sel1 = document.querySelectorAll('[data-test-selector=\"issue-comment-body\"]'); const sel2 = document.querySelectorAll('.js-timeline-item'); return (sel1.length + sel2.length); })()"#;
if let Ok(val) = browser_manager.execute_javascript(js).await {
let n = val.get("value").and_then(|v| v.as_i64()).unwrap_or(0);
if n > 0 { break; }
}
tokio::time::sleep(Duration::from_millis(800)).await;
}
if let Ok(val) = browser_manager.execute_javascript(r#"(function(){ return { html: document.documentElement.outerHTML, title: document.title||'' }; })()"#).await {
if let Some(html) = val.get("value").and_then(|v| v.get("html")).and_then(|v| v.as_str()) {
let (markdown, truncated) = match convert_html_to_markdown_trimmed(html.to_string(), 120_000) {
Ok(t) => t,
Err(e) => {
return ResponseInputItem::FunctionCallOutput { call_id: call_id_clone, output: FunctionCallOutputPayload { content: format!("Markdown conversion failed: {e}"), success: Some(false) } };
}
};
let body = serde_json::json!({
"url": params.url,
"status": 200,
"final_url": res.url,
"content_type": "text/html",
"used_browser_ua": true,
"via_browser": true,
"truncated": truncated,
"markdown": markdown,
});
return ResponseInputItem::FunctionCallOutput { call_id: call_id_clone, output: FunctionCallOutputPayload { content: body.to_string(), success: Some(true) } };
}
}
}
}
// If explicit browser mode requested, try the internal browser first.
if matches!(params.mode.as_deref(), Some("browser")) {
{
@@ -3258,6 +3579,22 @@ async fn handle_web_fetch(
};
}
// Domain-specific extraction first (e.g., GitHub issues)
if params.url.contains("github.com/") && params.url.contains("/issues/") {
if let Some(md) = try_extract_github_issue_markdown(&body_text) {
let body = serde_json::json!({
"url": params.url,
"status": status.as_u16(),
"final_url": final_url,
"content_type": content_type,
"used_browser_ua": used_browser_ua,
"truncated": false,
"markdown": md,
});
return ResponseInputItem::FunctionCallOutput { call_id: call_id_clone, output: FunctionCallOutputPayload { content: body.to_string(), success: Some(true) } };
}
}
// Success: convert to markdown (sanitized and size-limited)
let (markdown, truncated) = match convert_html_to_markdown_trimmed(body_text, 120_000) {
Ok(t) => t,
@@ -3434,34 +3771,32 @@ async fn handle_run_agent(
// Helper: PATH lookup to determine if a command exists.
fn command_exists(cmd: &str) -> bool {
// Absolute/relative path with separators: verify it points to a file.
if cmd.contains(std::path::MAIN_SEPARATOR) || cmd.contains('/') || cmd.contains('\\') {
return std::fs::metadata(cmd).is_ok();
return std::fs::metadata(cmd).map(|m| m.is_file()).unwrap_or(false);
}
let Some(path_os) = std::env::var_os("PATH") else { return false; };
let pathext: Vec<String> = if cfg!(windows) {
std::env::var("PATHEXT")
.unwrap_or(".COM;.EXE;.BAT;.CMD;.MSI;.PS1".to_string())
.split(';')
.filter(|e| !e.trim().is_empty())
.map(|s| s.to_string())
.collect()
} else {
Vec::new()
};
for dir in std::env::split_paths(&path_os) {
if dir.as_os_str().is_empty() { continue; }
let base = dir.join(cmd);
if cfg!(windows) {
if std::fs::metadata(&base).is_ok() { return true; }
for ext in &pathext {
let candidate = base.with_extension(ext.trim_start_matches('.'));
if std::fs::metadata(&candidate).is_ok() { return true; }
#[cfg(target_os = "windows")]
{
return which::which(cmd).map(|p| p.is_file()).unwrap_or(false);
}
#[cfg(not(target_os = "windows"))]
{
use std::os::unix::fs::PermissionsExt;
let Some(path_os) = std::env::var_os("PATH") else { return false; };
for dir in std::env::split_paths(&path_os) {
if dir.as_os_str().is_empty() { continue; }
let candidate = dir.join(cmd);
if let Ok(meta) = std::fs::metadata(&candidate) {
if meta.is_file() {
let mode = meta.permissions().mode();
if mode & 0o111 != 0 { return true; }
}
}
} else if std::fs::metadata(&base).is_ok() {
return true;
}
false
}
false
}
let batch_id = if models.len() > 1 {
+3 -6
View File
@@ -245,12 +245,13 @@ impl App<'_> {
{
return;
}
// Leading-edge draw now for lower input latency; coalesce further
// requests until the debounce window elapses.
self.app_event_tx.send(AppEvent::Redraw);
let tx = self.app_event_tx.clone();
let pending_redraw = self.pending_redraw.clone();
thread::spawn(move || {
thread::sleep(REDRAW_DEBOUNCE);
tx.send(AppEvent::Redraw);
pending_redraw.store(false, Ordering::Release);
});
}
@@ -295,10 +296,6 @@ impl App<'_> {
AppEvent::RequestRedraw => {
self.schedule_redraw();
}
AppEvent::ImmediateRedraw => {
// Draw immediately; no debounce
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
}
AppEvent::Redraw => {
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
}
-4
View File
@@ -32,10 +32,6 @@ pub(crate) enum AppEvent {
/// Request a redraw which will be debounced by the [`App`].
RequestRedraw,
/// Request an immediate redraw with no debounce. Intended for latency-sensitive
/// interactions like input editing and cursor movement.
ImmediateRedraw,
/// Actually draw the next frame.
Redraw,
+11 -8
View File
@@ -164,7 +164,8 @@ impl BottomPane<'_> {
self.active_view = Some(view);
}
// Don't create a status view - keep composer visible
self.request_immediate_redraw();
// Debounce view navigation redraws to reduce render thrash
self.request_redraw();
InputResult::None
} else {
// If a task is running and a status line is visible, allow Esc to
@@ -177,7 +178,9 @@ impl BottomPane<'_> {
}
let (input_result, needs_redraw) = self.composer.handle_key_event(key_event);
if needs_redraw {
self.request_immediate_redraw();
// Route input updates through the app's debounced redraw path
// so typing doesn't attempt a full-screen redraw per key.
self.request_redraw();
}
if self.composer.is_in_paste_burst() {
self.request_redraw_in(ChatComposer::recommended_paste_flush_delay());
@@ -189,14 +192,14 @@ impl BottomPane<'_> {
/// Attempt to navigate history upwards from the composer. Returns true if consumed.
pub(crate) fn try_history_up(&mut self) -> bool {
let consumed = self.composer.try_history_up();
if consumed { self.request_immediate_redraw(); }
if consumed { self.request_redraw(); }
consumed
}
/// Attempt to navigate history downwards from the composer. Returns true if consumed.
pub(crate) fn try_history_down(&mut self) -> bool {
let consumed = self.composer.try_history_down();
if consumed { self.request_immediate_redraw(); }
if consumed { self.request_redraw(); }
consumed
}
@@ -234,7 +237,8 @@ impl BottomPane<'_> {
if self.active_view.is_none() {
let needs_redraw = self.composer.handle_paste(pasted);
if needs_redraw {
self.request_immediate_redraw();
// Large pastes may arrive as bursts; coalesce paints
self.request_redraw();
}
}
}
@@ -458,9 +462,8 @@ impl BottomPane<'_> {
self.app_event_tx.send(AppEvent::RequestRedraw)
}
pub(crate) fn request_immediate_redraw(&self) {
self.app_event_tx.send(AppEvent::ImmediateRedraw)
}
// Immediate redraw path removed; all UI updates flow through the
// debounced RequestRedraw/App::Redraw scheduler to reduce thrash.
pub(crate) fn flash_footer_notice(&mut self, text: String) {
self.composer.flash_footer_notice(text);
+217 -81
View File
@@ -150,6 +150,8 @@ pub(crate) struct ChatWidget<'a> {
live_builder: RowBuilder,
// Store pending image paths keyed by their placeholder text
pending_images: HashMap<String, PathBuf>,
// Store pending non-image file paths keyed by their placeholder text
pending_files: HashMap<String, PathBuf>,
welcome_shown: bool,
// Path to the latest browser screenshot and URL for display
latest_browser_screenshot: Arc<Mutex<Option<(PathBuf, String)>>>,
@@ -218,6 +220,10 @@ pub(crate) struct ChatWidget<'a> {
// Prefix sums of content heights (including spacing) for fast scroll range
prefix_sums: std::cell::RefCell<Vec<u16>>,
// Cache key for prefix_sums to avoid rebuilding on pure scroll frames
last_prefix_width: std::cell::Cell<u16>,
last_prefix_count: std::cell::Cell<usize>,
prefix_valid: std::cell::Cell<bool>,
// Stateful vertical scrollbar for history view
vertical_scrollbar_state: std::cell::RefCell<ScrollbarState>,
@@ -272,8 +278,11 @@ struct DiffConfirm {
}
struct UserMessage {
text: String,
image_paths: Vec<PathBuf>,
/// What to show in the chat history (keeps placeholders like "[image: name.png]")
display_text: String,
/// Items to send to the core/model in the correct order, with inline
/// markers preceding images so the LLM knows placement.
ordered_items: Vec<InputItem>,
}
#[derive(Default, Clone, Debug)]
@@ -417,10 +426,11 @@ enum AgentStatus {
impl From<String> for UserMessage {
fn from(text: String) -> Self {
Self {
text,
image_paths: Vec::new(),
let mut ordered = Vec::new();
if !text.trim().is_empty() {
ordered.push(InputItem::Text { text: text.clone() });
}
Self { display_text: text, ordered_items: ordered }
}
}
@@ -428,7 +438,16 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
if text.is_empty() && image_paths.is_empty() {
None
} else {
Some(UserMessage { text, image_paths })
let mut ordered: Vec<InputItem> = Vec::new();
if !text.trim().is_empty() {
ordered.push(InputItem::Text { text: text.clone() });
}
for path in image_paths {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("image");
ordered.push(InputItem::Text { text: format!("[image: {}]", filename) });
ordered.push(InputItem::LocalImage { path });
}
Some(UserMessage { display_text: text, ordered_items: ordered })
}
}
@@ -875,6 +894,7 @@ impl ChatWidget<'_> {
fn invalidate_height_cache(&mut self) {
self.height_cache.borrow_mut().clear();
self.prefix_sums.borrow_mut().clear();
self.prefix_valid.set(false);
}
@@ -1290,60 +1310,141 @@ impl ChatWidget<'_> {
const IMAGE_EXTENSIONS: &[&str] = &[
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ".ico", ".tiff", ".tif",
];
// We keep a visible copy of the original (normalized) text for history
let mut display_text = text.clone();
let mut ordered_items: Vec<InputItem> = Vec::new();
let mut image_paths = Vec::new();
let mut cleaned_text = text.clone();
// First, handle [image: ...] placeholders from drag-and-drop
let placeholder_regex = regex_lite::Regex::new(r"\[image: [^\]]+\]").unwrap();
// First, handle [image: ...] and [file: ...] placeholders from drag-and-drop
let placeholder_regex = regex_lite::Regex::new(r"\[(image|file): [^\]]+\]").unwrap();
let mut cursor = 0usize;
for mat in placeholder_regex.find_iter(&text) {
// Push preceding text as a text item (if any)
if mat.start() > cursor {
let chunk = &text[cursor..mat.start()];
if !chunk.trim().is_empty() {
ordered_items.push(InputItem::Text { text: chunk.to_string() });
}
}
let placeholder = mat.as_str();
if let Some(path) = self.pending_images.remove(placeholder) {
image_paths.push(path);
// Remove the placeholder from the text
cleaned_text = cleaned_text.replace(placeholder, "");
if placeholder.starts_with("[image:") {
if let Some(path) = self.pending_images.remove(placeholder) {
// Emit a small marker followed by the image so the LLM sees placement
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("image");
let marker = format!("[image: {}]", filename);
ordered_items.push(InputItem::Text { text: marker });
ordered_items.push(InputItem::LocalImage { path });
} else {
// Unknown placeholder: preserve as text
ordered_items.push(InputItem::Text { text: placeholder.to_string() });
}
} else if placeholder.starts_with("[file:") {
if let Some(path) = self.pending_files.remove(placeholder) {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
// Always include a marker text
ordered_items.push(InputItem::Text { text: format!("[file: {}]", filename) });
// Inline small textual files; otherwise, include a short descriptor
if let Ok(meta) = std::fs::metadata(&path) {
let size = meta.len();
// Threshold ~64 KiB
let inline_limit: u64 = 64 * 1024;
if size <= inline_limit {
match std::fs::read(&path) {
Ok(bytes) => {
if let Ok(text) = String::from_utf8(bytes) {
// Detect language by extension for nicer fences
let lang = path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let mut block = String::new();
block.push_str(&format!("\n```{}\n", lang));
block.push_str(&text);
if !text.ends_with('\n') { block.push('\n'); }
block.push_str("```\n");
ordered_items.push(InputItem::Text { text: block });
} else {
ordered_items.push(InputItem::Text { text: format!("(binary or non-UTF8 file, {} bytes; not inlined)", size) });
}
}
Err(e) => {
ordered_items.push(InputItem::Text { text: format!("(failed to read file: {})", e) });
}
}
} else {
ordered_items.push(InputItem::Text { text: format!("(large file, {} bytes; not inlined)", size) });
}
} else {
ordered_items.push(InputItem::Text { text: "(file not found at submit)".to_string() });
}
} else {
ordered_items.push(InputItem::Text { text: placeholder.to_string() });
}
} else {
// Unknown placeholder type; preserve
ordered_items.push(InputItem::Text { text: placeholder.to_string() });
}
cursor = mat.end();
}
// Push any remaining trailing text
if cursor < text.len() {
let chunk = &text[cursor..];
if !chunk.trim().is_empty() {
ordered_items.push(InputItem::Text { text: chunk.to_string() });
}
}
// Then check for direct file paths in the text
// Then check for direct file paths typed into the message (no placeholder).
// We conservatively append these at the end to avoid mis-ordering text.
// This keeps the behavior consistent while still including the image.
// We do NOT strip them from display_text so the user sees what they typed.
let words: Vec<String> = text.split_whitespace().map(String::from).collect();
for word in &words {
// Skip placeholders we already handled
if word.starts_with("[image:") {
continue;
if word.starts_with("[image:") { continue; }
let is_image_path = IMAGE_EXTENSIONS.iter().any(|ext| word.to_lowercase().ends_with(ext));
if !is_image_path { continue; }
let path = Path::new(word);
if path.exists() {
// Add a marker then the image so the LLM has contextual placement info
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("image");
ordered_items.push(InputItem::Text { text: format!("[image: {}]", filename) });
ordered_items.push(InputItem::LocalImage { path: path.to_path_buf() });
}
}
// Check if this looks like an image path
let is_image_path = IMAGE_EXTENSIONS
.iter()
.any(|ext| word.to_lowercase().ends_with(ext));
if is_image_path {
let path = Path::new(word);
// Check if it's a relative or absolute path that exists
if path.exists() {
image_paths.push(path.to_path_buf());
// Remove the path from the text
cleaned_text = cleaned_text.replace(word, "");
} else {
// Try with common relative paths
let potential_paths = vec![
PathBuf::from(word),
PathBuf::from("./").join(word),
std::env::current_dir()
.ok()
.map(|d| d.join(word))
.unwrap_or_default(),
];
for potential_path in potential_paths {
if potential_path.exists() {
image_paths.push(potential_path);
cleaned_text = cleaned_text.replace(word, "");
break;
// Also handle non-image direct file paths by appending a marker and an inline preview (if small text)
for word in &words {
if word.starts_with("[file:") || word.starts_with("[image:") { continue; }
// Heuristic: treat anything that looks like a path with an extension as a candidate
let looks_like_path = word.contains('/') || word.contains('\\');
let has_ext = std::path::Path::new(word).extension().is_some();
if !(looks_like_path && has_ext) { continue; }
let path = Path::new(word);
if path.exists() && path.is_file() {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
ordered_items.push(InputItem::Text { text: format!("[file: {}]", filename) });
if let Ok(meta) = std::fs::metadata(path) {
let size = meta.len();
let inline_limit: u64 = 64 * 1024;
if size <= inline_limit {
match std::fs::read(path) {
Ok(bytes) => {
if let Ok(text) = String::from_utf8(bytes) {
let lang = path.extension().and_then(|s| s.to_str()).unwrap_or("");
let mut block = String::new();
block.push_str(&format!("\n```{}\n", lang));
block.push_str(&text);
if !text.ends_with('\n') { block.push('\n'); }
block.push_str("```\n");
ordered_items.push(InputItem::Text { text: block });
} else {
ordered_items.push(InputItem::Text { text: format!("(binary or non-UTF8 file, {} bytes; not inlined)", size) });
}
}
Err(e) => ordered_items.push(InputItem::Text { text: format!("(failed to read file: {})", e) }),
}
} else {
ordered_items.push(InputItem::Text { text: format!("(large file, {} bytes; not inlined)", size) });
}
}
}
@@ -1353,8 +1454,8 @@ impl ChatWidget<'_> {
// - Normalize CRLF -> LF
// - Trim trailing spaces per line
// - Remove any completely blank lines at the start and end
cleaned_text = cleaned_text.replace("\r\n", "\n");
let mut _lines_tmp: Vec<String> = cleaned_text
display_text = display_text.replace("\r\n", "\n");
let mut _lines_tmp: Vec<String> = display_text
.lines()
.map(|l| l.trim_end().to_string())
.collect();
@@ -1364,11 +1465,11 @@ impl ChatWidget<'_> {
while _lines_tmp.last().map_or(false, |s| s.trim().is_empty()) {
_lines_tmp.pop();
}
cleaned_text = _lines_tmp.join("\n");
display_text = _lines_tmp.join("\n");
UserMessage {
text: cleaned_text,
image_paths,
display_text,
ordered_items,
}
}
@@ -1556,6 +1657,7 @@ impl ChatWidget<'_> {
// Text will be properly wrapped when displayed based on terminal width
live_builder: RowBuilder::new(usize::MAX),
pending_images: HashMap::new(),
pending_files: HashMap::new(),
welcome_shown: false,
latest_browser_screenshot: Arc::new(Mutex::new(None)),
cached_image_protocol: RefCell::new(None),
@@ -1589,9 +1691,12 @@ impl ChatWidget<'_> {
height_manager: RefCell::new(HeightManager::new(
crate::height_manager::HeightManagerConfig::default(),
)),
last_hud_present: std::cell::Cell::new(false),
last_hud_present: std::cell::Cell::new(false),
prefix_sums: std::cell::RefCell::new(Vec::new()),
vertical_scrollbar_state: std::cell::RefCell::new(ScrollbarState::default()),
last_prefix_width: std::cell::Cell::new(0),
last_prefix_count: std::cell::Cell::new(0),
prefix_valid: std::cell::Cell::new(false),
vertical_scrollbar_state: std::cell::RefCell::new(ScrollbarState::default()),
scrollbar_visible_until: std::cell::Cell::new(None),
last_theme: crate::theme::current_theme(),
perf_enabled: false,
@@ -1666,6 +1771,7 @@ impl ChatWidget<'_> {
running_web_search: HashMap::new(),
live_builder: RowBuilder::new(usize::MAX),
pending_images: HashMap::new(),
pending_files: HashMap::new(),
welcome_shown: false,
latest_browser_screenshot: Arc::new(Mutex::new(None)),
cached_image_protocol: RefCell::new(None),
@@ -1699,9 +1805,12 @@ impl ChatWidget<'_> {
height_manager: RefCell::new(HeightManager::new(
crate::height_manager::HeightManagerConfig::default(),
)),
last_hud_present: std::cell::Cell::new(false),
last_hud_present: std::cell::Cell::new(false),
prefix_sums: std::cell::RefCell::new(Vec::new()),
vertical_scrollbar_state: std::cell::RefCell::new(ScrollbarState::default()),
last_prefix_width: std::cell::Cell::new(0),
last_prefix_count: std::cell::Cell::new(0),
prefix_valid: std::cell::Cell::new(false),
vertical_scrollbar_state: std::cell::RefCell::new(ScrollbarState::default()),
scrollbar_visible_until: std::cell::Cell::new(None),
last_theme: crate::theme::current_theme(),
perf_enabled: false,
@@ -2088,6 +2197,17 @@ impl ChatWidget<'_> {
} else {
tracing::warn!("Image path does not exist: {:?}", path);
}
} else {
// Handle non-image file drop/paste by inserting a [file: name] token
let path = PathBuf::from(&path_str);
if path.exists() && path.is_file() {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
let placeholder = format!("[file: {}]", filename);
self.pending_files.insert(placeholder.clone(), path);
self.bottom_pane.handle_paste(placeholder);
self.request_redraw();
return;
}
}
}
@@ -2250,11 +2370,13 @@ impl ChatWidget<'_> {
fn submit_user_message(&mut self, user_message: UserMessage) {
// Fade the welcome cell only when a user actually posts a message.
for cell in &self.history_cells { cell.trigger_fade(); }
let UserMessage { text, image_paths } = user_message;
// Keep the original text for display purposes
let original_text = text.clone();
let mut actual_text = text;
let UserMessage { display_text, mut ordered_items } = user_message;
let original_text = display_text.clone();
// Build a combined string view of the text-only parts to process slash commands
let mut text_only = String::new();
for it in &ordered_items {
if let InputItem::Text { text } = it { if !text_only.is_empty() { text_only.push('\n'); } text_only.push_str(text); }
}
// Save the prompt if it's a multi-agent command
let original_trimmed = original_text.trim();
@@ -2266,16 +2388,17 @@ impl ChatWidget<'_> {
}
// Process slash commands and expand them if needed
let processed = crate::slash_command::process_slash_command_message(&original_text);
let processed = crate::slash_command::process_slash_command_message(&text_only);
match processed {
crate::slash_command::ProcessedCommand::ExpandedPrompt(expanded) => {
// Replace the slash command with the expanded prompt for the LLM
actual_text = expanded;
// Replace the message with the expanded prompt
ordered_items.clear();
ordered_items.push(InputItem::Text { text: expanded });
}
crate::slash_command::ProcessedCommand::RegularCommand(cmd, _args) => {
// This is a regular slash command, dispatch it normally
self.app_event_tx
.send(AppEvent::DispatchCommand(cmd, actual_text.clone()));
.send(AppEvent::DispatchCommand(cmd, original_text.clone()));
return;
}
crate::slash_command::ProcessedCommand::Error(error_msg) => {
@@ -2425,16 +2548,8 @@ impl ChatWidget<'_> {
tracing::info!("Browser is not enabled, skipping screenshot capture");
}
if !actual_text.is_empty() {
items.push(InputItem::Text {
text: actual_text.clone(),
});
}
// Add user-provided images (these are persistent in history)
for path in image_paths {
items.push(InputItem::LocalImage { path });
}
// Use the ordered items (text + images interleaved with markers)
items.extend(ordered_items);
if items.is_empty() {
return;
@@ -6474,6 +6589,7 @@ impl WidgetRef for &ChatWidget<'_> {
if self.height_cache_last_width.get() != content_area.width {
self.height_cache.borrow_mut().clear();
self.prefix_sums.borrow_mut().clear();
self.prefix_valid.set(false);
self.height_cache_last_width.set(content_area.width);
}
@@ -6483,11 +6599,18 @@ impl WidgetRef for &ChatWidget<'_> {
p.frames = p.frames.saturating_add(1);
}
let total_height: u16 = {
// Detect dynamic content that requires per-frame recomputation
let has_active_animation_early = self.history_cells.iter().any(|cell| cell.is_animating());
let must_rebuild_prefix = !self.prefix_valid.get()
|| self.last_prefix_width.get() != content_area.width
|| self.last_prefix_count.get() != all_content.len()
|| streaming_cell.is_some()
|| has_active_animation_early;
let total_height: u16 = if must_rebuild_prefix {
let perf_enabled = self.perf_enabled;
let total_start = if perf_enabled { Some(std::time::Instant::now()) } else { None };
let mut ps = self.prefix_sums.borrow_mut();
// Always rebuild to account for height changes without item count changes
ps.clear();
ps.push(0);
let mut acc = 0u16;
@@ -6552,12 +6675,19 @@ impl WidgetRef for &ChatWidget<'_> {
}
let total = *ps.last().unwrap_or(&0);
if let Some(start) = total_start {
if perf_enabled {
if self.perf_enabled {
let mut p = self.perf.borrow_mut();
p.ns_total_height = p.ns_total_height.saturating_add(start.elapsed().as_nanos());
}
}
// Update cache keys
self.last_prefix_width.set(content_area.width);
self.last_prefix_count.set(all_content.len());
self.prefix_valid.set(true);
total
} else {
// Use cached prefix sums
*self.prefix_sums.borrow().last().unwrap_or(&0)
};
// Check for active animations using the trait method
@@ -6761,7 +6891,13 @@ impl WidgetRef for &ChatWidget<'_> {
Some(_) => crate::colors::error(),
}
} else {
crate::colors::info()
// Handle merged exec cells (multi-block "Ran") the same as single execs
match item.kind() {
crate::history_cell::HistoryCellType::Exec { kind: crate::history_cell::ExecKind::Run, status: crate::history_cell::ExecStatus::Success } => ratatui::style::Color::Black,
crate::history_cell::HistoryCellType::Exec { kind: crate::history_cell::ExecKind::Run, status: crate::history_cell::ExecStatus::Error } => crate::colors::error(),
crate::history_cell::HistoryCellType::Exec { .. } => crate::colors::info(),
_ => crate::colors::info(),
}
}
} else if symbol == "↯" {
// Patch/Updated arrow color – match the header text color
File diff suppressed because it is too large Load Diff
+12 -14
View File
@@ -1041,15 +1041,15 @@ fn parse_blockquotes(lines: &[&str]) -> Option<(usize, Vec<Line<'static>>)> {
}
}
// Render the quote content using a nested MarkdownRenderer so lists/code inside
// the quote are fully supported.
let mut inner = MarkdownRenderer::new();
inner.process_text(&content);
inner.finish();
let lines_to_render = if inner.lines.is_empty() {
// Render the quote content as raw literal text without interpreting
// Markdown syntax inside the blockquote. This preserves the exact
// characters shown by the model (e.g., `**bold**`, lists, images)
// rather than re‑parsing them. Each input line corresponds to a
// single rendered line of content.
let lines_to_render = if content.is_empty() {
vec![Line::from("")]
} else {
inner.lines
vec![Line::from(Span::raw(content.clone()))]
};
let bar_style = if callout_kind.is_some() {
@@ -1074,13 +1074,11 @@ fn parse_blockquotes(lines: &[&str]) -> Option<(usize, Vec<Line<'static>>)> {
.spans
.into_iter()
.map(|s| {
if let Some(fg) = s.style.fg {
if fg == crate::colors::function() {
let mut st = s.style;
st.fg = Some(crate::colors::mix_toward(fg, content_fg, 0.30));
return Span::styled(s.content, st);
}
return s;
if let Some(_fg) = s.style.fg {
// Preserve explicit colors (e.g., code spans) even though we
// no longer parse markdown inside quotes. If a span already has
// an FG, keep it as-is.
s
} else {
let mut st = s.style;
st.fg = Some(content_fg);