Skip to content

Commit 0a6a845

Browse files
author
Yogthos
committed
ui: center chat content + wrap to chamber width
Two parts working together: ## Wrap chat messages to chamber width Markdown rendering for assistant messages and the live token stream used `renderer.line_width()` (the full content band), so long replies spilled across the entire terminal even when the tool chambers below them were capped at 120 cols. Result: chat text was visually a different width from chambers — the eye saw two parallel layouts. Both render paths now wrap to `renderer.content_width()` minus the 8-col chat handle prefix (`<dirge> ` + space), so wrapped lines sit beneath the handle and the right edge matches chamber width. ## Center the chat band The whole chat content area (chamber tops/bottoms, chat lines, banner, prompt input row, status row, cursor) now indents by `content_indent()` columns to center within the visible chat band. On a 160-col terminal with the panel visible: - band width = 160 - 33 (panel) - 1 (divider gutter) = 126 cols - target content width = min(126, 120) = 120 cols - indent = (126 - 120) / 2 = 3 cols So the chat sits in a 120-col column with ~3 cols of margin each side, regardless of how wide the terminal is. On a 100-col terminal the indent is 0 — the chat uses the full band. Two new helpers on `Renderer`: - `content_width() -> usize` — cap at 120 cols. - `content_indent() -> usize` — left padding to center within the band. `render_viewport` applies the indent at paint time so the buffer itself stays raw (no rewrites needed when terminal width changes). The bottom rows (input + status + cursor) match. ## Test plan - [x] `cargo test --features plugin` -> 599 pass, 0 fail. - [x] Both build profiles -> 0 warnings. - [ ] Eyeball: on a wide terminal, verify chat + chambers share the same 120-col column and both visibly center under the chat band; verify cursor lands where text is being typed; verify status line + prompt align with chat above.
1 parent 7e91302 commit 0a6a845

3 files changed

Lines changed: 55 additions & 12 deletions

File tree

src/ui/events.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,13 @@ pub fn render_session(
8181
let cont_indent = " ".repeat(handle.chars().count());
8282

8383
if msg.role == MessageRole::Assistant {
84-
let max_width = renderer.line_width();
84+
// Wrap chat to the same width tool chambers use so chat
85+
// and chamber blocks line up visually. The 8-col handle
86+
// prefix is subtracted so wrapped continuation text fits
87+
// beneath the handle position.
88+
let max_width = renderer
89+
.content_width()
90+
.saturating_sub(handle.chars().count() + 1);
8591
let mut styled = markdown::markdown_to_styled(&msg.content, max_width);
8692
for (i, entry) in styled.iter_mut().enumerate() {
8793
if i == 0 {

src/ui/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,7 +1412,7 @@ pub async fn run_interactive(
14121412
continue;
14131413
}
14141414

1415-
let max_width = renderer.line_width();
1415+
let max_width = renderer.content_width().saturating_sub(9); // 8-col handle + space
14161416
let mut styled =
14171417
crate::ui::markdown::markdown_to_styled(&response_buf, max_width);
14181418

@@ -1603,7 +1603,7 @@ pub async fn run_interactive(
16031603
}
16041604

16051605
if !response_buf.is_empty() {
1606-
let max_width = renderer.line_width();
1606+
let max_width = renderer.content_width().saturating_sub(9); // 8-col handle + space
16071607
let mut styled = crate::ui::markdown::markdown_to_styled(
16081608
&response_buf,
16091609
max_width,
@@ -1812,7 +1812,7 @@ pub async fn run_interactive(
18121812
// the conversation history reflects what the user saw,
18131813
// not a phantom turn that "never happened".
18141814
if !response_buf.is_empty() {
1815-
let max_width = renderer.line_width();
1815+
let max_width = renderer.content_width().saturating_sub(9); // 8-col handle + space
18161816
let mut styled = crate::ui::markdown::markdown_to_styled(
18171817
&response_buf,
18181818
max_width,

src/ui/renderer.rs

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,22 @@ impl Renderer {
157157
self.max_line_width()
158158
}
159159

160+
/// Target width for chat content. Caps at 120 cols so wide
161+
/// terminals don't stretch chambers + chat lines into sprawling
162+
/// rivers of text. Matches the cap used by tool chambers.
163+
pub fn content_width(&self) -> usize {
164+
self.line_width().min(120)
165+
}
166+
167+
/// Left padding in columns to horizontally center the chat
168+
/// content area (`content_width`) within the visible chat band
169+
/// (`line_width`). Zero when content already fills the band.
170+
pub fn content_indent(&self) -> usize {
171+
let band = self.line_width();
172+
let target = self.content_width();
173+
band.saturating_sub(target) / 2
174+
}
175+
160176
pub fn buffer_len(&self) -> usize {
161177
self.buffer.len()
162178
}
@@ -373,12 +389,23 @@ impl Renderer {
373389
// `content_cols`). All chat text is clipped here; the remaining
374390
// columns belong to the divider + panel.
375391
let content_band = content_cols.saturating_sub(1) as usize;
392+
// Left indent in columns to horizontally center chat content
393+
// inside the band. We then clip the per-line text to
394+
// `content_band - indent` so wide content can't spill into
395+
// the divider/panel.
396+
let indent = self.content_indent();
397+
let line_cap = content_band.saturating_sub(indent);
376398
for i in 0..visible {
377399
stdout.execute(MoveTo(0, i as u16))?;
400+
// Paint indent spaces (no color) so any stale text on the
401+
// left edge from a wider previous line gets wiped.
402+
if indent > 0 {
403+
write!(stdout, "{}", " ".repeat(indent))?;
404+
}
378405
let text_chars: usize = if start + i < end {
379406
let entry = &self.buffer[start + i];
380407
let line_idx = start + i;
381-
let text: String = entry.text.chars().take(content_band).collect();
408+
let text: String = entry.text.chars().take(line_cap).collect();
382409
let actual_chars = text.chars().count();
383410

384411
let is_selected = self.selection_active
@@ -420,9 +447,12 @@ impl Renderer {
420447
0
421448
};
422449
if self.panel_visible() {
423-
// Manually pad to the content band; ClearType::UntilNewLine
424-
// would wipe the panel area to our right.
425-
let pad = content_band.saturating_sub(text_chars);
450+
// Pad to fill the content band so stale chars from
451+
// wider previous lines get wiped. With centering,
452+
// the written length per row is `indent + text_chars`;
453+
// the trailing pad fills `content_band - (indent + text)`.
454+
let written = indent + text_chars;
455+
let pad = content_band.saturating_sub(written);
426456
if pad > 0 {
427457
write!(stdout, "{}", " ".repeat(pad))?;
428458
}
@@ -728,12 +758,16 @@ impl Renderer {
728758
let display_chars: Vec<Vec<char>> =
729759
display_lines.iter().map(|s| s.chars().collect()).collect();
730760

761+
// Input rows + status row also center under the chat band so
762+
// the prompt + status visually align with the chat content
763+
// above.
764+
let bottom_indent = self.content_indent();
731765
for row_offset in 0..visible_input_rows {
732766
let row = input_top + row_offset as u16;
733767
let vr_idx = first_visible_visual + row_offset;
734768
stdout.execute(MoveTo(0, row))?;
735769
write!(stdout, "{}", " ".repeat(cols as usize))?;
736-
stdout.execute(MoveTo(0, row))?;
770+
stdout.execute(MoveTo(bottom_indent as u16, row))?;
737771
write!(
738772
stdout,
739773
"{}",
@@ -763,10 +797,10 @@ impl Renderer {
763797
write!(stdout, "{}", ResetColor)?;
764798
}
765799

766-
// Status row.
800+
// Status row — also centered under the chat band.
767801
stdout.execute(MoveTo(0, status_row))?;
768802
write!(stdout, "{}", " ".repeat(cols as usize))?;
769-
stdout.execute(MoveTo(0, status_row))?;
803+
stdout.execute(MoveTo(bottom_indent as u16, status_row))?;
770804
write!(
771805
stdout,
772806
"{}",
@@ -803,7 +837,10 @@ impl Renderer {
803837
let cursor_row =
804838
input_top + (cursor_visual_row.saturating_sub(first_visible_visual)) as u16;
805839
// Match the 3-column prompt prefix used in the loop above.
806-
let cursor_x = (3 + cursor_visual_col).min(cols.saturating_sub(1) as usize) as u16;
840+
// Match the indented prompt (`bottom_indent + 3-col prompt
841+
// prefix` + cursor offset within content).
842+
let cursor_x =
843+
(bottom_indent + 3 + cursor_visual_col).min(cols.saturating_sub(1) as usize) as u16;
807844
stdout.execute(MoveTo(cursor_x, cursor_row))?;
808845

809846
if self.panel_visible() {

0 commit comments

Comments
 (0)