Skip to content

Commit d85fcee

Browse files
authored
feat(tui): UX improvements and v0.9.1 release (#165)
* feat(tui): add mouse scroll support for chat widget * feat(tui): add ASCII art splash screen on startup * feat(tui): add colored splash screen with block-letter banner * feat(tui): load conversation history into chat on startup * feat(tui): render model thinking blocks in darker color Parse <think>...</think> tags in assistant messages and display thinking content with DarkGray style to visually distinguish intermediate reasoning from the final result. * fix(tui): fix scroll overflow and add scrollbar to chat widget Pre-wrap lines manually instead of relying on Paragraph::Wrap to ensure lines.len() matches visual line count. Clamp scroll_offset to max_scroll on every draw frame to prevent sticky scroll at top. Add scrollbar track with thumb indicator. * feat(tui): render chat messages with pulldown-cmark markdown Replace hand-rolled inline parser with pulldown-cmark AST walker. Supports bold, italic, strikethrough, inline code, fenced code blocks with language tags, headings, bullet/numbered lists, blockquotes, and horizontal rules. Thinking blocks (<think>) processed separately before markdown pass to preserve style segmentation. * release: prepare v0.9.1 * style(tui): fix rustfmt formatting in chat widget * fix: resolve clippy warnings in tests * fix(tui): resolve clippy warnings in chat widget
1 parent 111ecd7 commit d85fcee

File tree

16 files changed

+718
-58
lines changed

16 files changed

+718
-58
lines changed

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
## [0.9.1] - 2026-02-12
10+
11+
### Added
12+
- Mouse scroll support for TUI chat widget (scroll up/down via mouse wheel)
13+
- Splash screen with colored block-letter "ZEPH" banner on TUI startup
14+
- Conversation history loading into chat on TUI startup
15+
- Model thinking block rendering (`<think>` tags from Ollama DeepSeek/Qwen models) in distinct darker style
16+
- Markdown rendering for all chat messages via `pulldown-cmark`: bold, italic, strikethrough, headings, code blocks, inline code, lists, blockquotes, horizontal rules
17+
- Scrollbar track with proportional thumb indicator in chat widget
18+
19+
### Fixed
20+
- Chat messages no longer overflow below the viewport when lines wrap
21+
- Scroll no longer sticks at top after over-scrolling past content boundary
22+
923
## [0.9.0] - 2026-02-12
1024

1125
### Added

Cargo.lock

Lines changed: 11 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ resolver = "3"
55
[workspace.package]
66
edition = "2024"
77
rust-version = "1.88"
8-
version = "0.9.0"
8+
version = "0.9.1"
99
authors = ["bug-ops"]
1010
license = "MIT"
1111
repository = "https://github.com/bug-ops/zeph"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ cargo build --release --features tui
9393
| **A2A Protocol** | Agent-to-agent communication via JSON-RPC 2.0 with SSE streaming | [A2A](https://bug-ops.github.io/zeph/guide/a2a.html) |
9494
| **Model Orchestrator** | Route tasks to different providers with fallback chains | [Orchestrator](https://bug-ops.github.io/zeph/guide/orchestrator.html) |
9595
| **Self-Learning** | Skills evolve via failure detection and LLM-generated improvements | [Self-Learning](https://bug-ops.github.io/zeph/guide/self-learning.html) |
96-
| **TUI Dashboard** | ratatui terminal UI with live metrics, confirmation dialogs, responsive layout, multiline input | [TUI](https://bug-ops.github.io/zeph/guide/tui.html) |
96+
| **TUI Dashboard** | ratatui terminal UI with markdown rendering, scrollbar, mouse scroll, thinking blocks, conversation history, splash screen, live metrics | [TUI](https://bug-ops.github.io/zeph/guide/tui.html) |
9797
| **Multi-Channel I/O** | CLI, Telegram, and TUI with streaming support | [Channels](https://bug-ops.github.io/zeph/guide/channels.html) |
9898
| **Defense-in-Depth** | Shell sandbox, command filter, secret redaction, audit log, SSRF protection | [Security](https://bug-ops.github.io/zeph/security.html) |
9999

crates/zeph-core/src/agent.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,11 @@ impl<P: LlmProvider + Clone + 'static, C: Channel, T: ToolExecutor> Agent<P, C,
223223
}
224224
}
225225

226+
#[must_use]
227+
pub fn context_messages(&self) -> &[Message] {
228+
&self.messages
229+
}
230+
226231
/// Load conversation history from memory and inject into messages.
227232
///
228233
/// # Errors

crates/zeph-tui/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ repository.workspace = true
99
[dependencies]
1010
anyhow.workspace = true
1111
crossterm.workspace = true
12+
pulldown-cmark.workspace = true
1213
ratatui.workspace = true
1314
tokio = { workspace = true, features = ["sync", "rt", "time"] }
1415
tracing.workspace = true

crates/zeph-tui/src/app.rs

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ pub struct App {
4545
cursor_position: usize,
4646
input_mode: InputMode,
4747
messages: Vec<ChatMessage>,
48+
show_splash: bool,
4849
scroll_offset: usize,
4950
pub metrics: MetricsSnapshot,
5051
metrics_rx: Option<watch::Receiver<MetricsSnapshot>>,
@@ -66,6 +67,7 @@ impl App {
6667
cursor_position: 0,
6768
input_mode: InputMode::Insert,
6869
messages: Vec::new(),
70+
show_splash: true,
6971
scroll_offset: 0,
7072
metrics: MetricsSnapshot::default(),
7173
metrics_rx: None,
@@ -77,6 +79,29 @@ impl App {
7779
}
7880
}
7981

82+
#[must_use]
83+
pub fn show_splash(&self) -> bool {
84+
self.show_splash
85+
}
86+
87+
pub fn load_history(&mut self, messages: &[(&str, &str)]) {
88+
for &(role_str, content) in messages {
89+
let role = match role_str {
90+
"user" => MessageRole::User,
91+
"assistant" => MessageRole::Assistant,
92+
_ => continue,
93+
};
94+
self.messages.push(ChatMessage {
95+
role,
96+
content: content.to_owned(),
97+
streaming: false,
98+
});
99+
}
100+
if !self.messages.is_empty() {
101+
self.show_splash = false;
102+
}
103+
}
104+
80105
#[must_use]
81106
pub fn with_metrics_rx(mut self, rx: watch::Receiver<MetricsSnapshot>) -> Self {
82107
self.metrics_rx = Some(rx);
@@ -123,6 +148,15 @@ impl App {
123148
match event {
124149
AppEvent::Key(key) => self.handle_key(key),
125150
AppEvent::Tick | AppEvent::Resize(_, _) => {}
151+
AppEvent::MouseScroll(delta) => {
152+
if self.confirm_state.is_none() {
153+
if delta > 0 {
154+
self.scroll_offset = self.scroll_offset.saturating_add(1);
155+
} else {
156+
self.scroll_offset = self.scroll_offset.saturating_sub(1);
157+
}
158+
}
159+
}
126160
AppEvent::Agent(agent_event) => self.handle_agent_event(agent_event),
127161
}
128162
Ok(())
@@ -182,11 +216,16 @@ impl App {
182216
self.confirm_state.as_ref()
183217
}
184218

185-
pub fn draw(&self, frame: &mut ratatui::Frame) {
219+
pub fn draw(&mut self, frame: &mut ratatui::Frame) {
186220
let layout = AppLayout::compute(frame.area());
187221

188222
self.draw_header(frame, layout.header);
189-
widgets::chat::render(self, frame, layout.chat);
223+
if self.show_splash {
224+
widgets::splash::render(frame, layout.chat);
225+
} else {
226+
let max_scroll = widgets::chat::render(self, frame, layout.chat);
227+
self.scroll_offset = self.scroll_offset.min(max_scroll);
228+
}
190229
self.draw_side_panel(frame, &layout);
191230
widgets::input::render(self, frame, layout.input);
192231
widgets::status::render(self, &self.metrics, frame, layout.status);
@@ -356,6 +395,7 @@ impl App {
356395
if text.is_empty() {
357396
return;
358397
}
398+
self.show_splash = false;
359399
self.messages.push(ChatMessage {
360400
role: MessageRole::User,
361401
content: text.clone(),
@@ -377,7 +417,8 @@ mod tests {
377417
fn make_app() -> (App, mpsc::Receiver<String>, mpsc::Sender<AgentEvent>) {
378418
let (user_tx, user_rx) = mpsc::channel(16);
379419
let (agent_tx, agent_rx) = mpsc::channel(16);
380-
let app = App::new(user_tx, agent_rx);
420+
let mut app = App::new(user_tx, agent_rx);
421+
app.messages.clear();
381422
(app, user_rx, agent_tx)
382423
}
383424

@@ -387,6 +428,7 @@ mod tests {
387428
assert!(app.input().is_empty());
388429
assert_eq!(app.input_mode(), InputMode::Insert);
389430
assert!(app.messages().is_empty());
431+
assert!(app.show_splash());
390432
assert!(!app.should_quit);
391433
}
392434

@@ -732,4 +774,49 @@ mod tests {
732774
assert_eq!(app.input(), "a\nb");
733775
assert_eq!(app.cursor_position(), 2);
734776
}
777+
778+
#[test]
779+
fn mouse_scroll_up() {
780+
let (mut app, _rx, _tx) = make_app();
781+
assert_eq!(app.scroll_offset(), 0);
782+
app.handle_event(AppEvent::MouseScroll(1)).unwrap();
783+
assert_eq!(app.scroll_offset(), 1);
784+
app.handle_event(AppEvent::MouseScroll(1)).unwrap();
785+
assert_eq!(app.scroll_offset(), 2);
786+
}
787+
788+
#[test]
789+
fn mouse_scroll_down() {
790+
let (mut app, _rx, _tx) = make_app();
791+
app.scroll_offset = 5;
792+
app.handle_event(AppEvent::MouseScroll(-1)).unwrap();
793+
assert_eq!(app.scroll_offset(), 4);
794+
app.handle_event(AppEvent::MouseScroll(-1)).unwrap();
795+
assert_eq!(app.scroll_offset(), 3);
796+
}
797+
798+
#[test]
799+
fn mouse_scroll_down_saturates_at_zero() {
800+
let (mut app, _rx, _tx) = make_app();
801+
app.scroll_offset = 1;
802+
app.handle_event(AppEvent::MouseScroll(-1)).unwrap();
803+
assert_eq!(app.scroll_offset(), 0);
804+
app.handle_event(AppEvent::MouseScroll(-1)).unwrap();
805+
assert_eq!(app.scroll_offset(), 0);
806+
}
807+
808+
#[test]
809+
fn mouse_scroll_during_confirm_blocked() {
810+
let (mut app, _rx, _tx) = make_app();
811+
let (tx, _oneshot_rx) = tokio::sync::oneshot::channel();
812+
app.confirm_state = Some(ConfirmState {
813+
prompt: "test?".into(),
814+
response_tx: Some(tx),
815+
});
816+
app.scroll_offset = 5;
817+
app.handle_event(AppEvent::MouseScroll(1)).unwrap();
818+
assert_eq!(app.scroll_offset(), 5);
819+
app.handle_event(AppEvent::MouseScroll(-1)).unwrap();
820+
assert_eq!(app.scroll_offset(), 5);
821+
}
735822
}

crates/zeph-tui/src/event.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
use std::time::Duration;
22

3-
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent};
3+
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEventKind};
44
use tokio::sync::{mpsc, oneshot};
55

66
#[derive(Debug)]
77
pub enum AppEvent {
88
Key(KeyEvent),
99
Tick,
1010
Resize(u16, u16),
11+
MouseScroll(i8),
1112
Agent(AgentEvent),
1213
}
1314

@@ -41,6 +42,11 @@ impl EventReader {
4142
let evt = match event::read() {
4243
Ok(CrosstermEvent::Key(key)) => AppEvent::Key(key),
4344
Ok(CrosstermEvent::Resize(w, h)) => AppEvent::Resize(w, h),
45+
Ok(CrosstermEvent::Mouse(mouse)) => match mouse.kind {
46+
MouseEventKind::ScrollUp => AppEvent::MouseScroll(1),
47+
MouseEventKind::ScrollDown => AppEvent::MouseScroll(-1),
48+
_ => continue,
49+
},
4450
_ => continue,
4551
};
4652
if self.tx.blocking_send(evt).is_err() {
@@ -91,4 +97,13 @@ mod tests {
9197
assert!(s.contains("ConfirmRequest"));
9298
assert!(s.contains("delete?"));
9399
}
100+
101+
#[test]
102+
fn app_event_mouse_scroll_variant() {
103+
let scroll_up = AppEvent::MouseScroll(1);
104+
assert!(matches!(scroll_up, AppEvent::MouseScroll(1)));
105+
106+
let scroll_down = AppEvent::MouseScroll(-1);
107+
assert!(matches!(scroll_down, AppEvent::MouseScroll(-1)));
108+
}
94109
}

crates/zeph-tui/src/theme.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ pub struct Theme {
1111
pub panel_title: Style,
1212
pub highlight: Style,
1313
pub error: Style,
14+
pub thinking_message: Style,
15+
pub code_inline: Style,
16+
pub code_block: Style,
1417
pub streaming_cursor: Style,
1518
}
1619

@@ -31,6 +34,9 @@ impl Default for Theme {
3134
.add_modifier(Modifier::BOLD),
3235
highlight: Style::default().fg(Color::Green),
3336
error: Style::default().fg(Color::Red),
37+
thinking_message: Style::default().fg(Color::DarkGray),
38+
code_inline: Style::default().fg(Color::Yellow),
39+
code_block: Style::default().fg(Color::Green),
3440
streaming_cursor: Style::default()
3541
.fg(Color::Yellow)
3642
.add_modifier(Modifier::SLOW_BLINK),

0 commit comments

Comments
 (0)