tui: strip OSC 8 hyperlinks in markdown; refine rendering and syntax handling

- Sanitize OSC 8 escape sequences during markdown rendering to avoid stray
  hyperlink escapes interfering with layout and selection.
- Adjust history cell and chat rendering to keep styling consistent after
  stripping OSC 8.
- Tweak tui markdown renderer, syntax highlighting, and line utils for
  robustness.
- Update codex-core config docs/types where needed.
This commit is contained in:
Code (AI)
2025-08-29 10:48:02 +10:00
parent e760a9ddd1
commit a30c0198ad
18 changed files with 1713 additions and 316 deletions
+15 -36
View File
@@ -32,41 +32,7 @@ The browser will either be an internal headless browser, or a CPD connection to
## Agent tools
Your agents are like having a team of expert peers at your disposal at any time. Use them for non-trivial work in these types of situations;
### Solve
If you get stuck on a problem and your first attempt has failed, race your agents to find the solution in read_only mode. Research shows that the fastest agent is frequently the most accurate.
Example;
agent_run {
"task": "Find and fix flaky test `test_refund_flow` in services/payments. Produce: (1) root cause, (2) minimal unified diff, (3) commands to reproduce and verify. Do not change business logic.",
"context": "Monorepo; CI fails intermittently on macOS since a1b2c3. Stripe sandbox used in tests.",
"files": ["services/payments", "services/payments/tests", "ci/config.yml"],
"model": ["claude","gemini","codex"],
"output": "Root cause + minimal patch + reproducible verification",
"read_only": true
}
agent_wait {"batch_id":"<batch_id>","return_all":false} // will return as soon as first agent is complete
agent_result {"agent_id":"<first_agent_id>"}
// Repeat and create new agents if needed until problem is resolved
agent_cancel {"batch_id":"<batch>"} // stop running agents ONLY once the task is tested and confirmed solved. Even if you think you have solved the problem it may be good to check the output of other agents
### Plan
When starting a complicated task without an obvious direction, you can find multiple perspectives then merge into one plan with rationale.
Example;
agent_run {
"task": "Draft a phased rollout plan for feature flags across apps/web and services. Include migration steps, rollback, monitoring, ownership, and two safe defaults.",
"context": "apps/web (Next.js), services/api (Rust Axum). Existing env flags in `config/flags`. Goal: progressive delivery.",
"files": ["apps/web/package.json", "services/api/Cargo.toml", "config/flags"],
"model": ["claude","gemini","codex"],
"output": "Numbered phases + risk matrix + observability plan",
"read_only": true
}
agent_wait { "batch_id": "<batch_id>", "return_all": true } // wait for all
### Code
Implement multiple tasks at once, or see how multiple other peers would build a solution.
Your agents are like having a team of expert peers at your disposal at any time. Use them for non-trivial work.
Example;
agent_run {
@@ -80,4 +46,17 @@ agent_run {
agent_wait {"batch_id":"<batch_id>","return_all":true,"timeout_seconds": 600 } // Long timeout or you can do separate work and check back later.
# Warnings
- Do not create new branches or make changes to git unless requested.
- Do not create new branches or make changes to git unless requested.
# Final output
You can include FULL markdown in any responses you make. These will be converted to beautiful output in the terminal.
**In particular** you can use fenced code blocks which will be shown with full syntax highlighting. Try to include the language of the block e.g.
```bash
# Shell example
set -euo pipefail
./build-fast.sh
```
Markdown tables, quotes, callouts, task lists, strikethrough and inline code are also all supported.
+18 -1
View File
@@ -115,7 +115,11 @@ pub struct Tui {
/// Theme configuration for the TUI
#[serde(default)]
pub theme: ThemeConfig,
/// Syntax highlighting configuration (Markdown fenced code blocks)
#[serde(default)]
pub highlight: HighlightConfig,
/// Whether to show reasoning content expanded by default (can be toggled with Ctrl+R/T)
#[serde(default)]
pub show_reasoning: bool,
@@ -142,6 +146,19 @@ impl Default for ThemeConfig {
}
}
/// Configuration for syntax highlighting in Markdown code blocks.
///
/// `theme` accepts the following values:
/// - "auto" (default): choose a sensible built-in syntect theme based on
/// whether the current UI theme is light or dark.
/// - "<name>": use a specific syntect theme by name from the default ThemeSet.
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
pub struct HighlightConfig {
/// Theme selection preference (see docstring for accepted values)
#[serde(default)]
pub theme: Option<String>,
}
/// Available predefined themes
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default)]
#[serde(rename_all = "kebab-case")]
+1 -1
View File
@@ -82,7 +82,7 @@ unicode-segmentation = "1.12.0"
unicode-width = "0.1"
url = "2"
uuid = "1"
syntect = "5"
syntect = { version = "5", features = ["yaml-load", "plist-load", "parsing", "default-syntaxes"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -0,0 +1,32 @@
%YAML 1.2
---
name: CMake
file_extensions:
- CMakeLists.txt
- cmake
scope: source.cmake
contexts:
main:
- include: comments
- include: strings
- match: '\b(if|else|elseif|endif|function|endfunction|macro|endmacro|foreach|endforeach|while|endwhile|project|add_executable|add_library|target_link_libraries|set|include|find_package)\b'
scope: keyword.control.cmake
- match: '\$\{[A-Za-z_][A-Za-z0-9_]*\}'
scope: variable.other.readwrite.cmake
- match: '[A-Za-z_][A-Za-z0-9_]*\s*\('
scope: entity.name.function.cmake
comments:
- match: '#.*$'
scope: comment.line.number-sign.cmake
strings:
- match: '"'
scope: punctuation.definition.string.begin.cmake
push:
- meta_scope: string.quoted.double.cmake
- match: '"'
scope: punctuation.definition.string.end.cmake
pop: true
- match: '\\.'
scope: constant.character.escape.cmake
@@ -0,0 +1,40 @@
%YAML 1.2
---
name: Dockerfile
file_extensions:
- Dockerfile
scope: source.dockerfile
contexts:
main:
- include: comments
- match: '^(\s*)(?i:(FROM|RUN|CMD|LABEL|EXPOSE|ENV|ADD|COPY|ENTRYPOINT|VOLUME|USER|WORKDIR|ARG|ONBUILD|STOPSIGNAL|HEALTHCHECK|SHELL))\b'
captures:
2: keyword.control.directive.dockerfile
push:
- meta_scope: meta.instruction.dockerfile
- match: '$'
pop: true
- include: strings
- include: strings
comments:
- match: '#.*$'
scope: comment.line.number-sign.dockerfile
strings:
- match: '"'
scope: punctuation.definition.string.begin.dockerfile
push:
- meta_scope: string.quoted.double.dockerfile
- match: '"'
scope: punctuation.definition.string.end.dockerfile
pop: true
- match: '\\.'
scope: constant.character.escape.dockerfile
- match: "'"
scope: punctuation.definition.string.begin.dockerfile
push:
- meta_scope: string.quoted.single.dockerfile
- match: "'"
scope: punctuation.definition.string.end.dockerfile
pop: true
@@ -0,0 +1,48 @@
%YAML 1.2
---
name: GraphQL
file_extensions:
- graphql
- gql
scope: source.graphql
contexts:
main:
- include: comments
- include: strings
- include: numbers
- match: '\b(type|interface|union|enum|input|schema|query|mutation|subscription|scalar|extend|implements|fragment|on)\b'
scope: keyword.declaration.graphql
- match: '\b(true|false|null)\b'
scope: constant.language.graphql
- match: '\$[A-Za-z_][A-Za-z0-9_]*'
scope: variable.parameter.graphql
- match: '@[A-Za-z_][A-Za-z0-9_]*'
scope: entity.other.attribute-name.directive.graphql
- match: '[A-Za-z_][A-Za-z0-9_]*'
scope: entity.name.type.graphql
comments:
- match: '#.*$'
scope: comment.line.number-sign.graphql
strings:
- match: '"""'
scope: punctuation.definition.string.begin.graphql
push:
- meta_scope: string.quoted.triple.graphql
- match: '"""'
scope: punctuation.definition.string.end.graphql
pop: true
- match: '"'
scope: punctuation.definition.string.begin.graphql
push:
- meta_scope: string.quoted.double.graphql
- match: '"'
scope: punctuation.definition.string.end.graphql
pop: true
- match: '\\.'
scope: constant.character.escape.graphql
numbers:
- match: '(?:\b-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)'
scope: constant.numeric.graphql
@@ -0,0 +1,47 @@
%YAML 1.2
---
name: HCL
file_extensions:
- hcl
- tf
- tfvars
scope: source.hcl
contexts:
main:
- include: comments
- include: strings
- include: numbers
- match: '\b(true|false|null)\b'
scope: constant.language.hcl
- match: '\b(resource|variable|output|provider|module|locals|data)\b'
scope: keyword.declaration.hcl
- match: '[A-Za-z_][A-Za-z0-9_]*\s*(?=\{)'
scope: entity.name.section.hcl
- match: '[A-Za-z_][A-Za-z0-9_]*\s*=\s*'
scope: support.type.property-name.hcl
comments:
- match: '#.*$'
scope: comment.line.number-sign.hcl
- match: '//.*$'
scope: comment.line.double-slash.hcl
- match: '/\*'
scope: comment.block.hcl
push:
- match: '\*/'
pop: true
strings:
- match: '"'
scope: punctuation.definition.string.begin.hcl
push:
- meta_scope: string.quoted.double.hcl
- match: '"'
scope: punctuation.definition.string.end.hcl
pop: true
- match: '\\.'
scope: constant.character.escape.hcl
numbers:
- match: '\b-?\d+(?:\.\d+)?\b'
scope: constant.numeric.hcl
@@ -0,0 +1,36 @@
%YAML 1.2
---
name: Nix
file_extensions:
- nix
scope: source.nix
contexts:
main:
- include: comments
- include: strings
- include: numbers
- match: '\b(let|in|rec|with|inherit|if|then|else)\b'
scope: keyword.control.nix
- match: '\b(true|false|null)\b'
scope: constant.language.nix
- match: '[A-Za-z_][A-Za-z0-9_\-]*'
scope: variable.other.nix
comments:
- match: '#.*$'
scope: comment.line.number-sign.nix
strings:
- match: '"'
scope: punctuation.definition.string.begin.nix
push:
- meta_scope: string.quoted.double.nix
- match: '"'
scope: punctuation.definition.string.end.nix
pop: true
- match: '\\.'
scope: constant.character.escape.nix
numbers:
- match: '\b-?\d+(?:\.\d+)?\b'
scope: constant.numeric.nix
@@ -0,0 +1,40 @@
%YAML 1.2
---
name: PowerShell
file_extensions:
- ps1
- psm1
- psd1
scope: source.powershell
contexts:
main:
- include: comments
- include: strings
- match: '\$(\w+)'
scope: variable.other.readwrite.powershell
- match: '\b(function|param|begin|process|end|if|elseif|else|switch|for|foreach|while|do|return|break|continue|trap|throw|try|catch|finally)\b'
scope: keyword.control.powershell
- match: '-[A-Za-z][A-Za-z0-9-]*'
scope: variable.parameter.powershell
comments:
- match: '#.*$'
scope: comment.line.number-sign.powershell
strings:
- match: '"'
scope: punctuation.definition.string.begin.powershell
push:
- meta_scope: string.quoted.double.powershell
- match: '"'
scope: punctuation.definition.string.end.powershell
pop: true
- match: '`.'
scope: constant.character.escape.powershell
- match: "'"
scope: punctuation.definition.string.begin.powershell
push:
- meta_scope: string.quoted.single.powershell
- match: "'"
scope: punctuation.definition.string.end.powershell
pop: true
@@ -0,0 +1,41 @@
%YAML 1.2
---
name: Protocol Buffer
file_extensions:
- proto
scope: source.protobuf
contexts:
main:
- include: comments
- include: strings
- include: numbers
- match: '\b(syntax|package|import|option|message|enum|service|rpc|returns|oneof|map|repeated|required|optional)\b'
scope: keyword.declaration.protobuf
- match: '\b(true|false)\b'
scope: constant.language.boolean.protobuf
- match: '\b[A-Za-z_][A-Za-z0-9_]*\b'
scope: entity.name.type.protobuf
comments:
- match: '//.*$'
scope: comment.line.double-slash.protobuf
- match: '/\*'
scope: comment.block.protobuf
push:
- match: '\*/'
pop: true
strings:
- match: '"'
scope: punctuation.definition.string.begin.protobuf
push:
- meta_scope: string.quoted.double.protobuf
- match: '"'
scope: punctuation.definition.string.end.protobuf
pop: true
- match: '\\.'
scope: constant.character.escape.protobuf
numbers:
- match: '\b\d+\b'
scope: constant.numeric.integer.protobuf
@@ -0,0 +1,103 @@
%YAML 1.2
---
name: TOML
file_extensions:
- toml
scope: source.toml
contexts:
main:
- include: comments
- include: tables
- include: keyval
comments:
- match: '#.*$'
scope: comment.line.number-sign.toml
tables:
- match: '^\s*\[\[.*?\]\]'
scope: entity.name.section.array.table.toml
- match: '^\s*\[.*?\]'
scope: entity.name.section.table.toml
keyval:
- match: '^\s*([A-Za-z0-9_\-.]+)\s*(=)\s*'
captures:
1: support.type.property-name.toml
2: punctuation.separator.key-value.toml
push:
- meta_scope: meta.value.toml
- include: values
- match: '$'
pop: true
values:
- include: strings
- include: numbers
- include: booleans
- include: arrays
- include: inline-table
strings:
- match: '"""'
scope: punctuation.definition.string.begin.toml
push:
- meta_scope: string.quoted.triple.toml
- match: '"""'
scope: punctuation.definition.string.end.toml
pop: true
- match: '\\.'
scope: constant.character.escape.toml
- match: '"'
scope: punctuation.definition.string.begin.toml
push:
- meta_scope: string.quoted.double.toml
- match: '"'
scope: punctuation.definition.string.end.toml
pop: true
- match: '\\.'
scope: constant.character.escape.toml
- match: "'"
scope: punctuation.definition.string.begin.toml
push:
- meta_scope: string.quoted.single.toml
- match: "'"
scope: punctuation.definition.string.end.toml
pop: true
numbers:
- match: '\b([+-]?\d+(_\d+)*\.?\d*([eE][+-]?\d+)?)\b'
scope: constant.numeric.toml
booleans:
- match: '\b(true|false)\b'
scope: constant.language.boolean.toml
arrays:
- match: '\['
scope: punctuation.section.sequence.begin.toml
push:
- meta_scope: meta.sequence.toml
- match: '\]'
scope: punctuation.section.sequence.end.toml
pop: true
- include: values
- match: ','
scope: punctuation.separator.sequence.toml
inline-table:
- match: '\{'
scope: punctuation.section.mapping.begin.toml
push:
- meta_scope: meta.mapping.toml
- match: '\}'
scope: punctuation.section.mapping.end.toml
pop: true
- match: '([A-Za-z0-9_\-.]+)\s*(=)'
captures:
1: support.type.property-name.toml
2: punctuation.separator.key-value.toml
- include: values
- match: ','
scope: punctuation.separator.mapping.toml
+214 -3
View File
@@ -190,6 +190,8 @@ pub(crate) struct ChatWidget<'a> {
// Any further deltas for these ids should be ignored to avoid post-final updates.
closed_answer_ids: HashSet<String>,
closed_reasoning_ids: HashSet<String>,
// Guard for out-of-order exec events: track call_ids that already ended
ended_call_ids: HashSet<String>,
// Accumulated patch change sets for this session (latest last)
@@ -233,6 +235,10 @@ pub(crate) struct ChatWidget<'a> {
// Pending jump-back state (reversible until submit)
pending_jump_back: Option<PendingJumpBack>,
// Track active task ids so we don't drop the working status while any
// agent/sub‑agent is still running (long‑running sessions can interleave).
active_task_ids: HashSet<String>,
}
struct PendingJumpBack {
@@ -427,6 +433,176 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
}
impl ChatWidget<'_> {
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>() {
if exec.output.is_none() {
let completed = history_cell::new_completed_exec_command(
exec.command.clone(),
exec.parsed.clone(),
CommandOutput { exit_code, stdout, stderr },
);
self.history_cells[idx] = Box::new(completed);
self.invalidate_height_cache();
self.request_redraw();
}
}
}
fn finalize_all_running_as_interrupted(&mut self) {
// Mark running Exec cells as interrupted (SIGINT semantics 130)
let interrupted_msg = "Interrupted by user".to_string();
let stdout_empty = String::new();
// Snapshot keys to avoid borrow issues
let running: Vec<(String, Option<usize>)> = self
.running_commands
.iter()
.map(|(k, v)| (k.clone(), v.history_index))
.collect();
for (_call_id, maybe_idx) in running {
if let Some(idx) = maybe_idx { self.finalize_exec_cell_at(idx, 130, stdout_empty.clone(), interrupted_msg.clone()); }
}
self.running_commands.clear();
// Custom tools: replace running cells with a failed completion note
if !self.running_custom_tools.is_empty() {
let entries: Vec<(String, usize)> = self.running_custom_tools.iter().map(|(k, i)| (k.clone(), *i)).collect();
for (_k, idx) in entries {
if idx < self.history_cells.len() {
// Create a synthetic completion with failure
let completed = history_cell::new_completed_custom_tool_call(
"custom".to_string(),
None,
std::time::Duration::from_millis(0),
false,
"Interrupted by user".to_string(),
);
self.history_cells[idx] = Box::new(completed);
}
}
self.running_custom_tools.clear();
self.invalidate_height_cache();
self.request_redraw();
}
// Web searches: finalize as unsuccessful
if !self.running_web_search.is_empty() {
let entries: Vec<(String, (usize, Option<String>))> = self.running_web_search.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
for (call_id, (idx, query_opt)) in entries {
let mut target_idx = None;
if idx < self.history_cells.len() {
let is_ws = self.history_cells[idx]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
.is_some_and(|rt| rt.has_title("Web Search..."));
if is_ws { target_idx = Some(idx); }
}
if target_idx.is_none() {
for i in (0..self.history_cells.len()).rev() {
if let Some(rt) = self.history_cells[i]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
{
if rt.has_title("Web Search...") { target_idx = Some(i); break; }
}
}
}
if let Some(i) = target_idx {
if let Some(rt) = self.history_cells[i]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
{
let completed = rt.finalize_web_search(false, query_opt);
self.history_cells[i] = Box::new(completed);
self.invalidate_height_cache();
self.request_redraw();
}
}
self.running_web_search.remove(&call_id);
}
}
// If nothing else is active, hide the spinner and show cancelled
self.bottom_pane.update_status_text("cancelled".to_string());
let any_tasks_active = !self.active_task_ids.is_empty();
if !any_tasks_active {
self.bottom_pane.set_task_running(false);
}
}
fn finalize_all_running_due_to_answer(&mut self) {
// Convert any lingering running exec/tool cells to completed so the UI doesn't hang.
let note = "Completed (final answer received)".to_string();
let stdout_empty = String::new();
let running: Vec<(String, Option<usize>)> = self
.running_commands
.iter()
.map(|(k, v)| (k.clone(), v.history_index))
.collect();
for (_call_id, maybe_idx) in running {
if let Some(idx) = maybe_idx { self.finalize_exec_cell_at(idx, 0, stdout_empty.clone(), note.clone()); }
}
self.running_commands.clear();
if !self.running_custom_tools.is_empty() {
let entries: Vec<(String, usize)> = self.running_custom_tools.iter().map(|(k, i)| (k.clone(), *i)).collect();
for (_k, idx) in entries {
if idx < self.history_cells.len() {
let completed = history_cell::new_completed_custom_tool_call(
"custom".to_string(),
None,
std::time::Duration::from_millis(0),
true,
"Final answer received".to_string(),
);
self.history_cells[idx] = Box::new(completed);
}
}
self.running_custom_tools.clear();
self.invalidate_height_cache();
self.request_redraw();
}
if !self.running_web_search.is_empty() {
let entries: Vec<(String, (usize, Option<String>))> = self
.running_web_search
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for (call_id, (idx, query_opt)) in entries {
let mut target_idx = None;
if idx < self.history_cells.len() {
let is_ws = self.history_cells[idx]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
.is_some_and(|rt| rt.has_title("Web Search..."));
if is_ws { target_idx = Some(idx); }
}
if target_idx.is_none() {
for i in (0..self.history_cells.len()).rev() {
if let Some(rt) = self.history_cells[i]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
{
if rt.has_title("Web Search...") { target_idx = Some(i); break; }
}
}
}
if let Some(i) = target_idx {
if let Some(rt) = self.history_cells[i]
.as_any()
.downcast_ref::<history_cell::RunningToolCallCell>()
{
let completed = rt.finalize_web_search(true, query_opt);
self.history_cells[i] = Box::new(completed);
self.invalidate_height_cache();
self.request_redraw();
}
}
self.running_web_search.remove(&call_id);
}
}
}
fn perf_label_for_item(&self, item: &dyn HistoryCell) -> String {
use crate::history_cell::{ExecKind, ExecStatus, HistoryCellType, PatchKind, ToolStatus};
match item.kind() {
@@ -753,6 +929,11 @@ impl ChatWidget<'_> {
/// Handle exec command begin immediately
fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
// If we already saw an end for this call_id (out-of-order delivery),
// ignore the late begin to avoid creating a phantom running cell.
if self.ended_call_ids.contains(&ev.call_id) {
return;
}
// Ensure welcome animation fades out when a command starts
for cell in &self.history_cells {
cell.trigger_fade();
@@ -793,6 +974,8 @@ impl ChatWidget<'_> {
/// Handle exec command end immediately
fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) {
// Record that this call has ended so a late Begin can be ignored.
self.ended_call_ids.insert(ev.call_id.clone());
let ExecCommandEndEvent {
call_id,
exit_code,
@@ -1235,10 +1418,14 @@ impl ChatWidget<'_> {
fn interrupt_running_task(&mut self) {
if self.bottom_pane.is_task_running() {
self.active_exec_cell = None;
self.running_commands.clear();
// Finalize any visible running indicators as interrupted (Exec/Web/Custom)
self.finalize_all_running_as_interrupted();
self.bottom_pane.clear_ctrl_c_quit_hint();
self.submit_op(Op::Interrupt);
self.bottom_pane.set_task_running(false);
// If nothing else is active, drop spinner; otherwise status already updated.
if self.active_task_ids.is_empty() {
self.bottom_pane.set_task_running(false);
}
self.bottom_pane.clear_live_ring();
// Reset with max width to disable wrapping
self.live_builder = RowBuilder::new(usize::MAX);
@@ -1401,6 +1588,7 @@ impl ChatWidget<'_> {
interrupts: interrupts::InterruptManager::new(),
closed_answer_ids: HashSet::new(),
closed_reasoning_ids: HashSet::new(),
ended_call_ids: HashSet::new(),
session_patch_sets: Vec::new(),
baseline_file_contents: HashMap::new(),
diff_overlay: None,
@@ -1421,6 +1609,7 @@ impl ChatWidget<'_> {
perf: std::cell::RefCell::new(PerfStats::default()),
session_id: None,
pending_jump_back: None,
active_task_ids: HashSet::new(),
};
// Note: Initial redraw needs to be triggered after widget is added to app_state
@@ -1509,6 +1698,7 @@ impl ChatWidget<'_> {
interrupts: interrupts::InterruptManager::new(),
closed_answer_ids: HashSet::new(),
closed_reasoning_ids: HashSet::new(),
ended_call_ids: HashSet::new(),
session_patch_sets: Vec::new(),
baseline_file_contents: HashMap::new(),
diff_overlay: None,
@@ -1529,6 +1719,7 @@ impl ChatWidget<'_> {
perf: std::cell::RefCell::new(PerfStats::default()),
session_id: None,
pending_jump_back: None,
active_task_ids: HashSet::new(),
}
}
@@ -2550,6 +2741,8 @@ impl ChatWidget<'_> {
message.len(),
message.chars().take(80).collect::<String>()
);
// Fallback: if any tools/execs are still marked running, complete them now.
self.finalize_all_running_due_to_answer();
// Use StreamController for final answer
let sink = AppEventHistorySink(self.app_event_tx.clone());
self.current_stream_kind = Some(StreamKind::Answer);
@@ -2671,6 +2864,8 @@ impl ChatWidget<'_> {
}
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
tracing::debug!("AgentReasoning event with text: {:?}...", text.chars().take(100).collect::<String>());
// Fallback: if any tools/execs are still marked running, complete them now.
self.finalize_all_running_due_to_answer();
// Use StreamController for final reasoning
let sink = AppEventHistorySink(self.app_event_tx.clone());
self.current_stream_kind = Some(StreamKind::Reasoning);
@@ -2708,7 +2903,10 @@ impl ChatWidget<'_> {
// New turn: clear closed id guards
self.closed_answer_ids.clear();
self.closed_reasoning_ids.clear();
self.ended_call_ids.clear();
self.bottom_pane.clear_ctrl_c_quit_hint();
// Mark this task id as active and ensure the status stays visible
self.active_task_ids.insert(id.clone());
self.bottom_pane.set_task_running(true);
self.bottom_pane.update_status_text("waiting for model".to_string());
@@ -2727,6 +2925,8 @@ impl ChatWidget<'_> {
self.current_stream_kind = Some(StreamKind::Answer);
self.stream.finalize(StreamKind::Answer, true, &sink);
}
// Remove this id from the active set (it may be a sub‑agent)
self.active_task_ids.remove(&id);
// 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
@@ -2776,7 +2976,18 @@ impl ChatWidget<'_> {
}
// Now that streaming is complete, flush any queued interrupts
self.flush_interrupt_queue();
self.bottom_pane.set_task_running(false);
// Only drop the working status if nothing is actually running.
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);
}
self.current_stream_kind = None;
self.mark_needs_redraw();
}
+524 -118
View File
@@ -353,6 +353,65 @@ impl HistoryCell for PlainHistoryCell {
self.lines.clone()
}
}
fn has_custom_render(&self) -> bool {
matches!(self.kind, HistoryCellType::User)
}
fn desired_height(&self, width: u16) -> u16 {
if matches!(self.kind, HistoryCellType::User) {
// Match input composer wrapping by reserving 2 columns of right padding.
// Composer content width is pane−6; history content is pane−4 (after gutter).
// Subtract 2 more so wrapping positions are identical when the message moves
// from the composer into history.
let inner_w = width.saturating_sub(2);
let text = Text::from(self.display_lines_trimmed());
Paragraph::new(text)
.wrap(Wrap { trim: false })
.line_count(inner_w)
.try_into()
.unwrap_or(0)
} else {
Paragraph::new(Text::from(self.display_lines_trimmed()))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(0)
}
}
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
if !matches!(self.kind, HistoryCellType::User) {
// Fallback to default behavior for non-user cells
return HistoryCell::custom_render_with_skip(self, area, buf, skip_rows);
}
// Render User cells with extra right padding to mirror the composer input padding.
let cell_bg = crate::colors::background();
let bg_style = Style::default().bg(cell_bg).fg(crate::colors::text());
// Clear area
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
}
}
let lines = self.display_lines_trimmed();
let text = Text::from(lines);
// Add Block with padding: reserve 2 columns on the right.
let block = Block::default()
.style(bg_style)
.padding(Padding { left: 0, right: 2, top: 0, bottom: 0 });
Paragraph::new(text)
.block(block)
.wrap(Wrap { trim: false })
.scroll((skip_rows, 0))
.style(bg_style)
.render(area, buf);
}
}
// ==================== ExecCell ====================
@@ -434,14 +493,76 @@ impl HistoryCell for AssistantMarkdownCell {
true
}
fn desired_height(&self, width: u16) -> u16 {
// Match StreamingContentCell: 1 row padding above and below
let content = Text::from(self.display_lines_trimmed());
let content_rows: u16 = Paragraph::new(content)
.wrap(Wrap { trim: false })
.line_count(width.saturating_sub(2))
.try_into()
.unwrap_or(0);
content_rows.saturating_add(2)
// Match custom rendering exactly:
// - Apply bullet wrapping and horizontal rules transformation
// - 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);
let mut all_lines = self.display_lines_trimmed();
if inner_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() {
if crate::render::line_utils::is_code_block_painted(&line) {
transformed.push(line);
continue;
}
if is_horizontal_rule_line(&line) {
transformed.push(Line::from(Span::styled(
std::iter::repeat('─').take(inner_width as usize).collect::<String>(),
Style::default().fg(crate::colors::assistant_hr()),
)));
continue;
}
if let Some((indent_spaces, bullet_char)) = detect_bullet_prefix(&line) {
if is_first_output_line && bullet_char == "-" {
use ratatui::text::Span;
let mut spans = std::mem::take(&mut line.spans);
let mut i = 0usize;
let mut out_spans: Vec<Span<'static>> = Vec::new();
if indent_spaces > 0 { out_spans.push(Span::raw(" ".repeat(indent_spaces))); i = 1; }
i = i.saturating_add(2);
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));
}
} else {
transformed.push(line);
}
is_first_output_line = false;
}
all_lines = transformed;
}
let mut total: u16 = 0;
let mut i = 0usize;
while i < all_lines.len() {
let is_code = crate::render::line_utils::is_code_block_painted(&all_lines[i]);
let start = i; i += 1;
while i < all_lines.len() && crate::render::line_utils::is_code_block_painted(&all_lines[i]) == is_code { i += 1; }
if is_code {
let mut chunk: Vec<Line<'static>> = all_lines[start..i].to_vec();
if let Some(first) = chunk.first() {
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
if flat.contains("⟦LANG:") { chunk.remove(0); }
}
while chunk.first().is_some_and(|l| crate::render::line_utils::is_blank_line_spaces_only(l)) { chunk.remove(0); }
while chunk.last().is_some_and(|l| crate::render::line_utils::is_blank_line_spaces_only(l)) { chunk.pop(); }
total = total.saturating_add(chunk.len() as u16 + 4);
} else {
let text = Text::from(all_lines[start..i].to_vec());
let rows: u16 = Paragraph::new(text)
.wrap(Wrap { trim: false })
.line_count(inner_width)
.try_into()
.unwrap_or(0);
total = total.saturating_add(rows);
}
}
// Top + bottom padding rows
total.saturating_add(2)
}
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
// Mirror StreamingContentCell rendering so finalized assistant cells look
@@ -463,7 +584,8 @@ impl HistoryCell for AssistantMarkdownCell {
let inner_width = area.width.saturating_sub(2);
if inner_width > 4 {
let mut transformed: Vec<Line<'static>> = Vec::new();
for line in all_lines.into_iter() {
let mut is_first_output_line = true;
for mut line in all_lines.into_iter() {
if crate::render::line_utils::is_code_block_painted(&line) {
transformed.push(line);
continue;
@@ -479,15 +601,35 @@ impl HistoryCell for AssistantMarkdownCell {
continue;
}
if let Some((indent_spaces, bullet_char)) = detect_bullet_prefix(&line) {
// Special case: If this is the very first output line and the bullet
// is a plain hyphen ("-"), drop the bullet itself and keep the content.
if is_first_output_line && bullet_char == "-" {
use ratatui::text::Span;
let mut spans = std::mem::take(&mut line.spans);
let mut i = 0usize;
// Optional leading indent
let mut out_spans: Vec<Span<'static>> = Vec::new();
if indent_spaces > 0 {
out_spans.push(Span::raw(" ".repeat(indent_spaces)));
i = 1; // skip the indent span we expect
}
// Skip bullet span and the following single-space span
i = i.saturating_add(2);
// Append the rest of the content as-is
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,
));
}
} else {
transformed.push(line);
}
is_first_output_line = false;
}
all_lines = transformed;
}
@@ -566,45 +708,88 @@ impl HistoryCell for AssistantMarkdownCell {
*y = y.saturating_add(draw_h);
*skip = 0;
}
Seg::Code(lines) => {
Seg::Code(lines_in) => {
if lines_in.is_empty() { return; }
// Extract language sentinel and drop it from visible lines
let mut lang_label: Option<String> = None;
let mut lines = lines_in.clone();
if let Some(first) = lines.first() {
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
if let Some(s) = flat.strip_prefix("⟦LANG:") {
if let Some(end) = s.find('⟧') { lang_label = Some(s[..end].to_string()); lines.remove(0); }
}
}
if lines.is_empty() { return; }
// Determine target width for the code card: fit content but not beyond inner_width
// 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 card_w = max_w.min(inner_width).max(1);
let total = lines.len() as u16 + 2; // top+bottom padding rows
let inner_w = max_w.max(1);
let card_w = inner_w.saturating_add(4).min(inner_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);
if avail == 0 { return; }
// Compute visible slice
// Compute visible slice (accounting for top/bottom border + inner padding rows)
let mut local_skip = *skip;
let mut top_pad = 1u16;
if local_skip > 0 { let drop = local_skip.min(top_pad); top_pad -= drop; local_skip -= drop; }
let code_skip = local_skip.min(lines.len() as u16);
local_skip -= code_skip;
let mut bottom_pad = 1u16;
if local_skip > 0 { let drop = local_skip.min(bottom_pad); bottom_pad -= drop; }
let mut top_border = 1u16; if local_skip > 0 { let drop = local_skip.min(top_border); top_border -= drop; local_skip -= drop; }
let mut top_pad = 1u16; if local_skip > 0 { let drop = local_skip.min(top_pad); top_pad -= drop; local_skip -= drop; }
let code_skip = local_skip.min(lines.len() as u16); local_skip -= code_skip;
let mut bottom_pad = 1u16; if local_skip > 0 { let drop = local_skip.min(bottom_pad); bottom_pad -= drop; }
let mut bottom_border = 1u16; if local_skip > 0 { let drop = local_skip.min(bottom_border); bottom_border -= drop; }
// Compute drawable height in this pass
let visible = top_pad + (lines.len() as u16 - code_skip) + bottom_pad;
let visible = top_border + top_pad + (lines.len() as u16 - code_skip) + bottom_pad + bottom_border;
let draw_h = visible.min(avail);
if draw_h == 0 { return; }
// Draw background block for visible rows
let rect = Rect { x: area.x, y: *y, width: card_w, height: draw_h };
// Compute optional outer horizontal padding (1 col left/right) inside content area
let content_x = area.x;
let content_w = inner_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)
} else {
let rp = content_w.saturating_sub(card_w) > 0;
(content_x, false, rp)
};
// Draw bordered block for visible rows
let rect = Rect { x: rect_x, y: *y, width: card_w, height: draw_h };
let code_bg = crate::colors::code_block_bg();
let blk = Block::default().style(Style::default().bg(code_bg));
let mut blk = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(code_bg))
.padding(Padding { left: 1, right: 1, top: 1, bottom: 1 });
if let Some(lang) = &lang_label {
blk = blk.title(Span::styled(format!(" {} ", lang), Style::default().fg(crate::colors::text_dim())));
}
// Clone before render so we can compute inner rect after drawing borders
let blk_for_inner = blk.clone();
blk.render(rect, buf);
// Inner paragraph area (exclude top padding row if present)
let inner_y = rect.y + top_pad.min(draw_h);
let inner_h = rect.height.saturating_sub(top_pad + bottom_pad).min(rect.height);
// Inner paragraph area (exclude borders)
let inner_rect = blk_for_inner.inner(rect);
let inner_h = inner_rect.height.min(rect.height);
if inner_h > 0 {
let slice_start = code_skip as usize;
let slice_end = lines.len();
let txt = Text::from(lines[slice_start..slice_end].to_vec());
let inner_rect = Rect { x: rect.x, y: inner_y, width: rect.width, height: inner_h };
Paragraph::new(txt)
.style(Style::default().bg(code_bg))
.block(Block::default().style(Style::default().bg(code_bg)))
.render(inner_rect, buf);
}
// Draw optional outside padding stripes using code background
if left_pad {
let px = rect.x.saturating_sub(1);
for yy in rect.y..rect.y.saturating_add(rect.height) {
buf[(px, yy)].set_char(' ').set_style(Style::default().bg(code_bg));
}
}
if right_pad {
let px = rect.x.saturating_add(rect.width);
if px < content_x.saturating_add(content_w) {
for yy in rect.y..rect.y.saturating_add(rect.height) {
buf[(px, yy)].set_char(' ').set_style(Style::default().bg(code_bg));
}
}
}
*y = y.saturating_add(draw_h);
*skip = 0;
}
@@ -615,6 +800,14 @@ impl HistoryCell for AssistantMarkdownCell {
if cur_y >= end_y { break; }
draw_segment(seg, &mut cur_y, &mut remaining_skip);
}
// Bottom padding row (blank): area is already cleared to bg
if remaining_skip == 0 && cur_y < end_y {
cur_y = cur_y.saturating_add(1);
} else {
remaining_skip = remaining_skip.saturating_sub(1);
}
// Mark as used to satisfy unused_assignments lint
let _ = (cur_y, remaining_skip);
}
}
@@ -1970,22 +2163,74 @@ impl HistoryCell for StreamingContentCell {
true
}
fn desired_height(&self, width: u16) -> u16 {
// Reserve one row of padding above and below the assistant content.
// Also reserve 1 col of left padding and 2 cols of right padding.
let content = Text::from(self.display_lines_trimmed());
let content_rows: u16 = Paragraph::new(content)
.wrap(Wrap { trim: false })
.line_count(width.saturating_sub(2))
.try_into()
.unwrap_or(0);
content_rows.saturating_add(2)
// Match streaming render path, including bullet wrapping and HR expansion.
let inner_width = width.saturating_sub(2);
let mut all_lines = self.display_lines_trimmed();
if inner_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() {
if crate::render::line_utils::is_code_block_painted(&line) {
transformed.push(line);
continue;
}
if is_horizontal_rule_line(&line) {
transformed.push(Line::from(Span::styled(
std::iter::repeat('─').take(inner_width as usize).collect::<String>(),
Style::default().fg(crate::colors::assistant_hr()),
)));
continue;
}
if let Some((indent_spaces, bullet_char)) = detect_bullet_prefix(&line) {
if is_first_output_line && bullet_char == "-" {
use ratatui::text::Span;
let mut spans = std::mem::take(&mut line.spans);
let mut i = 0usize;
let mut out_spans: Vec<Span<'static>> = Vec::new();
if indent_spaces > 0 { out_spans.push(Span::raw(" ".repeat(indent_spaces))); i = 1; }
i = i.saturating_add(2);
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));
}
} else {
transformed.push(line);
}
is_first_output_line = false;
}
all_lines = transformed;
}
let mut total: u16 = 0;
let mut i = 0usize;
while i < all_lines.len() {
let is_code = crate::render::line_utils::is_code_block_painted(&all_lines[i]);
let start = i; i += 1;
while i < all_lines.len() && crate::render::line_utils::is_code_block_painted(&all_lines[i]) == is_code { i += 1; }
if is_code {
let mut chunk_len = (i - start) as u16;
if let Some(first) = all_lines[start..i].first() {
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
if flat.contains("⟦LANG:") { chunk_len = chunk_len.saturating_sub(1); }
}
total = total.saturating_add(chunk_len.saturating_add(4));
} else {
let text = Text::from(all_lines[start..i].to_vec());
let rows: u16 = Paragraph::new(text)
.wrap(Wrap { trim: false })
.line_count(inner_width)
.try_into()
.unwrap_or(0);
total = total.saturating_add(rows);
}
}
// Top + bottom padding rows
total.saturating_add(2)
}
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
// Render with a 1-row top and bottom padding, all using the assistant bg tint.
let cell_bg = crate::colors::assistant_bg();
// Do not set a widget-level FG here; it can mask per-span modifiers like BOLD on some terminals.
// Keep only BG tint so span-level styles remain authoritative.
// (A debug flag still exists, but this is now the default behavior.)
let bg_style = Style::default().bg(cell_bg);
// Hard clear area with assistant background
@@ -1995,17 +2240,21 @@ impl HistoryCell for StreamingContentCell {
}
}
// Prepare lines with bullet hanging-indents (first-sentence styling is now handled in the markdown renderer).
let mut content_lines = self.display_lines_trimmed();
let wrap_width = area.width.saturating_sub(2); // keep 2-col right padding in layout
if wrap_width > 4 {
// 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 mut transformed: Vec<Line<'static>> = Vec::new();
for line in content_lines.into_iter() {
// Horizontal rule: lines of only --- *** or ___ -> draw across width
let mut is_first_output_line = true;
for mut line in all_lines.into_iter() {
if crate::render::line_utils::is_code_block_painted(&line) {
transformed.push(line);
continue;
}
if is_horizontal_rule_line(&line) {
let hr = Line::from(Span::styled(
std::iter::repeat('─')
.take(wrap_width as usize)
.take(inner_width as usize)
.collect::<String>(),
Style::default().fg(crate::colors::assistant_hr()),
));
@@ -2013,66 +2262,162 @@ impl HistoryCell for StreamingContentCell {
continue;
}
if let Some((indent_spaces, bullet_char)) = detect_bullet_prefix(&line) {
transformed.extend(wrap_bullet_line(
line,
indent_spaces,
&bullet_char,
wrap_width,
));
if is_first_output_line && bullet_char == "-" {
use ratatui::text::Span;
let mut spans = std::mem::take(&mut line.spans);
let mut i = 0usize;
let mut out_spans: Vec<Span<'static>> = Vec::new();
if indent_spaces > 0 {
out_spans.push(Span::raw(" ".repeat(indent_spaces)));
i = 1; // skip the indent span we expect
}
i = i.saturating_add(2); // skip bullet and following space
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,
));
}
} else {
transformed.push(line);
}
is_first_output_line = false;
}
content_lines = transformed;
all_lines = transformed;
}
let content_text = Text::from(content_lines);
let content_total: u16 = Paragraph::new(content_text.clone())
.wrap(Wrap { trim: false })
.line_count(area.width.saturating_sub(2))
.try_into()
.unwrap_or(0);
// Segment into normal text vs code blocks (contiguous painted lines)
#[derive(Debug)]
enum Seg { Text(Vec<Line<'static>>), Code(Vec<Line<'static>>), }
let mut segs: Vec<Seg> = Vec::new();
let mut i = 0usize;
while i < all_lines.len() {
let is_code = crate::render::line_utils::is_code_block_painted(&all_lines[i]);
let start = i; i += 1;
while i < all_lines.len() && crate::render::line_utils::is_code_block_painted(&all_lines[i]) == is_code { i += 1; }
let chunk: Vec<Line<'static>> = all_lines[start..i].to_vec();
segs.push(if is_code { Seg::Code(chunk) } else { Seg::Text(chunk) });
}
// Streaming-style top padding row
let mut remaining_skip = skip_rows;
let mut cur_y = area.y;
let end_y = area.y.saturating_add(area.height);
// Top padding (1 row)
if remaining_skip == 0 && cur_y < end_y {
// already cleared with bg; nothing else to draw
cur_y = cur_y.saturating_add(1);
}
if remaining_skip == 0 && cur_y < end_y { cur_y = cur_y.saturating_add(1); }
remaining_skip = remaining_skip.saturating_sub(1);
// Content block
if cur_y < end_y {
let content_skip = remaining_skip.min(content_total);
let avail = end_y.saturating_sub(cur_y);
let draw_h = (content_total.saturating_sub(content_skip)).min(avail);
if draw_h > 0 {
// Render content flush to the gutter (no extra left pad) and keep 2 cols right pad
let content_area = Rect {
x: area.x,
y: cur_y,
width: area.width.saturating_sub(2),
height: draw_h,
};
let block = Block::default().style(bg_style);
Paragraph::new(content_text)
.block(block)
.wrap(Wrap { trim: false })
.scroll((content_skip, 0))
.style(bg_style)
.render(content_area, buf);
cur_y = cur_y.saturating_add(draw_h);
// Helpers
use unicode_width::UnicodeWidthStr as UW;
let measure_line = |l: &Line<'_>| -> usize { l.spans.iter().map(|s| UW::width(s.content.as_ref())).sum() };
let mut draw_segment = |seg: &Seg, y: &mut u16, skip: &mut u16| {
if *y >= end_y { return; }
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);
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 };
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;
}
Seg::Code(lines_in) => {
if lines_in.is_empty() { return; }
// Extract optional language sentinel and drop it from the content lines
let mut lang_label: Option<String> = None;
let mut lines = lines_in.clone();
if let Some(first) = lines.first() {
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
if let Some(s) = flat.strip_prefix("⟦LANG:") {
if let Some(end) = s.find('⟧') { lang_label = Some(s[..end].to_string()); lines.remove(0); }
}
}
if lines.is_empty() { return; }
// Determine target width of the code card (respect inner 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));
// Include top/bottom border (2) + inner padding rows (2)
let total = lines.len() as u16 + 4;
if *skip >= total { *skip -= total; return; }
let avail = end_y.saturating_sub(*y);
if avail == 0 { return; }
// Compute visible slice of the card (accounting for inner padding rows)
let mut local_skip = *skip;
let mut top_border = 1u16; if local_skip > 0 { let drop = local_skip.min(top_border); top_border -= drop; local_skip -= drop; }
let mut top_pad = 1u16; if local_skip > 0 { let drop = local_skip.min(top_pad); top_pad -= drop; local_skip -= drop; }
let code_skip = local_skip.min(lines.len() as u16); local_skip -= code_skip;
let mut bottom_pad = 1u16; if local_skip > 0 { let drop = local_skip.min(bottom_pad); bottom_pad -= drop; }
let mut bottom_border = 1u16; if local_skip > 0 { let drop = local_skip.min(bottom_border); bottom_border -= drop; }
let visible = top_border + top_pad + (lines.len() as u16 - code_skip) + bottom_pad + bottom_border;
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 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)
} else {
let rp = content_w.saturating_sub(card_w) > 0;
(content_x, false, rp)
};
// Draw bordered block for the visible rows
let rect = Rect { x: rect_x, y: *y, width: card_w, height: draw_h };
let code_bg = crate::colors::code_block_bg();
let mut blk = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(code_bg))
.padding(Padding { left: 1, right: 1, top: 1, bottom: 1 });
if let Some(lang) = &lang_label { blk = blk.title(Span::styled(format!(" {} ", lang), Style::default().fg(crate::colors::text_dim()))); }
let blk_for_inner = blk.clone();
blk.render(rect, buf);
// Inner paragraph area (exclude borders)
let inner_rect = blk_for_inner.inner(rect);
let inner_h = inner_rect.height.min(rect.height);
if inner_h > 0 {
let slice_start = code_skip as usize;
let txt = Text::from(lines[slice_start..].to_vec());
Paragraph::new(txt)
.style(Style::default().bg(code_bg))
.block(Block::default().style(Style::default().bg(code_bg)))
.render(inner_rect, buf);
}
// Draw optional outside padding stripes
if left_pad {
let px = rect.x.saturating_sub(1);
for yy in rect.y..rect.y.saturating_add(rect.height) {
buf[(px, yy)].set_char(' ').set_style(Style::default().bg(code_bg));
}
}
if right_pad {
let px = rect.x.saturating_add(rect.width);
if px < content_x.saturating_add(content_w) {
for yy in rect.y..rect.y.saturating_add(rect.height) {
buf[(px, yy)].set_char(' ').set_style(Style::default().bg(code_bg));
}
}
}
*y = y.saturating_add(draw_h); *skip = 0;
}
}
}
};
// Bottom padding (1 row)
if cur_y < end_y {
// one row already cleared with bg; nothing else to draw
// when remaining_skip > 0, the single bottom pad row is entirely skipped.
// if remaining_skip > 0, the single bottom pad row is entirely skipped
for seg in &segs {
if cur_y >= end_y { break; }
draw_segment(seg, &mut cur_y, &mut remaining_skip);
}
// Bottom padding row (blank): area already cleared
if remaining_skip == 0 && cur_y < end_y { cur_y = cur_y.saturating_add(1); } else { remaining_skip = remaining_skip.saturating_sub(1); }
// Mark as used to satisfy unused_assignments lint
let _ = (cur_y, remaining_skip);
}
fn display_lines(&self) -> Vec<Line<'static>> {
// Hide the header line (e.g., "codex") when using a gutter symbol
@@ -2092,7 +2437,8 @@ impl HistoryCell for StreamingContentCell {
// Detect lines that start with a markdown bullet produced by our renderer and return (indent, bullet)
fn detect_bullet_prefix(line: &ratatui::text::Line<'_>) -> Option<(usize, String)> {
let bullets = ["-", "•", "◦", "·", "∘", "⋅"];
// Treat these as unordered bullets, plus checkbox glyphs for task lists.
let bullets = ["-", "•", "◦", "·", "∘", "⋅", "☐", "✔"];
let spans = &line.spans;
if spans.is_empty() {
return None;
@@ -2107,21 +2453,33 @@ fn detect_bullet_prefix(line: &ratatui::text::Line<'_>) -> Option<(usize, String
idx = 1;
}
}
// Next must be bullet glyph
// Next must be a bullet-like prefix and a following space span.
// Handle three cases:
// 1) Unordered bullets (•, -, etc) and task checkboxes (☐, ✔)
// 2) Ordered bullets like "1." (digits followed by '.')
// In all cases, there must be a subsequent single-space span after the marker.
let bullet_span = spans.get(idx)?;
let bullet = bullet_span.content.as_ref();
if !bullets.contains(&bullet) {
return None;
}
// Next must be a single space span (renderer adds one)
let space_ok = spans
let has_space_after = spans
.get(idx + 1)
.map(|s| s.content.as_ref() == " ")
.unwrap_or(false);
if !space_ok {
if !has_space_after {
return None;
}
Some((indent, bullet.to_string()))
if bullets.contains(&bullet) {
return Some((indent, bullet.to_string()));
}
// Ordered list: e.g., "1.", "12.", etc.
if bullet.len() >= 2
&& bullet.ends_with('.')
&& bullet[..bullet.len() - 1]
.chars()
.all(|c| c.is_ascii_digit())
{
return Some((indent, bullet.to_string()));
}
None
}
// Wrap a bullet line with a hanging indent so wrapped lines align under the content start.
@@ -2133,6 +2491,8 @@ fn wrap_bullet_line(
) -> Vec<ratatui::text::Line<'static>> {
use ratatui::style::Style;
use ratatui::text::Span;
use unicode_width::UnicodeWidthChar as UWChar;
use unicode_width::UnicodeWidthStr as UWStr;
let width = width as usize;
let mut spans = std::mem::take(&mut line.spans);
@@ -2170,27 +2530,68 @@ fn wrap_bullet_line(
}
}
// Prefix widths
let first_prefix = indent_spaces + 1 /*bullet*/ + 1 /*space*/;
let cont_prefix = indent_spaces + 2; // spaces equal to bullet+space
// Prefix widths (display columns)
let bullet_cols = UWStr::width(bullet);
let first_prefix = indent_spaces + bullet_cols + 1 /*space*/;
let cont_prefix = indent_spaces + bullet_cols + 1; // spaces equal to bullet+space
let mut out: Vec<ratatui::text::Line<'static>> = Vec::new();
let mut pos = 0usize;
let mut first = true;
while pos < chars.len() {
let avail = if first {
let avail_cols = if first {
width.saturating_sub(first_prefix)
} else {
width.saturating_sub(cont_prefix)
};
let avail = avail.max(1);
let mut take = 0usize;
let mut col = 0usize;
while pos + take < chars.len() && col < avail {
col += 1;
take += 1;
} as usize;
let avail_cols = avail_cols.max(1);
// Greedy take up to avail_cols, preferring to break at a preceding space.
let mut taken = 0usize; // number of chars consumed
let mut cols = 0usize; // display columns consumed
let mut last_space_char_idx: Option<usize> = None; // index into chars
while pos + taken < chars.len() {
let (ch, _) = chars[pos + taken];
let w = UWChar::width(ch).unwrap_or(0).max(1);
if cols.saturating_add(w) > avail_cols {
break;
}
cols += w;
if ch == ' ' {
last_space_char_idx = Some(pos + taken);
}
taken += 1;
if cols == avail_cols { break; }
}
let slice = &chars[pos..pos + take];
// Choose cut position: prefer last space within range; otherwise force break.
let (cut_end, next_start) = if let Some(space_idx) = last_space_char_idx {
// Trim any spaces following the break point for next line start
let mut next = space_idx;
// cut_end excludes the space
let mut cut = space_idx;
// Also trim any trailing spaces before the break in this segment
while cut > pos && chars[cut - 1].0 == ' ' { cut -= 1; }
// Advance next past contiguous spaces
while next < chars.len() && chars[next].0 == ' ' { next += 1; }
(cut, next)
} else {
// No space seen in range – hard break (very long word or first token longer than width)
(pos + taken, pos + taken)
};
// If cut_end did not advance (e.g., segment starts with spaces), skip spaces and continue
if cut_end <= pos {
let mut p = pos;
while p < chars.len() && chars[p].0 == ' ' { p += 1; }
if p == pos { // safety: ensure forward progress
p = pos + 1;
}
pos = p;
continue;
}
let slice = &chars[pos..cut_end];
let mut seg_spans: Vec<Span<'static>> = Vec::new();
// Build prefix spans
if first {
@@ -2220,7 +2621,7 @@ fn wrap_bullet_line(
seg_spans.push(Span::styled(buf, cur_style.unwrap()));
}
out.push(ratatui::text::Line::from(seg_spans));
pos += take;
pos = next_start;
first = false;
}
@@ -2577,6 +2978,7 @@ fn normalize_overwrite_sequences(input: &str) -> String {
fn build_preview_lines(text: &str, _include_left_pipe: bool) -> Vec<Line<'static>> {
let processed = format_json_compact(text).unwrap_or_else(|| text.to_string());
let processed = normalize_overwrite_sequences(&processed);
let processed = expand_tabs_to_spaces(&processed, 4);
let non_empty: Vec<&str> = processed.lines().filter(|line| !line.is_empty()).collect();
enum Seg<'a> {
@@ -2645,7 +3047,7 @@ fn output_lines(
format!("Error (exit code {})", exit_code),
Style::default().fg(crate::colors::error()),
));
let stderr_norm = normalize_overwrite_sequences(stderr);
let stderr_norm = expand_tabs_to_spaces(&normalize_overwrite_sequences(stderr), 4);
for line in stderr_norm.lines().filter(|line| !line.is_empty()) {
lines.push(ansi_escape_line(line).style(Style::default().fg(crate::colors::error())));
}
@@ -4317,8 +4719,12 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
Line::from(""),
];
for line in stderr.lines() {
lines.push(ansi_escape_line(line).red());
let norm = normalize_overwrite_sequences(&stderr);
let norm = expand_tabs_to_spaces(&norm, 4);
for line in norm.lines() {
if !line.is_empty() {
lines.push(ansi_escape_line(line).red());
}
}
lines.push(Line::from(""));
+48 -43
View File
@@ -81,41 +81,43 @@ fn append_markdown_with_opener_and_cwd_and_bold(
let left_right_pad = if fenced { 1 } else { 0 };
let target_w = max_w + (left_right_pad * 2);
// Optional: add a blank row above for fenced code blocks
// When fenced and language is known, emit a hidden sentinel line so the
// downstream renderer can surface a border + title without losing lang info.
if fenced {
let top = Line::from(Span::styled(" ".repeat(target_w), Style::default().bg(code_bg)));
lines.push(top);
let label = _lang.clone().unwrap_or_else(|| "text".to_string());
let sentinel = format!("⟦LANG:{}⟧", label);
lines.push(Line::from(Span::styled(sentinel, Style::default().fg(code_bg).bg(code_bg))));
}
for l in highlighted.iter_mut() {
// Apply background to all existing spans instead of the line,
// so the painted region matches our explicit padding width.
for sp in l.spans.iter_mut() {
sp.style = sp.style.bg(code_bg);
}
// Prepend left padding when requested
if left_right_pad > 0 {
l.spans.insert(0, Span::styled(" ", Style::default().bg(code_bg)));
}
let w: usize = l
.spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
if target_w > w {
let pad = " ".repeat(target_w - w);
l.spans.push(Span::styled(pad, Style::default().bg(code_bg)));
} else if w == 0 {
// Defensive: paint at least one cell so background shows
l.spans.push(Span::styled(" ", Style::default().bg(code_bg)));
}
}
lines.extend(highlighted);
// Optional: add a blank row below for fenced code blocks
if fenced {
let bottom = Line::from(Span::styled(" ".repeat(target_w), Style::default().bg(code_bg)));
lines.push(bottom);
for l in highlighted.iter_mut() {
// Apply background to all existing spans instead of the line,
// so the painted region matches our explicit padding width.
for sp in l.spans.iter_mut() {
sp.style = sp.style.bg(code_bg);
}
// Prepend left padding when requested
if left_right_pad > 0 {
l.spans.insert(0, Span::styled(" ", Style::default().bg(code_bg)));
}
let w: usize = l
.spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
if target_w > w {
let pad = " ".repeat(target_w - w);
l.spans.push(Span::styled(pad, Style::default().bg(code_bg)));
} else if w == 0 {
// Defensive: paint at least one cell so background shows
l.spans.push(Span::styled(" ", Style::default().bg(code_bg)));
}
}
lines.extend(highlighted);
} else {
// Non‑fenced (indented) blocks: do NOT convert to code cards.
// Preserve exact text and any syntax highlighting FG, but no background.
lines.extend(highlighted);
}
}
}
@@ -426,6 +428,7 @@ mod tests {
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd);
// Filter out the hidden language sentinel line
let rendered: Vec<String> = out
.iter()
.map(|l| {
@@ -434,17 +437,15 @@ mod tests {
.map(|s| s.content.clone())
.collect::<String>()
})
.filter(|s| !s.contains("⟦LANG:"))
.collect();
// Expect: top padding row, the 3 code lines (each with a leading space and optional trailing spaces), bottom padding row.
assert_eq!(rendered.len(), 5, "expected fenced padding rows added: {:?}", rendered);
// Helpers: identify padded blank line and strip left pad+trailing spaces
let is_padded_blank = |s: &str| !s.is_empty() && s.trim().is_empty();
let strip_line = |s: &str| s.strip_prefix(' ').unwrap_or(s).trim_end().to_string();
assert!(is_padded_blank(&rendered[0]));
assert_eq!(strip_line(&rendered[1]), " indented");
assert_eq!(strip_line(&rendered[2]), "\t\twith tabs");
assert_eq!(strip_line(&rendered[3]), " four spaces");
assert!(is_padded_blank(&rendered[4]));
// Expect just the code lines (no fence markers), preserving leading whitespace.
// We no longer inject visible padding rows here; borders/padding are applied at render time.
let strip_line = |s: &str| s.strip_prefix(' ').unwrap_or(s).to_string();
assert!(rendered.len() >= 3, "unexpected length: {:?}", rendered);
assert_eq!(strip_line(&rendered[0]), " indented");
assert_eq!(strip_line(&rendered[1]), "\t\twith tabs");
assert_eq!(strip_line(&rendered[2]), " four spaces");
}
#[test]
@@ -461,6 +462,7 @@ mod tests {
.map(|s| s.content.clone())
.collect::<String>()
})
.filter(|s| !s.contains("⟦LANG:"))
.collect();
// Expect first and last lines rewritten, and the interior fenced code line
// unchanged (but wrapped with left/right padding rows).
@@ -484,6 +486,7 @@ mod tests {
.map(|s| s.content.clone())
.collect::<String>()
})
.filter(|s| !s.contains("⟦LANG:"))
.collect();
assert_eq!(
rendered,
@@ -511,6 +514,7 @@ mod tests {
.map(|s| s.content.clone())
.collect::<String>()
})
.filter(|s| !s.contains("⟦LANG:"))
.collect();
// Expect first and last lines rewritten, and the indented code line present
// unchanged (citations inside not rewritten). We do not assert on blank
@@ -560,10 +564,11 @@ mod tests {
let rendered: Vec<String> = out
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.filter(|s| !s.contains("⟦LANG:"))
.collect();
// There should be at least 9 lines including top/bottom padding.
assert!(rendered.len() >= 9, "unexpected line count: {:?}", rendered);
// There should be at least 7 visible code lines (no border/padding rows emitted here).
assert!(rendered.len() >= 7, "unexpected line count: {:?}", rendered);
// Find the internal blank: it should be a line consisting only of spaces
// (inserted by padding logic inside code block), not an actually empty string
+63 -38
View File
@@ -151,11 +151,14 @@ impl MarkdownRenderer {
.max()
.unwrap_or(0);
let target_w = max_w + 2; // left + right space
// Add top blank row
// Emit hidden sentinel with language for border/title downstream
let label = self
.code_block_lang
.clone()
.unwrap_or_else(|| "text".to_string());
self.lines.push(Line::from(Span::styled(
" ".repeat(target_w),
Style::default().bg(code_bg),
format!("⟦LANG:{}⟧", label),
Style::default().fg(code_bg).bg(code_bg),
)));
for l in highlighted.iter_mut() {
@@ -183,11 +186,6 @@ impl MarkdownRenderer {
}
}
self.lines.extend(highlighted);
// Add bottom blank row
self.lines.push(Line::from(Span::styled(
" ".repeat(target_w),
Style::default().bg(code_bg),
)));
self.code_block_buf.clear();
self.in_code_block = false;
self.code_block_lang = None;
@@ -395,14 +393,20 @@ impl MarkdownRenderer {
spans.push(Span::raw(current_text.clone()));
current_text.clear();
}
// Render as a hyperlink span with a friendly label
// Render label and make the target URL visible next to it.
let lbl = if label.is_empty() {
"Image".to_string()
} else {
label
};
let link = hyperlink_span(&lbl, &target, &Span::raw(""));
spans.push(link);
// Underlined label
let mut st = Style::default();
st.add_modifier.insert(Modifier::UNDERLINED);
spans.push(Span::styled(lbl, st));
// Append visible URL in parens (dimmed)
spans.push(Span::raw(" ("));
spans.push(Span::styled(target.clone(), Style::default().fg(crate::colors::text_dim())));
spans.push(Span::raw(")"));
i += consumed;
continue;
}
@@ -695,10 +699,14 @@ impl MarkdownRenderer {
.max()
.unwrap_or(0);
let target_w = max_w + 2; // left + right space
// Add top blank row
// Emit hidden sentinel with language for border/title downstream
let label = self
.code_block_lang
.clone()
.unwrap_or_else(|| "text".to_string());
self.lines.push(Line::from(Span::styled(
" ".repeat(target_w),
Style::default().bg(code_bg),
format!("⟦LANG:{}⟧", label),
Style::default().fg(code_bg).bg(code_bg),
)));
for l in highlighted.iter_mut() {
@@ -723,11 +731,6 @@ impl MarkdownRenderer {
}
}
self.lines.extend(highlighted);
// Add bottom blank row
self.lines.push(Line::from(Span::styled(
" ".repeat(target_w),
Style::default().bg(code_bg),
)));
self.code_block_buf.clear();
self.in_code_block = false;
self.code_block_lang = None;
@@ -1196,15 +1199,19 @@ fn apply_first_sentence_style(spans: &mut Vec<Span<'static>>) -> bool {
true
}
// Turn inline markdown links and bare URLs into OSC 8 hyperlinks.
// This runs late, on the already-styled spans for a line, so it preserves
// bold/italic styling while adding underlines for links and embedding the
// hyperlink escape sequences in the text.
// Mixed mode hyperlinking:
// - Only Markdown links are wrapped with OSC 8 (to keep labeled links clickable).
// - Explicit http(s) URLs and bare domains are left as plain text so the
// terminal's native URL detection handles them. This avoids width/underline
// glitches reported in some terminals (e.g., iTerm2) when OSC 8 is pervasive.
// Turn inline markdown links and bare URLs into display-friendly spans.
// NOTE: We intentionally avoid emitting OSC 8 hyperlinks here. While OSC 8
// hyperlinks render correctly when static, some terminals exhibit artifacts
// when scrolling or re-wrapping content that contains embedded escape
// sequences. By rendering labels as plain underlined text and leaving literal
// URLs as-is (so the terminal can auto-detect them), we guarantee stable
// rendering during scroll without leaking control characters.
//
// Behavior:
// - Markdown links [label](target): render as an underlined `label` span
// (no OSC 8). We prefer stability over clickability for labeled links.
// - Explicit http(s) URLs and bare domains: emit verbatim text so terminals
// can auto-link them. This keeps them clickable without control sequences.
fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
// Patterns: markdown [label](target), explicit http(s) URLs, and plain domains.
// Keep conservative to avoid false positives.
@@ -1297,7 +1304,24 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
span.content = text[cursor..abs_start].to_string().into();
out.push(span);
}
out.push(hyperlink_span(&label, &target, &s));
// 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);
cursor = abs_end;
changed = true;
continue;
@@ -1328,7 +1352,7 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
continue;
}
// 3) bare domain (Mixed mode: no OSC 8; emit as text)
// 3) bare domain: emit as text so terminal can auto-link
if let Some(m) = dom_re.find(after) {
let start = cursor + m.start();
let end = cursor + m.end();
@@ -1379,12 +1403,13 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
out
}
fn hyperlink_span(label: &str, target: &str, base: &Span<'static>) -> Span<'static> {
// OSC 8 wrapper (Markdown links only in Mixed mode). Avoid adding our own
// underline to prevent double/dotted underline when terminals decorate
// hyperlinks themselves.
let content = format!("\u{1b}]8;;{target}\u{7}{label}\u{1b}]8;;\u{7}");
Span::styled(content, base.style)
// Deprecated: OSC 8 emission removed for scroll stability. Keep a small helper
// to style a label consistently if future callers rely on it.
#[allow(dead_code)]
fn hyperlink_span(label: &str, _target: &str, base: &Span<'static>) -> Span<'static> {
let mut st = base.style;
st.add_modifier.insert(Modifier::UNDERLINED);
Span::styled(label.to_string(), st)
}
// Return (start, end, label, target) for the first markdown link found in `s`.
@@ -1646,7 +1671,7 @@ mod fenced_padding_tests {
let text = "```rust\nfn a() {}\n\nfn bbbbb() { println!(\"hi\"); }\n```";
let lines = MarkdownRenderer::render(text);
// Expect at least top pad, 3 content lines, bottom pad
assert!(lines.len() >= 5);
assert!(lines.len() >= 3);
let widths: Vec<usize> = lines
.iter()
.map(|l| l.spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum())
+7 -1
View File
@@ -56,5 +56,11 @@ pub fn is_code_block_painted(line: &Line<'_>) -> bool {
if line.style.bg == Some(code_bg) {
return true;
}
line.spans.iter().any(|s| s.style.bg == Some(code_bg))
if line.spans.iter().any(|s| s.style.bg == Some(code_bg)) {
return true;
}
// Treat our hidden language sentinel as code so it groups with the block.
let flat: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
if flat.contains("⟦LANG:") { return true; }
false
}
+434 -75
View File
@@ -3,8 +3,9 @@ use ratatui::text::{Line, Span};
// syntect imports
use syntect::easy::HighlightLines;
use syntect::highlighting::{Style as SynStyle, Theme, ThemeSet};
use syntect::highlighting::{Style as SynStyle, Theme, ThemeItem, ThemeSet, ThemeSettings, StyleModifier, FontStyle, Color as SynColor};
use syntect::parsing::{SyntaxReference, SyntaxSet};
use syntect::parsing::SyntaxSetBuilder;
use syntect::util::LinesWithEndings;
// Convert a ratatui Color to RGB tuple; mirrored locally to avoid exposing internals.
@@ -44,19 +45,316 @@ fn is_light_bg() -> bool {
}
static PS: OnceCell<SyntaxSet> = OnceCell::new();
static PS_EXTRA: OnceCell<Option<SyntaxSet>> = OnceCell::new();
static THEMES: OnceCell<ThemeSet> = OnceCell::new();
// --- Highlight theme preference ---
#[derive(Debug, Clone)]
enum HighlightPref {
Auto,
Name(String),
}
static PREF: OnceCell<std::sync::RwLock<HighlightPref>> = OnceCell::new();
fn pref_cell() -> &'static std::sync::RwLock<HighlightPref> {
PREF.get_or_init(|| std::sync::RwLock::new(HighlightPref::Auto))
}
/// Initialize the highlight preference from config.
pub fn init_highlight_from_config(cfg: &codex_core::config_types::HighlightConfig) {
let raw = cfg.theme.as_deref().unwrap_or("auto");
let val_trim = raw.trim();
// Only support "auto" or a bare theme name. Anything else falls back to auto.
let parsed = if val_trim.is_empty() || val_trim.eq_ignore_ascii_case("auto") {
HighlightPref::Auto
} else if let Some(rest) = val_trim.strip_prefix("syntect:") {
// Deprecated prefix; ignore and treat as bare name for compatibility.
let name = rest.trim();
if name.is_empty() { HighlightPref::Auto } else { HighlightPref::Name(name.to_string()) }
} else if val_trim.eq_ignore_ascii_case("follow-ui") {
// Deprecated alias; treat as auto.
HighlightPref::Auto
} else {
HighlightPref::Name(val_trim.to_string())
};
if let Ok(mut lock) = pref_cell().write() {
*lock = parsed;
}
}
fn syntax_set() -> &'static SyntaxSet {
PS.get_or_init(|| SyntaxSet::load_defaults_newlines())
}
fn themes() -> &'static ThemeSet {
THEMES.get_or_init(ThemeSet::load_defaults)
fn extra_syntax_set() -> &'static Option<SyntaxSet> {
PS_EXTRA.get_or_init(|| {
use std::path::PathBuf;
let mut folder = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
folder.push("assets/syntaxes");
if folder.is_dir() {
let mut builder = SyntaxSetBuilder::new();
builder.add_plain_text_syntax();
match builder.add_from_folder(&folder, true) {
Ok(_) => Some(builder.build()),
Err(e) => {
tracing::warn!("Failed to load extra syntaxes from {}: {}", folder.display(), e);
None
}
}
} else {
None
}
})
}
fn current_theme_name<'a>(_ts: &'a ThemeSet) -> &'a str {
// Restrict to Solarized only; pick based on current UI background brightness.
if is_light_bg() { "Solarized (light)" } else { "Solarized (dark)" }
fn themes() -> &'static ThemeSet {
THEMES.get_or_init(|| {
let mut ts = ThemeSet::load_defaults();
// Insert Codex built-in themes
ts.themes.insert("Code Dark".to_string(), build_code_dark_theme());
ts.themes.insert("Code Light".to_string(), build_code_light_theme());
ts
})
}
fn hex(rgb: (u8, u8, u8)) -> SynColor { SynColor { r: rgb.0, g: rgb.1, b: rgb.2, a: 0xFF } }
fn hexa(r: u8, g: u8, b: u8, a: u8) -> SynColor { SynColor { r, g, b, a } }
fn item(scope: &str, fg: (u8,u8,u8), style: Option<FontStyle>) -> ThemeItem {
ThemeItem {
scope: scope.parse().unwrap_or_default(),
style: StyleModifier { foreground: Some(hex(fg)), background: None, font_style: style },
}
}
fn build_code_dark_theme() -> Theme {
let mut t = Theme::default();
t.name = Some("Code Dark".to_string());
t.settings = {
let mut s = ThemeSettings::default();
// Globals
s.foreground = Some(hex((0xD4,0xD4,0xD4)));
s.background = Some(hex((0x1E,0x1E,0x1E)));
s.caret = Some(hex((0xAE,0xAF,0xAD)));
s.selection = Some(hex((0x26,0x4F,0x78)));
s.line_highlight = Some(hex((0x2A,0x2A,0x2A)));
// UI extras
s.misspelling = Some(hex((0xF1,0x4C,0x4C)));
s.minimap_border = Some(hex((0x01,0x04,0x09))); // #010409
s.accent = Some(hex((0x00,0x78,0xD4)));
s.bracket_contents_foreground = Some(hex((0xFF,0xD7,0x00))); // gold
s.brackets_foreground = Some(hex((0x88,0x88,0x88)));
s.brackets_background = Some(hexa(0,100,0,0x1A)); // #0064001A
s.tags_foreground = Some(hex((0x56,0x9C,0xD6)));
s.highlight = Some(hexa(0xAD,0xD6,0xFF,0x26));
s.find_highlight = Some(hex((0x9E,0x6A,0x03)));
s.find_highlight_foreground = Some(hex((0x00,0x00,0x00)));
s.gutter = Some(hex((0x1E,0x1E,0x1E)));
s.gutter_foreground = Some(hex((0x6E,0x76,0x81)));
s.selection_foreground = Some(hex((0xD4,0xD4,0xD4)));
s.selection_border = Some(hexa(0,0,0,0x00));
s.inactive_selection = Some(hex((0x3A,0x3D,0x41)));
s.inactive_selection_foreground = Some(hex((0xD4,0xD4,0xD4)));
s.guide = Some(hex((0x40,0x40,0x40)));
s.active_guide = Some(hex((0x70,0x70,0x70)));
s.stack_guide = Some(hex((0x58,0x58,0x58)));
s.shadow = Some(hexa(0,0,0,0x5C)); // 0.36
// CSS strings (kept empty per mapping)
s.popup_css = Some(String::new());
s.phantom_css = Some(String::new());
// Underline options
s.bracket_contents_options = Some(syntect::highlighting::UnderlineOption::None);
s.brackets_options = Some(syntect::highlighting::UnderlineOption::Underline);
s.tags_options = Some(syntect::highlighting::UnderlineOption::None);
s
};
let italic = Some(FontStyle::ITALIC);
let _bold_italic = Some(FontStyle::BOLD | FontStyle::ITALIC);
t.scopes = vec![
// Comments
item("comment", (0x6A,0x99,0x55), italic),
// Strings
item("string", (0xCE,0x91,0x78), None),
item("string.regexp", (0xD1,0x69,0x69), None),
item("string.template", (0xCE,0x91,0x78), None),
item("constant.regexp", (0x64,0x66,0x95), None),
item("constant.character.escape", (0xD7,0xBA,0x7D), None),
// Numbers / constants
item("constant.numeric", (0xB5,0xCE,0xA8), None),
item("constant.language.boolean", (0x56,0x9C,0xD6), None),
item("constant.language.null", (0x56,0x9C,0xD6), None),
// Keywords / operators / storage
item("keyword", (0xC5,0x86,0xC0), None),
item("keyword.control", (0xC5,0x86,0xC0), None),
item("keyword.operator", (0xD4,0xD4,0xD4), None),
item("storage, storage.type, storage.modifier", (0x56,0x9C,0xD6), None),
// Variables / properties
item("variable, variable.other.readwrite, meta.definition.variable", (0x9C,0xDC,0xFE), None),
item("variable.language", (0x56,0x9C,0xD6), None),
item("variable.other.member", (0x9C,0xDC,0xFE), None),
item("variable.other.constant", (0x9C,0xDC,0xFE), None),
item("variable.parameter", (0x9C,0xDC,0xFE), None),
item("meta.object-literal.key, support.type.property-name, variable.other.property", (0x9C,0xDC,0xFE), None),
item("support.type.property-name.json", (0x9C,0xDC,0xFE), None),
// Functions
item("function, entity.name.function, support.function, meta.function-call", (0xDC,0xDC,0xAA), None),
item("meta.function entity.name.function", (0xDC,0xDC,0xAA), None),
// Types / classes
item("type, entity.name.type, support.type, entity.name.class, support.class, entity.name.interface, entity.name.enum", (0x4E,0xC9,0xB0), None),
// HTML / XML
item("entity.name.tag", (0x56,0x9C,0xD6), None),
item("entity.other.attribute-name", (0x9C,0xDC,0xFE), None),
item("punctuation.definition.tag", (0x80,0x80,0x80), None),
// Markdown
item("markup.heading", (0x56,0x9C,0xD6), None),
item("markup.bold", (0x56,0x9C,0xD6), None),
item("markup.italic", (0xD4,0xD4,0xD4), None),
item("markup.strikethrough", (0xD4,0xD4,0xD4), None),
item("markup.inline.raw", (0xCE,0x91,0x78), None),
item("markup.quote", (0x6A,0x99,0x55), None),
// Diffs
item("diff.header", (0x56,0x9C,0xD6), None),
item("markup.inserted", (0x3F,0xB9,0x50), None),
item("markup.deleted", (0xF8,0x51,0x49), None),
item("markup.changed", (0x56,0x9C,0xD6), None),
item("markup.ignored", (0x80,0x80,0x80), None),
item("meta.diff.header, meta.diff.range", (0x56,0x9C,0xD6), None),
item("source.diff", (0xD4,0xD4,0xD4), None),
// Punctuation helpers
item("punctuation.definition.quote.begin.markdown", (0x6A,0x99,0x55), None),
item("punctuation.definition.list.begin.markdown", (0x67,0x96,0xE6), None),
item("punctuation.section.embedded", (0x56,0x9C,0xD6), None),
item("punctuation.section.template-expression", (0x56,0x9C,0xD6), None),
item("punctuation.definition.parameters", (0xD4,0xD4,0xD4), None),
item("punctuation.separator.key-value", (0xD4,0xD4,0xD4), None),
// Invalid
item("invalid", (0xF4,0x47,0x47), None),
];
t
}
fn build_code_light_theme() -> Theme {
let mut t = Theme::default();
t.name = Some("Code Light".to_string());
t.settings = {
let mut s = ThemeSettings::default();
// Globals
s.foreground = Some(hex((0x00,0x00,0x00)));
s.background = Some(hex((0xFF,0xFF,0xFF)));
s.caret = Some(hex((0x00,0x00,0x00)));
s.selection = Some(hex((0xAD,0xD6,0xFF)));
s.line_highlight = Some(hex((0xF3,0xF3,0xF3))); // per mapping
// UI extras
s.misspelling = Some(hex((0xE5,0x14,0x00)));
s.minimap_border = Some(hexa(0xD4,0xD4,0xD4,0x4D));
s.accent = Some(hex((0x00,0x78,0xD4)));
s.bracket_contents_foreground = Some(hex((0xFF,0xD7,0x00)));
s.brackets_foreground = Some(hex((0xB9,0xB9,0xB9)));
s.brackets_background = Some(hexa(0,100,0,0x1A)); // 0.10
s.tags_foreground = Some(hex((0x80,0x00,0x00)));
s.highlight = Some(hexa(0xAD,0xD6,0xFF,0x80));
s.find_highlight = Some(hex((0x9E,0x6A,0x03)));
s.find_highlight_foreground = Some(hex((0x00,0x00,0x00)));
s.gutter = Some(hex((0xFF,0xFF,0xFF)));
s.gutter_foreground = Some(hexa(0,0,0,0x80)); // 0.50
s.selection_foreground = Some(hex((0x00,0x00,0x00)));
s.selection_border = Some(hexa(0,0,0,0x00));
s.inactive_selection = Some(hex((0xE5,0xEB,0xF1)));
s.inactive_selection_foreground = Some(hex((0x00,0x00,0x00)));
s.guide = Some(hex((0xD3,0xD3,0xD3)));
s.active_guide = Some(hex((0x93,0x93,0x93)));
s.stack_guide = Some(hexa(0,0,0,0x00));
s.shadow = Some(hexa(0,0,0,0x5C)); // 0.36
// CSS strings and underline options
s.popup_css = Some(String::new());
s.phantom_css = Some(String::new());
s.bracket_contents_options = Some(syntect::highlighting::UnderlineOption::None);
s.brackets_options = Some(syntect::highlighting::UnderlineOption::Underline);
s.tags_options = Some(syntect::highlighting::UnderlineOption::None);
s
};
let italic = Some(FontStyle::ITALIC);
let _bold_italic = Some(FontStyle::BOLD | FontStyle::ITALIC);
t.scopes = vec![
// Comments
item("comment", (0x00,0x80,0x00), italic),
// Strings / regex
item("string", (0xA3,0x15,0x15), None),
item("string.regexp", (0x81,0x1F,0x3F), None),
item("constant.regexp", (0xAF,0x00,0xDB), None),
item("constant.character.escape", (0xEE,0x00,0x00), None),
// Numbers / units
item("constant.numeric, keyword.other.unit", (0x09,0x86,0x58), None),
item("constant.language.boolean", (0x00,0x00,0xFF), None),
// Keywords / storage
item("keyword, keyword.control, storage, storage.type, storage.modifier", (0x00,0x00,0xFF), None),
item("keyword.operator", (0x00,0x00,0x00), None),
// Variables
item("variable, variable.other.readwrite, meta.definition.variable", (0x00,0x10,0x80), None),
item("variable.parameter", (0x00,0x10,0x80), None),
// Functions
item("entity.name.function, support.function, meta.function-call", (0x79,0x5E,0x26), None),
item("meta.function entity.name.function", (0x79,0x5E,0x26), None),
// Types / classes
item("entity.name.type, support.type, entity.name.class, support.class, entity.name.interface, entity.name.enum", (0x26,0x7F,0x99), None),
// HTML / CSS / JSON
item("entity.name.tag", (0x80,0x00,0x00), None),
item("entity.name.selector", (0x80,0x00,0x00), None),
item("entity.other.attribute-name", (0xE5,0x00,0x00), None),
item("punctuation.definition.tag", (0x80,0x00,0x00), None),
item("meta.object-literal.key, support.type.property-name, variable.other.property", (0x00,0x10,0x80), None),
item("support.type.property-name.json, meta.structure.dictionary.key.python", (0x04,0x51,0xA5), None),
// Markdown
item("markup.heading", (0x80,0x00,0x00), None),
item("markup.bold", (0x00,0x00,0x80), None),
item("markup.italic", (0x00,0x00,0x00), None),
item("markup.strikethrough", (0x00,0x00,0x00), None),
item("markup.inline.raw", (0x80,0x00,0x00), None),
item("markup.quote", (0x04,0x51,0xA5), None),
// Diffs
item("diff.header", (0x00,0x00,0x80), None),
item("markup.inserted", (0x1A,0x7F,0x37), None),
item("markup.deleted", (0xCF,0x22,0x2E), None),
item("markup.changed", (0x04,0x51,0xA5), None),
item("markup.ignored", (0x80,0x80,0x80), None),
item("meta.diff.header", (0x00,0x00,0x80), None),
item("meta.diff.range", (0x04,0x51,0xA5), None),
item("source.diff", (0x00,0x00,0x00), None),
// Punctuation helpers
item("punctuation.definition.quote.begin.markdown", (0x04,0x51,0xA5), None),
item("punctuation.definition.list.begin.markdown", (0x04,0x51,0xA5), None),
item("punctuation.section.embedded", (0x00,0x00,0xFF), None),
item("punctuation.section.template-expression", (0x00,0x00,0xFF), None),
item("punctuation.definition.parameters", (0x00,0x00,0x00), None),
item("punctuation.separator.key-value", (0x00,0x00,0x00), None),
// Invalid
item("invalid", (0xE5,0x14,0x00), None),
];
t
}
fn current_theme_name<'a>(ts: &'a ThemeSet) -> &'a str {
// Resolve based on configured preference; fall back to Solarized light/dark.
let pref = match pref_cell().read() {
Ok(g) => g.clone(),
Err(_) => HighlightPref::Auto,
};
match pref {
HighlightPref::Name(ref name) => {
if let Some((_k, _v)) = ts.themes.iter().find(|(k, _)| k.to_ascii_lowercase() == name.to_ascii_lowercase()) {
// SAFETY: We just looked up the same key; fetch again by exact key to get &'a str
for key in ts.themes.keys() {
if key.to_ascii_lowercase() == name.to_ascii_lowercase() {
return key;
}
}
}
// Not found: fall back to auto below
}
HighlightPref::Auto => {}
}
if is_light_bg() { "Code Light" } else { "Code Dark" }
}
fn blending_enabled() -> bool { false }
@@ -68,21 +366,32 @@ fn default_theme<'a>() -> &'a Theme {
// Prefer the Solarized themes; fall back to first available if missing.
ts.themes
.get(name)
.or_else(|| ts.themes.get(if is_light_bg() { "Solarized (light)" } else { "Solarized (dark)" }))
.or_else(|| ts.themes.get(if is_light_bg() { "Code Light" } else { "Code Dark" }))
.unwrap_or_else(|| ts.themes.values().next().expect("at least one syntect theme"))
}
fn try_syntax_for_lang<'a>(ps: &'a SyntaxSet, lang: &str) -> Option<&'a SyntaxReference> {
// Try token, then extension, then name; return None if not found.
// Try token, then extension, then name (case-insensitive fallback).
let lang = normalize_lang(lang);
ps.find_syntax_by_token(lang)
.or_else(|| ps.find_syntax_by_extension(lang))
.or_else(|| ps.find_syntax_by_name(lang))
.or_else(|| {
let want = lang.to_ascii_lowercase();
ps.syntaxes()
.iter()
.find(|s| s.name.to_ascii_lowercase() == want)
})
.or_else(|| {
// Graceful fallback: if TOML isn't bundled in this build of syntect,
// approximate with INI so keys/sections/strings get some color.
if lang.eq_ignore_ascii_case("toml") {
ps.find_syntax_by_name("INI").or_else(|| ps.find_syntax_by_extension("ini"))
} else { None }
})
}
fn syntax_for_lang<'a>(ps: &'a SyntaxSet, lang: &str) -> &'a SyntaxReference {
try_syntax_for_lang(ps, lang).unwrap_or_else(|| ps.find_syntax_plain_text())
}
// Removed unused helper to keep build warning-free.
fn span_from_syn((SynStyle { foreground, font_style, .. }, text): (SynStyle, &str)) -> Span<'static> {
use ratatui::style::{Color, Modifier, Style};
@@ -105,28 +414,42 @@ fn span_from_syn((SynStyle { foreground, font_style, .. }, text): (SynStyle, &st
/// Highlight a code block into ratatui Lines while preserving exact text.
pub(crate) fn highlight_code_block(content: &str, lang: Option<&str>) -> Vec<Line<'static>> {
let ps = syntax_set();
let theme = default_theme();
// Resolve language
let syntax = {
// Prefer the provided language when it maps to a known syntax.
if let Some(l) = lang.and_then(|l| if l.trim().is_empty() { None } else { Some(l) }) {
if let Some(s) = try_syntax_for_lang(ps, l) {
s
} else {
// Unknown language label: try a best-effort auto-detect before
// falling back to plain text.
autodetect_lang(content)
.map(|dl| syntax_for_lang(ps, dl))
.unwrap_or_else(|| ps.find_syntax_plain_text())
// Resolve across default and optional extra syntax sets
let mut ps = syntax_set();
let mut syntax = if let Some(l) = lang.and_then(|l| if l.trim().is_empty() { None } else { Some(l) }) {
if let Some(s) = try_syntax_for_lang(ps, l) { s } else if let Some(ref extra) = *extra_syntax_set() {
if let Some(s2) = try_syntax_for_lang(extra, l) { ps = extra; s2 } else { ps.find_syntax_plain_text() }
} else { ps.find_syntax_plain_text() }
} else { ps.find_syntax_plain_text() };
if std::ptr::eq(syntax, ps.find_syntax_plain_text()) {
if let Some(dl) = autodetect_lang(content) {
if let Some(s2) = try_syntax_for_lang(ps, dl) { syntax = s2; }
else if let Some(ref extra) = *extra_syntax_set() {
if let Some(s3) = try_syntax_for_lang(extra, dl) { ps = extra; syntax = s3; }
}
} else {
// No label provided: attempt auto-detection, otherwise plain text.
autodetect_lang(content)
.map(|dl| syntax_for_lang(ps, dl))
.unwrap_or_else(|| ps.find_syntax_plain_text())
}
};
if std::ptr::eq(syntax, ps.find_syntax_plain_text()) {
if let Some(first) = content.lines().next() {
if let Some(s4) = ps.find_syntax_by_first_line(first) { syntax = s4; }
else if let Some(ref extra) = *extra_syntax_set() {
if let Some(s5) = extra.find_syntax_by_first_line(first) { ps = extra; syntax = s5; }
}
}
}
}
// TOML special-case: if labelled or looks like Cargo/Clear TOML and still plain, try INI.
if (lang.map(|l| l.eq_ignore_ascii_case("toml")).unwrap_or(false) || content.contains("[package]"))
&& std::ptr::eq(syntax, ps.find_syntax_plain_text())
{
if let Some(sini) = ps.find_syntax_by_name("INI").or_else(|| ps.find_syntax_by_extension("ini")) { syntax = sini; }
else if let Some(ref extra) = *extra_syntax_set() {
if let Some(sini2) = extra.find_syntax_by_name("INI").or_else(|| extra.find_syntax_by_extension("ini")) { ps = extra; syntax = sini2; }
}
}
let mut highlighter = HighlightLines::new(syntax, theme);
let mut out: Vec<Line<'static>> = Vec::new();
@@ -206,58 +529,73 @@ fn normalize_lang(lang: &str) -> &str {
match l.as_str() {
// Shells
"sh" | "bash" | "zsh" | "shell" | "console" | "shellsession" => "bash",
"ps" | "ps1" | "pwsh" | "powershell" => "PowerShell",
"bat" | "cmd" | "batch" => "Batch File",
// Web
"html" | "htm" | "xhtml" => "html",
"xml" => "xml",
"css" => "css",
"scss" | "sass" => "scss",
"html" | "htm" | "xhtml" => "HTML",
"xml" => "XML",
"css" => "CSS",
"scss" | "sass" => "SCSS",
// JS/TS
"js" | "javascript" => "javascript",
"mjs" | "cjs" => "javascript",
"jsx" => "jsx",
"ts" | "typescript" => "typescript",
"tsx" => "tsx",
"js" | "javascript" => "JavaScript",
"mjs" | "cjs" => "JavaScript",
"jsx" => "JavaScript (JSX)",
"ts" | "typescript" => "TypeScript",
"tsx" => "TypeScriptReact",
// Data/config
"json" => "json",
"yaml" | "yml" => "yaml",
"toml" => "toml",
"json" => "JSON",
"yaml" | "yml" => "YAML",
"toml" => "TOML",
"ini" | "cfg" | "conf" | "dotenv" | ".env" => "ini",
"properties" => "Java Properties",
// Rust and friends
"rs" | "rust" => "rust",
"rs" | "rust" => "Rust",
"toml.lock" | "cargo.lock" => "TOML",
// Python
"py" | "python" | "py3" => "python",
"py" | "python" | "py3" => "Python",
// C-family
"c" => "c",
"h" => "c",
"cpp" | "c++" | "cxx" | "cc" | "hpp" | "hh" => "cpp",
"objc" | "objective-c" | "m" | "mm" => "objective-c",
"cs" | "csharp" => "cs",
"c" => "C",
"h" => "C",
"cpp" | "c++" | "cxx" | "cc" | "hpp" | "hh" => "C++",
"objc" | "objective-c" | "m" | "mm" => "Objective-C",
"cs" | "csharp" => "C#",
// Other popular
"go" => "go",
"rb" | "ruby" => "ruby",
"java" => "java",
"scala" => "scala",
"kt" | "kts" | "kotlin" => "kotlin",
"swift" => "swift",
"php" => "php",
"dart" => "dart",
"lua" => "lua",
"r" => "r",
"hs" | "haskell" => "haskell",
"go" => "Go",
"rb" | "ruby" => "Ruby",
"java" => "Java",
"scala" => "Scala",
"kt" | "kts" | "kotlin" => "Kotlin",
"swift" => "Swift",
"php" => "PHP",
"dart" => "Dart",
"lua" => "Lua",
"r" => "R",
"hs" | "haskell" => "Haskell",
"zig" => "zig",
"nim" => "nim",
"jl" | "julia" => "julia",
"ex" | "exs" | "elixir" => "elixir",
"erl" | "erlang" => "erlang",
"clj" | "cljs" | "cljc" | "edn" | "clojure" => "clojure",
"ml" | "mli" | "ocaml" => "OCaml",
// Infra / devops
"docker" | "dockerfile" => "docker",
"make" | "makefile" | "mk" => "make",
"cmake" | "cmakelists.txt" => "cmake",
"nix" => "nix",
"tf" | "terraform" => "terraform",
"hcl" => "hcl",
"docker" | "dockerfile" => "Dockerfile",
"make" | "makefile" | "mk" => "Makefile",
"cmake" | "cmakelists.txt" => "CMake",
"nix" => "Nix",
"tf" | "terraform" => "HCL",
"hcl" => "HCL",
"nginx" => "nginx",
"apache" | "apacheconf" | "htaccess" => "Apache Conf",
// Diffs/patches
"diff" | "patch" => "Diff",
// Markup / docs
"md" | "markdown" => "markdown",
"md" | "markdown" => "Markdown",
// Data formats and misc languages
"sql" => "sql",
"proto" | "protobuf" => "protobuf",
"graphql" | "gql" => "graphql",
"plantuml" | "puml" => "plantuml",
"sql" => "SQL",
"proto" | "protobuf" => "Protocol Buffer",
"graphql" | "gql" => "GraphQL",
"plantuml" | "puml" => "PlantUML",
// Fallback
other => Box::leak(other.to_string().into_boxed_str()),
}
@@ -359,13 +697,34 @@ fn autodetect_lang(content: &str) -> Option<&'static str> {
return Some("sql");
}
// 9) INI / TOML / YAML (rough heuristics)
// 9) INI / TOML / YAML (improved heuristics)
if let Some(first_line) = s.lines().find(|l| !l.trim().is_empty()) {
let fl = first_line.trim();
if fl.starts_with('[') && fl.ends_with(']') {
// Distinguish common TOML sections (Cargo.toml and dotted sections)
let section = fl.trim_matches(['[', ']']);
let is_toml_section = matches!(
section,
"package" | "dependencies" | "dev-dependencies" | "build-dependencies" |
"workspace" | "profile" | _ if section.contains('.') || section.contains('-')
);
if is_toml_section { return Some("toml"); }
}
}
// Strong TOML signals across first 50 lines
let toml_signals = {
let lines: Vec<&str> = s.lines().take(50).collect();
let has_dotted_keys = lines.iter().any(|l| l.trim_start().split_once('=').map(|(k, _)| k.contains('.')).unwrap_or(false));
let has_inline_table = lines.iter().any(|l| l.contains("={") || l.contains(" = {"));
let has_array = lines.iter().any(|l| l.contains("=[") || l.contains(" = ["));
let has_quoted_assign = lines.iter().any(|l| l.contains("= \"") || l.contains("=\""));
(has_dotted_keys as u8 + has_inline_table as u8 + has_array as u8 + has_quoted_assign as u8) >= 2
};
if toml_signals { return Some("toml"); }
// Fallback to INI if it looks like sectioned key=value without YAML/hints
if let Some(first_line) = s.lines().find(|l| !l.trim().is_empty()) {
let fl = first_line.trim();
if fl.starts_with('[') && fl.ends_with(']') {
if s.contains("[[") || s.contains("]]") || s.contains("=\"") {
return Some("toml");
}
if s.lines().any(|l| l.contains('=')) && !s.contains(": ") {
return Some("ini");
}
+2
View File
@@ -48,6 +48,8 @@ impl std::fmt::Debug for TerminalInfo {
pub fn init(config: &Config) -> Result<(Tui, TerminalInfo)> {
// Initialize the theme based on config
crate::theme::init_theme(&config.tui.theme);
// Initialize syntax highlighting preference from config
crate::syntax_highlight::init_highlight_from_config(&config.tui.highlight);
execute!(stdout(), EnableBracketedPaste)?;