mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
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:
+103
-77
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(¶ms.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 {
|
||||
|
||||
@@ -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))??;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+411
-517
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user