mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
chore: commit local changes before sync
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<_>>();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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). |
|
||||
|
||||
Reference in New Issue
Block a user