chore: commit local changes before sync

This commit is contained in:
James Peter
2025-08-29 13:42:37 +10:00
parent f20bffe1a6
commit 75bb264345
8 changed files with 369 additions and 97 deletions
+2 -2
View File
@@ -5,6 +5,7 @@ use std::time::Duration;
use bytes::Bytes;
use codex_login::AuthManager;
use codex_login::AuthMode;
use codex_protocol::models::ResponseItem;
use eventsource_stream::Eventsource;
use futures::prelude::*;
use reqwest::StatusCode;
@@ -18,7 +19,6 @@ use tracing::debug;
use tracing::trace;
use tracing::warn;
use uuid::Uuid;
use codex_protocol::models::ResponseItem;
use crate::chat_completions::AggregateStreamExt;
use crate::chat_completions::stream_chat_completions;
@@ -174,7 +174,7 @@ impl ModelClient {
let full_instructions = prompt.get_full_instructions(&self.config.model_family);
let mut tools_json = create_tools_json_for_responses_api(&prompt.tools)?;
// ChatGPT backend expects the preview name for web search.
// ChatGPT backend currently expects the preview tool name.
if auth_mode == Some(AuthMode::ChatGPT) {
for tool in &mut tools_json {
if let Some(map) = tool.as_object_mut() {
+22 -10
View File
@@ -1561,18 +1561,22 @@ async fn submission_loop(
}
}
let default_shell = shell::default_user_shell().await;
let mut tools_config = ToolsConfig::new(
&config.model_family,
approval_policy,
sandbox_policy.clone(),
config.include_plan_tool,
config.include_apply_patch_tool,
config.tools_web_search_request,
config.use_experimental_streamable_shell_tool,
config.include_view_image_tool,
);
tools_config.web_search_allowed_domains =
config.tools_web_search_allowed_domains.clone();
sess = Some(Arc::new(Session {
client,
tools_config: ToolsConfig::new(
&config.model_family,
approval_policy,
sandbox_policy.clone(),
config.include_plan_tool,
config.include_apply_patch_tool,
config.tools_web_search_request,
config.use_experimental_streamable_shell_tool,
config.include_view_image_tool,
),
tools_config,
tx_event: tx_event.clone(),
user_instructions,
base_instructions,
@@ -2475,6 +2479,14 @@ async fn run_compact_agent(
}),
};
sess.send_event(event).await;
// Ensure the UI is released from running state even on errors.
let done = Event {
id: sub_id.clone(),
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
};
sess.send_event(done).await;
return;
}
}
+21 -2
View File
@@ -175,6 +175,8 @@ pub struct Config {
pub include_apply_patch_tool: bool,
/// Enable the native Responses web_search tool.
pub tools_web_search_request: bool,
/// Optional allow-list of domains for web_search filters.allowed_domains
pub tools_web_search_allowed_domains: Option<Vec<String>>,
/// Experimental: enable streamable shell tool selection (off by default).
pub use_experimental_streamable_shell_tool: bool,
/// Enable the `view_image` tool that lets the agent attach local images.
@@ -563,6 +565,15 @@ pub struct ToolsToml {
#[serde(default, alias = "web_search_request")]
pub web_search: Option<bool>,
/// Optional allow-list of domains used by the Responses API web_search tool.
/// Example:
///
/// [tools]
/// web_search = true
/// web_search_allowed_domains = ["openai.com", "arxiv.org"]
#[serde(default)]
pub web_search_allowed_domains: Option<Vec<String>>,
/// Enable the `view_image` tool that lets the agent attach local images.
#[serde(default)]
pub view_image: Option<bool>,
@@ -749,6 +760,10 @@ impl Config {
let tools_web_search_request = override_tools_web_search_request
.or(cfg.tools.as_ref().and_then(|t| t.web_search))
.unwrap_or(false);
let tools_web_search_allowed_domains = cfg
.tools
.as_ref()
.and_then(|t| t.web_search_allowed_domains.clone());
// View Image tool is enabled by default; can be disabled in config.
let include_view_image_tool_flag = cfg
.tools
@@ -862,6 +877,7 @@ impl Config {
include_plan_tool: include_plan_tool.unwrap_or(false),
include_apply_patch_tool: false,
tools_web_search_request,
tools_web_search_allowed_domains,
use_experimental_streamable_shell_tool: false,
include_view_image_tool: include_view_image_tool_flag,
responses_originator_header,
@@ -1260,6 +1276,7 @@ disable_response_storage = true
include_plan_tool: false,
include_apply_patch_tool: false,
tools_web_search_request: false,
tools_web_search_allowed_domains: None,
use_experimental_streamable_shell_tool: false,
responses_originator_header: "codex_cli_rs".to_string(),
debug: false,
@@ -1317,6 +1334,7 @@ disable_response_storage = true
include_plan_tool: false,
include_apply_patch_tool: false,
tools_web_search_request: false,
tools_web_search_allowed_domains: None,
use_experimental_streamable_shell_tool: false,
responses_originator_header: "codex_cli_rs".to_string(),
debug: false,
@@ -1388,8 +1406,9 @@ disable_response_storage = true
base_instructions: None,
include_plan_tool: false,
include_apply_patch_tool: false,
tools_web_search_request: false,
use_experimental_streamable_shell_tool: false,
tools_web_search_request: false,
tools_web_search_allowed_domains: None,
use_experimental_streamable_shell_tool: false,
responses_originator_header: "codex_cli_rs".to_string(),
debug: false,
using_chatgpt_auth: false,
+28 -6
View File
@@ -53,14 +53,26 @@ pub(crate) enum OpenAiTool {
Function(ResponsesApiTool),
#[serde(rename = "local_shell")]
LocalShell {},
// TODO: Understand why we get an error on web_search although the API docs say it's supported.
// https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C
#[serde(rename = "web_search_preview")]
WebSearch {},
/// Native Responses API web search tool. Optional fields like `filters`
/// are serialized alongside the type discriminator.
#[serde(rename = "web_search")]
WebSearch(WebSearchTool),
#[serde(rename = "custom")]
Freeform(FreeformTool),
}
#[derive(Debug, Clone, Serialize, PartialEq, Default)]
pub struct WebSearchTool {
#[serde(skip_serializing_if = "Option::is_none")]
pub filters: Option<WebSearchFilters>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Default)]
pub struct WebSearchFilters {
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_domains: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub enum ConfigShellToolType {
DefaultShell,
@@ -78,6 +90,7 @@ pub(crate) struct ToolsConfig {
pub web_search_request: bool,
#[allow(dead_code)]
pub include_view_image_tool: bool,
pub web_search_allowed_domains: Option<Vec<String>>,
}
#[allow(dead_code)]
@@ -131,6 +144,7 @@ impl ToolsConfig {
apply_patch_tool_type,
web_search_request: include_web_search_request,
include_view_image_tool,
web_search_allowed_domains: None,
}
}
@@ -583,7 +597,15 @@ pub(crate) fn get_openai_tools(
tools.push(create_list_agents_tool());
if config.web_search_request {
tools.push(OpenAiTool::WebSearch {});
let tool = match &config.web_search_allowed_domains {
Some(domains) if !domains.is_empty() => OpenAiTool::WebSearch(WebSearchTool {
filters: Some(WebSearchFilters {
allowed_domains: Some(domains.clone()),
}),
}),
_ => OpenAiTool::WebSearch(WebSearchTool::default()),
};
tools.push(tool);
}
// Always include web_fetch tool
@@ -623,7 +645,7 @@ mod tests {
.map(|tool| match tool {
OpenAiTool::Function(ResponsesApiTool { name, .. }) => name,
OpenAiTool::LocalShell {} => "local_shell",
OpenAiTool::WebSearch {} => "web_search",
OpenAiTool::WebSearch(_) => "web_search",
OpenAiTool::Freeform(FreeformTool { name, .. }) => name,
})
.collect::<Vec<_>>();
+54 -15
View File
@@ -433,6 +433,18 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
}
impl ChatWidget<'_> {
/// Hide the bottom spinner/status if the UI is idle (no streams, tools, agents, or tasks).
fn maybe_hide_spinner(&mut self) {
let any_tools_running = !self.running_commands.is_empty()
|| !self.running_custom_tools.is_empty()
|| !self.running_web_search.is_empty();
let any_streaming = self.stream.is_write_cycle_active();
let any_agents_active = !self.active_agents.is_empty() || self.agents_ready_to_start;
let any_tasks_active = !self.active_task_ids.is_empty();
if !(any_tools_running || any_streaming || any_agents_active || any_tasks_active) {
self.bottom_pane.set_task_running(false);
}
}
fn finalize_exec_cell_at(&mut self, idx: usize, exit_code: i32, stdout: String, stderr: String) {
if idx >= self.history_cells.len() { return; }
if let Some(exec) = self.history_cells[idx].as_any().downcast_ref::<history_cell::ExecCell>() {
@@ -528,6 +540,8 @@ impl ChatWidget<'_> {
if !any_tasks_active {
self.bottom_pane.set_task_running(false);
}
// Re-check global idle state
self.maybe_hide_spinner();
}
fn finalize_all_running_due_to_answer(&mut self) {
@@ -1074,6 +1088,8 @@ impl ChatWidget<'_> {
self.bottom_pane
.update_status_text(format!("command failed (exit {})", exit_code));
}
// Drop spinner if we're now idle
self.maybe_hide_spinner();
}
/// If a completed exec cell sits at `idx`, attempt to merge it into the
@@ -1085,7 +1101,7 @@ impl ChatWidget<'_> {
}
// Helper to compute the header label used by exec cells
let exec_label = |e: &history_cell::ExecCell| -> &'static str {
let _exec_label = |e: &history_cell::ExecCell| -> &'static str {
let action = history_cell::action_from_parsed(&e.parsed);
match (&e.output, action) {
(None, "read") => "Reading...",
@@ -1099,7 +1115,7 @@ impl ChatWidget<'_> {
_ => "",
}
};
let is_joinable_label = |s: &str| matches!(s, "Searched" | "Read" | "Listed" | "Ran" | "Reading..." | "Searching..." | "Listing..." | "Running...");
let _is_joinable_label = |s: &str| matches!(s, "Searched" | "Read" | "Listed" | "Ran" | "Reading..." | "Searching..." | "Listing..." | "Running...");
// New cell must be an ExecCell with completed output
let new_exec = match self.history_cells[idx]
@@ -1109,10 +1125,16 @@ impl ChatWidget<'_> {
Some(e) if e.output.is_some() => e,
_ => return,
};
let new_label = exec_label(new_exec);
if new_label.is_empty() || !is_joinable_label(new_label) {
return;
}
// Compare kinds rather than labels to avoid string drift
let to_kind = |e: &history_cell::ExecCell| -> history_cell::ExecKind {
match history_cell::action_from_parsed(&e.parsed) {
"read" => history_cell::ExecKind::Read,
"search" => history_cell::ExecKind::Search,
"list" => history_cell::ExecKind::List,
_ => history_cell::ExecKind::Run,
}
};
let new_kind = to_kind(new_exec);
// Case 1: previous is also a completed ExecCell with same header -> merge into MergedExecCell preserving styling
if let Some(prev_exec) = self.history_cells[idx - 1]
@@ -1120,8 +1142,7 @@ impl ChatWidget<'_> {
.downcast_ref::<history_cell::ExecCell>()
{
if prev_exec.output.is_some() {
let last_label = exec_label(prev_exec);
if last_label == new_label && !last_label.is_empty() {
if to_kind(prev_exec) == new_kind {
let mut merged = history_cell::MergedExecCell::from_exec(prev_exec);
if let Some(current_exec) = self.history_cells[idx].as_any().downcast_ref::<history_cell::ExecCell>() {
merged.push_exec(current_exec);
@@ -1149,13 +1170,7 @@ impl ChatWidget<'_> {
.as_any_mut()
.downcast_mut::<history_cell::MergedExecCell>()
{
let merged_label = match prev_merged.exec_kind() {
history_cell::ExecKind::Read => "Read",
history_cell::ExecKind::Search => "Searched",
history_cell::ExecKind::List => "Listed",
history_cell::ExecKind::Run => "Ran",
};
if new_label == merged_label {
if prev_merged.exec_kind() == new_kind {
if let Some(current_exec) = right[0]
.as_any()
.downcast_ref::<history_cell::ExecCell>()
@@ -1255,6 +1270,8 @@ impl ChatWidget<'_> {
} else {
self.add_to_history(history_cell::new_patch_apply_failure(ev.stderr));
}
// After patch application completes, re-evaluate idle state
self.maybe_hide_spinner();
}
/// Get or create the global browser manager
@@ -1377,6 +1394,9 @@ impl ChatWidget<'_> {
self.bottom_pane.set_task_running(false);
self.running_commands.clear();
self.stream.clear_all();
self.agents_ready_to_start = false;
self.active_task_ids.clear();
self.maybe_hide_spinner();
self.mark_needs_redraw();
}
@@ -1396,6 +1416,10 @@ impl ChatWidget<'_> {
self.live_builder = RowBuilder::new(usize::MAX);
// Stream state is now managed by StreamController
self.content_buffer.clear();
// Defensive: clear transient flags so UI can quiesce
self.agents_ready_to_start = false;
self.active_task_ids.clear();
self.maybe_hide_spinner();
self.request_redraw();
}
}
@@ -2582,6 +2606,12 @@ impl ChatWidget<'_> {
self.last_assistant_message = Some(message);
// Mark this id closed for further answer deltas in this turn
self.closed_answer_ids.insert(id.clone());
// Defensive: this turn's task should be considered complete for UI purposes
self.active_task_ids.remove(&id);
// Defensive: clear transient agents-preparing state unless we see real updates
self.agents_ready_to_start = false;
// Possibly drop spinner if everything is idle now
self.maybe_hide_spinner();
self.mark_needs_redraw();
}
EventMsg::ReplayHistory(ev) => {
@@ -2678,6 +2708,8 @@ impl ChatWidget<'_> {
}
// Update status to reflect we're done searching
self.bottom_pane.update_status_text("responding".to_string());
// Re-evaluate spinner visibility
self.maybe_hide_spinner();
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
tracing::debug!("AgentMessageDelta: {:?}", delta);
@@ -2756,6 +2788,10 @@ impl ChatWidget<'_> {
}
// Remove this id from the active set (it may be a sub‑agent)
self.active_task_ids.remove(&id);
// Defensive: clear transient agents-preparing state
self.agents_ready_to_start = false;
// Convert any lingering running exec/tool cells to completed so the UI doesn't hang
self.finalize_all_running_due_to_answer();
// Mark any running web searches as completed
if !self.running_web_search.is_empty() {
// Replace each running web search cell in-place with a completed one
@@ -2818,6 +2854,8 @@ impl ChatWidget<'_> {
self.bottom_pane.set_task_running(false);
}
self.current_stream_kind = None;
// Final re-check for idle state
self.maybe_hide_spinner();
self.mark_needs_redraw();
}
EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
@@ -3030,6 +3068,7 @@ impl ChatWidget<'_> {
// After tool completes, likely transitioning to response
self.bottom_pane.update_status_text("responding".to_string());
self.maybe_hide_spinner();
}
EventMsg::GetHistoryEntryResponse(event) => {
let codex_core::protocol::GetHistoryEntryResponseEvent {
+119 -44
View File
@@ -498,9 +498,10 @@ impl HistoryCell for AssistantMarkdownCell {
// - One top padding row for the cell (+1)
// - Fenced code blocks: exclude language sentinel; trim blank padding lines;
// add 4 rows for borders + inner pads
let inner_width = width.saturating_sub(2);
// Use full width for assistant text wrapping so bullets don't wrap early.
let text_wrap_width = width;
let mut all_lines = self.display_lines_trimmed();
if inner_width > 4 {
if text_wrap_width > 4 {
let mut transformed: Vec<Line<'static>> = Vec::new();
let mut is_first_output_line = true;
for mut line in all_lines.into_iter() {
@@ -510,7 +511,7 @@ impl HistoryCell for AssistantMarkdownCell {
}
if is_horizontal_rule_line(&line) {
transformed.push(Line::from(Span::styled(
std::iter::repeat('─').take(inner_width as usize).collect::<String>(),
std::iter::repeat('─').take(text_wrap_width as usize).collect::<String>(),
Style::default().fg(crate::colors::assistant_hr()),
)));
continue;
@@ -526,7 +527,7 @@ impl HistoryCell for AssistantMarkdownCell {
out_spans.extend(spans.drain(i..));
transformed.push(ratatui::text::Line::from(out_spans));
} else {
transformed.extend(wrap_bullet_line(line, indent_spaces, &bullet_char, inner_width));
transformed.extend(wrap_bullet_line(line, indent_spaces, &bullet_char, text_wrap_width));
}
} else {
transformed.push(line);
@@ -555,7 +556,7 @@ impl HistoryCell for AssistantMarkdownCell {
let text = Text::from(all_lines[start..i].to_vec());
let rows: u16 = Paragraph::new(text)
.wrap(Wrap { trim: false })
.line_count(inner_width)
.line_count(text_wrap_width)
.try_into()
.unwrap_or(0);
total = total.saturating_add(rows);
@@ -581,8 +582,8 @@ impl HistoryCell for AssistantMarkdownCell {
// and wrapping non-code text with bullet awareness. We'll render in
// segments so fenced code can be drawn inside a background container.
let mut all_lines = self.display_lines_trimmed();
let inner_width = area.width.saturating_sub(2);
if inner_width > 4 {
let text_wrap_width = area.width;
if text_wrap_width > 4 {
let mut transformed: Vec<Line<'static>> = Vec::new();
let mut is_first_output_line = true;
for mut line in all_lines.into_iter() {
@@ -593,7 +594,7 @@ impl HistoryCell for AssistantMarkdownCell {
if is_horizontal_rule_line(&line) {
let hr = Line::from(Span::styled(
std::iter::repeat('─')
.take(inner_width as usize)
.take(text_wrap_width as usize)
.collect::<String>(),
Style::default().fg(crate::colors::assistant_hr()),
));
@@ -623,7 +624,7 @@ impl HistoryCell for AssistantMarkdownCell {
line,
indent_spaces,
&bullet_char,
inner_width,
text_wrap_width,
));
}
} else {
@@ -688,7 +689,7 @@ impl HistoryCell for AssistantMarkdownCell {
let txt = Text::from(lines.clone());
let total: u16 = Paragraph::new(txt.clone())
.wrap(Wrap { trim: false })
.line_count(inner_width)
.line_count(text_wrap_width)
.try_into()
.unwrap_or(0);
if *skip >= total {
@@ -698,7 +699,7 @@ impl HistoryCell for AssistantMarkdownCell {
let avail = end_y.saturating_sub(*y);
let draw_h = (total.saturating_sub(*skip)).min(avail);
if draw_h == 0 { return; }
let rect = Rect { x: area.x, y: *y, width: inner_width, height: draw_h };
let rect = Rect { x: area.x, y: *y, width: area.width, height: draw_h };
Paragraph::new(txt)
.block(Block::default().style(bg_style))
.wrap(Wrap { trim: false })
@@ -723,7 +724,7 @@ impl HistoryCell for AssistantMarkdownCell {
// Determine target width for the code card (content width) and add borders (2) + inner pads (2)
let max_w = lines.iter().map(|l| measure_line(l)).max().unwrap_or(0) as u16;
let inner_w = max_w.max(1);
let card_w = inner_w.saturating_add(4).min(inner_width.max(4));
let card_w = inner_w.saturating_add(4).min(area.width.max(4));
let total = lines.len() as u16 + 4; // top/bottom border + inner padding rows
if *skip >= total { *skip -= total; return; }
let avail = end_y.saturating_sub(*y);
@@ -741,7 +742,7 @@ impl HistoryCell for AssistantMarkdownCell {
if draw_h == 0 { return; }
// Compute optional outer horizontal padding (1 col left/right) inside content area
let content_x = area.x;
let content_w = inner_width;
let content_w = area.width;
let outer_needed = card_w.saturating_add(2);
let (rect_x, left_pad, right_pad) = if content_w >= outer_needed {
(content_x.saturating_add(1), true, true)
@@ -852,6 +853,26 @@ impl HistoryCell for ExecCell {
fn has_custom_render(&self) -> bool {
true
}
fn desired_height(&self, width: u16) -> u16 {
// Measure exactly like custom_render_with_skip: preamble at full width,
// output inside a left-bordered block with left padding (width - 2).
let (pre_lines, out_lines) = self.exec_render_parts();
let pre_text = Text::from(trim_empty_lines(pre_lines));
let out_text = Text::from(trim_empty_lines(out_lines));
let pre_wrap_width = width;
let out_wrap_width = width.saturating_sub(2);
let pre_total: u16 = Paragraph::new(pre_text)
.wrap(Wrap { trim: false })
.line_count(pre_wrap_width)
.try_into()
.unwrap_or(0);
let out_total: u16 = Paragraph::new(out_text)
.wrap(Wrap { trim: false })
.line_count(out_wrap_width)
.try_into()
.unwrap_or(0);
pre_total.saturating_add(out_total)
}
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
// Render command header/content above and stdout/stderr preview inside a left-bordered block.
let (pre_lines, out_lines) = self.exec_render_parts();
@@ -1168,27 +1189,80 @@ impl MergedExecCell {
return None;
}
use ratatui::text::Span;
// Concatenate per-segment preambles (without their headers), ensure
// the first line has the leading connector, then coalesce contiguous
// ranges using the same logic as single-exec rendering.
let mut all: Vec<Line<'static>> = Vec::new();
for (i, (pre_raw, _)) in self.segments.iter().enumerate() {
let mut pre = trim_empty_lines(pre_raw.clone());
if !pre.is_empty() { pre.remove(0); }
if i == 0 {
if let Some(first) = pre.first_mut() {
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
let already = flat.trim_start().starts_with("└ ") || flat.trim_start().starts_with(" └ ");
if !already {
first.spans.insert(0, Span::styled("└ ", Style::default().fg(crate::colors::text_dim())));
// Concatenate per-segment preambles (without their headers), but KEEP ONLY
// read-like entries. Then normalize the connector so only the very first
// visible line uses "└ ", and subsequent lines use two spaces.
// Finally, coalesce contiguous ranges for the same file.
// Local helper: parse a read range line of the form
// "└ <file> (lines A to B)" or " <file> (lines A to B)".
fn parse_read_line(line: &Line<'_>) -> Option<(String, u32, u32)> {
if line.spans.is_empty() { return None; }
let first = line.spans[0].content.as_ref();
if !(first == "└ " || first == " ") { return None; }
let rest: String = line.spans.iter().skip(1).map(|s| s.content.as_ref()).collect();
if let Some(idx) = rest.rfind(" (lines ") {
let fname = rest[..idx].to_string();
let tail = &rest[idx + 1..];
if tail.starts_with("(lines ") && tail.ends_with(")") {
let inner = &tail[7..tail.len().saturating_sub(1)];
if let Some((a, b)) = inner.split_once(" to ") {
if let (Ok(s), Ok(e)) = (a.trim().parse::<u32>(), b.trim().parse::<u32>()) {
return Some((fname, s, e));
}
}
}
}
all.extend(pre);
None
}
// Merge adjacent ranges like "app.rs (lines 1 to 200)" + "app.rs (lines 200 to 400)"
coalesce_read_ranges_in_lines_local(&mut all);
Some(all)
// Heuristic: identify search-like lines (e.g., "… in dir/" or " (in dir)") so
// they can be dropped from a Read aggregation if they slipped in.
fn is_search_like(line: &Line<'_>) -> bool {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let t = text.trim();
t.contains(" (in ") || t.rsplit_once(" in ").map(|(_, rhs)| rhs.trim_end().ends_with('/')).unwrap_or(false)
}
let mut kept: Vec<Line<'static>> = Vec::new();
for (seg_idx, (pre_raw, _)) in self.segments.iter().enumerate() {
let mut pre = trim_empty_lines(pre_raw.clone());
if !pre.is_empty() { pre.remove(0); } // drop per-exec header
// Filter: keep definite read-range lines; drop obvious search-like lines.
for l in pre.into_iter() {
if is_search_like(&l) { continue; }
// Prefer lines that parse as read ranges; otherwise allow if they are not search-like.
let keep = parse_read_line(&l).is_some() || seg_idx == 0; // be permissive for first segment
if !keep { continue; }
kept.push(l);
}
}
if kept.is_empty() { return Some(kept); }
// Normalize connector: first visible line uses "└ ", later lines use " ".
if let Some(first) = kept.first_mut() {
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
let has_connector = flat.trim_start().starts_with("└ ");
if !has_connector {
first
.spans
.insert(0, Span::styled("└ ", Style::default().fg(crate::colors::text_dim())));
}
}
for l in kept.iter_mut().skip(1) {
if let Some(sp0) = l.spans.get_mut(0) {
if sp0.content.as_ref() == "└ " {
sp0.content = " ".into();
// Keep dim styling for alignment consistency
sp0.style = sp0.style.add_modifier(Modifier::DIM);
}
}
}
// Merge adjacent/overlapping ranges in-place
coalesce_read_ranges_in_lines_local(&mut kept);
Some(kept)
}
}
@@ -2572,9 +2646,10 @@ impl HistoryCell for StreamingContentCell {
}
fn desired_height(&self, width: u16) -> u16 {
// Match streaming render path, including bullet wrapping and HR expansion.
let inner_width = width.saturating_sub(2);
// Use the full available width for text to avoid premature wraps.
let text_wrap_width = width;
let mut all_lines = self.display_lines_trimmed();
if inner_width > 4 {
if text_wrap_width > 4 {
let mut transformed: Vec<Line<'static>> = Vec::new();
let mut is_first_output_line = true;
for mut line in all_lines.into_iter() {
@@ -2584,7 +2659,7 @@ impl HistoryCell for StreamingContentCell {
}
if is_horizontal_rule_line(&line) {
transformed.push(Line::from(Span::styled(
std::iter::repeat('─').take(inner_width as usize).collect::<String>(),
std::iter::repeat('─').take(text_wrap_width as usize).collect::<String>(),
Style::default().fg(crate::colors::assistant_hr()),
)));
continue;
@@ -2600,7 +2675,7 @@ impl HistoryCell for StreamingContentCell {
out_spans.extend(spans.drain(i..));
transformed.push(ratatui::text::Line::from(out_spans));
} else {
transformed.extend(wrap_bullet_line(line, indent_spaces, &bullet_char, inner_width));
transformed.extend(wrap_bullet_line(line, indent_spaces, &bullet_char, text_wrap_width));
}
} else {
transformed.push(line);
@@ -2627,7 +2702,7 @@ impl HistoryCell for StreamingContentCell {
let text = Text::from(all_lines[start..i].to_vec());
let rows: u16 = Paragraph::new(text)
.wrap(Wrap { trim: false })
.line_count(inner_width)
.line_count(text_wrap_width)
.try_into()
.unwrap_or(0);
total = total.saturating_add(rows);
@@ -2650,8 +2725,8 @@ impl HistoryCell for StreamingContentCell {
// Prepare content lines (wrap non-code text only)
let mut all_lines = self.display_lines_trimmed();
let inner_width = area.width.saturating_sub(2);
if inner_width > 4 {
let text_wrap_width = area.width;
if text_wrap_width > 4 {
let mut transformed: Vec<Line<'static>> = Vec::new();
let mut is_first_output_line = true;
for mut line in all_lines.into_iter() {
@@ -2662,7 +2737,7 @@ impl HistoryCell for StreamingContentCell {
if is_horizontal_rule_line(&line) {
let hr = Line::from(Span::styled(
std::iter::repeat('─')
.take(inner_width as usize)
.take(text_wrap_width as usize)
.collect::<String>(),
Style::default().fg(crate::colors::assistant_hr()),
));
@@ -2687,7 +2762,7 @@ impl HistoryCell for StreamingContentCell {
line,
indent_spaces,
&bullet_char,
inner_width,
text_wrap_width,
));
}
} else {
@@ -2726,12 +2801,12 @@ impl HistoryCell for StreamingContentCell {
match seg {
Seg::Text(lines) => {
let txt = Text::from(lines.clone());
let total: u16 = Paragraph::new(txt.clone()).wrap(Wrap { trim: false }).line_count(inner_width).try_into().unwrap_or(0);
let total: u16 = Paragraph::new(txt.clone()).wrap(Wrap { trim: false }).line_count(text_wrap_width).try_into().unwrap_or(0);
if *skip >= total { *skip -= total; return; }
let avail = end_y.saturating_sub(*y);
let draw_h = (total.saturating_sub(*skip)).min(avail);
if draw_h == 0 { return; }
let rect = Rect { x: area.x, y: *y, width: inner_width, height: draw_h };
let rect = Rect { x: area.x, y: *y, width: area.width, height: draw_h };
Paragraph::new(txt).block(Block::default().style(bg_style)).wrap(Wrap { trim: false }).scroll((*skip, 0)).style(bg_style).render(rect, buf);
*y = y.saturating_add(draw_h); *skip = 0;
}
@@ -2747,11 +2822,11 @@ impl HistoryCell for StreamingContentCell {
}
}
if lines.is_empty() { return; }
// Determine target width of the code card (respect inner width)
// Determine target width of the code card (respect content width)
let max_w = lines.iter().map(|l| measure_line(l)).max().unwrap_or(0) as u16;
let inner_w = max_w.max(1);
// Borders (2) + inner left/right padding (2)
let card_w = inner_w.saturating_add(4).min(inner_width.max(4));
let card_w = inner_w.saturating_add(4).min(area.width.max(4));
// Include top/bottom border (2) + inner padding rows (2)
let total = lines.len() as u16 + 4;
if *skip >= total { *skip -= total; return; }
@@ -2768,7 +2843,7 @@ impl HistoryCell for StreamingContentCell {
let draw_h = visible.min(avail); if draw_h == 0 { return; }
// Compute optional outer horizontal padding (1 col left/right) inside content area
let content_x = area.x;
let content_w = inner_width;
let content_w = area.width;
let outer_needed = card_w.saturating_add(2);
let (rect_x, left_pad, right_pad) = if content_w >= outer_needed {
(content_x.saturating_add(1), true, true)
+122 -18
View File
@@ -1276,6 +1276,12 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
}
let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
// Slight blue-tinted color for visible URLs/domains. Blend theme text toward primary (blue-ish).
let link_fg = crate::colors::mix_toward(
crate::colors::text(),
crate::colors::primary(),
0.35,
);
for s in spans {
// Skip autolinking inside inline code spans (we style code with the
// theme's function color). This avoids linking snippets like
@@ -1304,24 +1310,34 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
span.content = text[cursor..abs_start].to_string().into();
out.push(span);
}
// Render underlined label (no OSC 8). Preserve existing style.
let mut lbl_span = s.clone();
let mut st = lbl_span.style;
st.add_modifier.insert(Modifier::UNDERLINED);
lbl_span.style = st;
lbl_span.content = label.into();
out.push(lbl_span);
// Append visible URL dimmed in parentheses after the label
let mut open_span = s.clone();
open_span.content = " (".into();
out.push(open_span);
let mut url_span = s.clone();
url_span.style = url_span.style.patch(Style::default().fg(crate::colors::text_dim()));
url_span.content = target.clone().into();
out.push(url_span);
let mut close_span = s.clone();
close_span.content = ")".into();
out.push(close_span);
// Special case: when the label is just a short preview of the target URL
// (e.g., "front.com" for "https://front.com/integrations/…"), emit ONLY
// the full URL so terminals can auto‑link it. Avoid underlines/parentheses
// to keep scroll rendering stable and prevent duplicate visual tokens.
if is_short_preview_of_url(&label, &target) {
let mut url_only = s.clone();
url_only.content = target.clone().into();
url_only.style = url_only.style.patch(Style::default().fg(link_fg));
out.push(url_only);
} else {
// Default behavior: underlined label followed by dimmed URL in parens
let mut lbl_span = s.clone();
let mut st = lbl_span.style;
st.add_modifier.insert(Modifier::UNDERLINED);
lbl_span.style = st;
lbl_span.content = label.into();
out.push(lbl_span);
let mut open_span = s.clone();
open_span.content = " (".into();
out.push(open_span);
let mut url_span = s.clone();
url_span.style = url_span.style.patch(Style::default().fg(link_fg));
url_span.content = target.clone().into();
out.push(url_span);
let mut close_span = s.clone();
close_span.content = ")".into();
out.push(close_span);
}
cursor = abs_end;
changed = true;
continue;
@@ -1341,6 +1357,7 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
// Emit URL text verbatim; terminal will make it clickable.
let mut core_span = s.clone();
core_span.content = core.to_string().into();
core_span.style = core_span.style.patch(Style::default().fg(link_fg));
out.push(core_span);
if !trailing.is_empty() {
let mut span = s.clone();
@@ -1366,6 +1383,7 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
if is_probable_domain(core_dom) {
let mut core_span = s.clone();
core_span.content = core_dom.to_string().into();
core_span.style = core_span.style.patch(Style::default().fg(link_fg));
out.push(core_span);
if !trailing.is_empty() {
let mut span = s.clone();
@@ -1515,6 +1533,92 @@ fn find_markdown_image(s: &str) -> Option<(usize, String, String)> {
Some((k + 1, label.to_string(), target)) // consumed up to and including ')'
}
// Heuristic: determine if `label` is a short preview (typically a bare domain)
// for the full `target` URL. When true, we should display only the full URL
// (letting the terminal auto-link it) instead of "label (url)".
fn is_short_preview_of_url(label: &str, target: &str) -> bool {
// Must be an explicit HTTP(S) URL and label must be shorter
let lt = label.trim();
let tt = target.trim();
if lt.is_empty() || tt.is_empty() {
return false;
}
let lower_t = tt.to_ascii_lowercase();
if !(lower_t.starts_with("http://") || lower_t.starts_with("https://")) {
return false;
}
if lt.chars().count() >= tt.chars().count() {
return false;
}
// Normalize a string into a domain host if possible.
fn extract_host(s: &str) -> Option<String> {
let s = s.trim();
let lower = s.to_ascii_lowercase();
let without_scheme = if lower.starts_with("http://") {
&s[7..]
} else if lower.starts_with("https://") {
&s[8..]
} else {
s
};
let mut host = without_scheme
.split(&['/', '?', '#'][..])
.next()
.unwrap_or("")
.to_string();
if host.is_empty() {
return None;
}
// Strip userinfo and port if present
if let Some(idx) = host.rfind('@') {
host = host[idx + 1..].to_string();
}
if let Some(idx) = host.find(':') {
host = host[..idx].to_string();
}
// Drop leading www.
let host = host.trim().trim_matches('.').to_ascii_lowercase();
let host = host.strip_prefix("www.").unwrap_or(&host).to_string();
if host.contains('.') { Some(host) } else { None }
}
// Lightweight TLD guard for labels that aren't URLs
fn looks_like_domain(s: &str) -> bool {
let s = s.trim().trim_end_matches('/');
if !s.contains('.') || s.contains(' ') { return false; }
let tld = s.rsplit_once('.').map(|(_, t)| t).unwrap_or("").to_ascii_lowercase();
const ALLOWED_TLDS: &[&str] = &[
"com","org","net","edu","gov","io","ai","app","dev","co","us","uk","ca","de","fr","jp","cn","in","au"
];
ALLOWED_TLDS.contains(&tld.as_str())
}
let label_host = if lower_t.starts_with("http://") || lower_t.starts_with("https://") {
// If label itself is a URL, compare its host; otherwise, treat as domain text
extract_host(lt).or_else(|| Some(lt.to_ascii_lowercase()))
} else {
Some(lt.to_ascii_lowercase())
};
let target_host = extract_host(tt);
match (label_host, target_host) {
(Some(lh_raw), Some(mut th)) => {
let mut lh = lh_raw.trim().trim_end_matches('/').to_ascii_lowercase();
if lh.starts_with("www.") { lh = lh.trim_start_matches("www.").to_string(); }
if th.starts_with("www.") { th = th.trim_start_matches("www.").to_string(); }
// Require label to look like a domain to avoid stripping arbitrary text
if !looks_like_domain(&lh) { return false; }
// Exact match or label equals the registrable/root portion of the host
if lh == th { return true; }
// Host may include subdomains; if it ends with .<label>, treat as preview
if th.ends_with(&format!(".{lh}")) { return true; }
false
}
_ => false,
}
}
// Map ASCII to Unicode subscript/superscript for a small useful subset.
fn to_subscript(s: &str) -> String {
s.chars()
+1
View File
@@ -615,3 +615,4 @@ Options that are specific to the TUI.
| `projects.<path>.trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). |
| `preferred_auth_method` | `chatgpt` | `apikey` | Select default auth method (default: `chatgpt`). |
| `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). |
| `tools.web_search_allowed_domains` | array<string> | Optional allow-list for web search (filters.allowed_domains). |