Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]

### Added
- Clickable markdown links in TUI via OSC 8 hyperlinks — `[text](url)` renders as terminal-clickable link with URL sanitization and scheme allowlist (#580)
- `@`-triggered fuzzy file picker in TUI input — type `@` to search project files by name/path/extension with real-time filtering (#600)
- Orchestrator provider option in `zeph init` wizard for multi-model routing setup (#597)
- `zeph vault` CLI subcommands: `init` (generate age keypair), `set` (store secret), `get` (retrieve secret), `list` (show keys), `rm` (remove secret) (#598)
Expand Down
1 change: 1 addition & 0 deletions crates/zeph-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Provides a terminal UI for monitoring the Zeph agent in real time. Built on rata
- **event** — `AgentEvent`, `AppEvent`, `EventReader` for async event dispatch
- **file_picker** — `@`-triggered fuzzy file search with `nucleo-matcher` and `ignore` crate
- **highlight** — syntax highlighting for code blocks
- **hyperlink** — OSC 8 clickable hyperlinks for bare URLs and markdown links
- **layout** — panel arrangement and responsive grid
- **metrics** — `MetricsCollector`, `MetricsSnapshot` for live telemetry
- **theme** — color palette and style definitions
Expand Down
82 changes: 69 additions & 13 deletions crates/zeph-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use crate::layout::AppLayout;
use crate::metrics::MetricsSnapshot;
use crate::theme::Theme;
use crate::widgets;
use crate::widgets::chat::MdLink;
use crate::widgets::command_palette::CommandPaletteState;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand All @@ -28,6 +29,7 @@ pub struct RenderCacheKey {
pub struct RenderCacheEntry {
pub key: RenderCacheKey,
pub lines: Vec<Line<'static>>,
pub md_links: Vec<MdLink>,
}

#[derive(Default)]
Expand All @@ -36,19 +38,29 @@ pub struct RenderCache {
}

impl RenderCache {
pub fn get(&self, idx: usize, key: &RenderCacheKey) -> Option<&[Line<'static>]> {
pub fn get(&self, idx: usize, key: &RenderCacheKey) -> Option<(&[Line<'static>], &[MdLink])> {
self.entries
.get(idx)
.and_then(Option::as_ref)
.filter(|e| &e.key == key)
.map(|e| e.lines.as_slice())
.map(|e| (e.lines.as_slice(), e.md_links.as_slice()))
}

pub fn put(&mut self, idx: usize, key: RenderCacheKey, lines: Vec<Line<'static>>) {
pub fn put(
&mut self,
idx: usize,
key: RenderCacheKey,
lines: Vec<Line<'static>>,
md_links: Vec<MdLink>,
) {
if idx >= self.entries.len() {
self.entries.resize_with(idx + 1, || None);
}
self.entries[idx] = Some(RenderCacheEntry { key, lines });
self.entries[idx] = Some(RenderCacheEntry {
key,
lines,
md_links,
});
}

pub fn invalidate(&mut self, idx: usize) {
Expand Down Expand Up @@ -1934,6 +1946,50 @@ mod tests {
let output = draw_app(&mut app, 80, 24);
assert!(!output.contains("Type a message"));
}

#[test]
fn markdown_link_produces_hyperlink_span() {
let (mut app, _rx, _tx) = make_app();
app.show_splash = false;
app.messages.push(ChatMessage {
role: MessageRole::Assistant,
content: "See [docs](https://docs.rs) for details".into(),
streaming: false,
tool_name: None,
diff_data: None,
filter_stats: None,
});

let _ = draw_app(&mut app, 80, 24);
let links = app.take_hyperlinks();
let doc_link = links.iter().find(|s| s.url == "https://docs.rs");
assert!(
doc_link.is_some(),
"expected hyperlink span for markdown link, got: {links:?}"
);
}

#[test]
fn bare_url_still_produces_hyperlink_span() {
let (mut app, _rx, _tx) = make_app();
app.show_splash = false;
app.messages.push(ChatMessage {
role: MessageRole::Assistant,
content: "Visit https://example.com today".into(),
streaming: false,
tool_name: None,
diff_data: None,
filter_stats: None,
});

let _ = draw_app(&mut app, 80, 24);
let links = app.take_hyperlinks();
let bare = links.iter().find(|s| s.url == "https://example.com");
assert!(
bare.is_some(),
"expected hyperlink span for bare URL, got: {links:?}"
);
}
}

#[test]
Expand Down Expand Up @@ -2152,8 +2208,8 @@ mod tests {
let mut cache = RenderCache::default();
let key = make_key(42, 80);
let lines = vec![Line::from(Span::raw("hello"))];
cache.put(0, key, lines.clone());
let result = cache.get(0, &key).unwrap();
cache.put(0, key, lines.clone(), vec![]);
let (result, _) = cache.get(0, &key).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].spans[0].content, "hello");
}
Expand All @@ -2164,7 +2220,7 @@ mod tests {
let key1 = make_key(1, 80);
let key2 = make_key(2, 80);
let lines = vec![Line::from(Span::raw("a"))];
cache.put(0, key1, lines);
cache.put(0, key1, lines, vec![]);
assert!(cache.get(0, &key2).is_none());
}

Expand All @@ -2174,7 +2230,7 @@ mod tests {
let key80 = make_key(1, 80);
let key100 = make_key(1, 100);
let lines = vec![Line::from(Span::raw("b"))];
cache.put(0, key80, lines);
cache.put(0, key80, lines, vec![]);
assert!(cache.get(0, &key100).is_none());
}

Expand All @@ -2183,7 +2239,7 @@ mod tests {
let mut cache = RenderCache::default();
let key = make_key(1, 80);
let lines = vec![Line::from(Span::raw("x"))];
cache.put(0, key, lines);
cache.put(0, key, lines, vec![]);
assert!(cache.get(0, &key).is_some());
cache.invalidate(0);
assert!(cache.get(0, &key).is_none());
Expand All @@ -2200,8 +2256,8 @@ mod tests {
let mut cache = RenderCache::default();
let key0 = make_key(1, 80);
let key1 = make_key(2, 80);
cache.put(0, key0, vec![Line::from(Span::raw("a"))]);
cache.put(1, key1, vec![Line::from(Span::raw("b"))]);
cache.put(0, key0, vec![Line::from(Span::raw("a"))], vec![]);
cache.put(1, key1, vec![Line::from(Span::raw("b"))], vec![]);
cache.clear();
assert!(cache.get(0, &key0).is_none());
assert!(cache.get(1, &key1).is_none());
Expand All @@ -2212,8 +2268,8 @@ mod tests {
let mut cache = RenderCache::default();
let key = make_key(5, 80);
let lines = vec![Line::from(Span::raw("z"))];
cache.put(5, key, lines);
let result = cache.get(5, &key).unwrap();
cache.put(5, key, lines, vec![]);
let (result, _) = cache.get(5, &key).unwrap();
assert_eq!(result[0].spans[0].content, "z");
}
}
Expand Down
Loading
Loading