fix(tui): preserve user message formatting

This commit is contained in:
Hunter Bown
2026-05-23 21:11:26 -05:00
parent 86fda2705f
commit 90a9dfbe7a
13 changed files with 308 additions and 16 deletions
+11
View File
@@ -9,8 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.8.42] - 2026-05-24
### Changed
- **CodeWhale positioning is clarified as DeepSeek-first and open-model
oriented.** README, rebrand notes, crate metadata, and npm package text now
describe CodeWhale as an agentic terminal for open source and open-weight
coding models while preserving the official DeepSeek provider as first-class.
### Fixed
- **User-authored messages render as literal plain text.** Leading whitespace,
whitespace-only lines, repeated spaces, and Markdown-looking `#` / `-` text
now survive in transcript history, while assistant messages still render
Markdown normally.
- **Stream decode failures no longer leave the turn visually stuck.** The UI
now marks an active turn failed and flushes live cells as soon as the engine
emits a stream error, so the sidebar/footer recover without requiring
+1 -1
View File
@@ -1,6 +1,6 @@
# 🐳 CodeWhale
> **このターミナルネイティブのコーディングエージェントは、DeepSeek V4 の 100 万トークンのコンテキストウィンドウとプレフィックスキャッシュ機能を中心に構築されています。単一のバイナリとして配布され、Node.js や Python のランタイムは不要です。MCP クライアント、サンドボックス、永続的なタスクキューも標準で同梱されています。**
> **DeepSeek ファーストで、オープンソースおよびオープンウェイトのコーディングモデルに向けたターミナルネイティブのコーディングエージェントです。DeepSeek V4 の 100 万トークンのコンテキストウィンドウとプレフィックスキャッシュ機能を中心に構築されています。単一のバイナリとして配布され、Node.js や Python のランタイムは不要です。MCP クライアント、サンドボックス、永続的なタスクキューも標準で同梱されています。**
[English README](README.md)
[简体中文 README](README.zh-CN.md)
+6 -2
View File
@@ -1,6 +1,6 @@
# CodeWhale
> Terminal coding agent for DeepSeek V4. It runs from the `codewhale` command, streams reasoning blocks, edits local workspaces with approval gates, and includes an auto mode that chooses both model and thinking level per turn.
> DeepSeek-first agentic terminal for open source and open-weight coding models. It runs from the `codewhale` command, streams reasoning blocks, edits local workspaces with approval gates, and includes an auto mode that chooses both model and thinking level per turn.
[简体中文 README](README.zh-CN.md)
[日本語 README](README.ja-JP.md)
@@ -72,7 +72,7 @@ cargo install codewhale-tui --locked --force
## What Is It?
CodeWhale is a coding agent that runs in your terminal. It can read and edit files, run shell commands, search the web, manage git, and coordinate sub-agents from a keyboard-driven TUI.
CodeWhale is a DeepSeek-first coding agent for open source and open-weight models that runs in your terminal. It can read and edit files, run shell commands, search the web, manage git, and coordinate sub-agents from a keyboard-driven TUI.
It is built around DeepSeek V4 (`deepseek-v4-pro` / `deepseek-v4-flash`), including 1M-token context windows, streaming reasoning blocks, and prefix-cache-aware cost reporting.
@@ -246,6 +246,10 @@ Both binaries are required. Cross-compilation and platform-specific notes: [docs
### Other API Providers
Official DeepSeek remains the default and first-class path. Other providers are
additive, with OpenRouter starting from DeepSeek Pro/Flash before broader
open-model catalogs are enabled.
```bash
# NVIDIA NIM
codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"
+1 -1
View File
@@ -1,6 +1,6 @@
# CodeWhale
> **面向 [DeepSeek V4](https://platform.deepseek.com) 的终端原生编程智能体:100 万 token 上下文、思考模式流式推理、前缀缓存感知。自包含 Rust 二进制发布——开箱即带 MCP 客户端、沙箱和持久化任务队列。**
> **DeepSeek 优先、面向开源与开放权重编码模型的终端原生编程智能体:100 万 token 上下文、思考模式流式推理、前缀缓存感知。自包含 Rust 二进制发布——开箱即带 MCP 客户端、沙箱和持久化任务队列。**
[English README](README.md)
[日本語 README](README.ja-JP.md)
+1 -1
View File
@@ -4,7 +4,7 @@ version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Codex-style CLI facade for DeepSeek workspace architecture"
description = "DeepSeek-first agentic terminal facade for open coding models"
[[bin]]
name = "codewhale"
+11
View File
@@ -9,8 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.8.42] - 2026-05-24
### Changed
- **CodeWhale positioning is clarified as DeepSeek-first and open-model
oriented.** README, rebrand notes, crate metadata, and npm package text now
describe CodeWhale as an agentic terminal for open source and open-weight
coding models while preserving the official DeepSeek provider as first-class.
### Fixed
- **User-authored messages render as literal plain text.** Leading whitespace,
whitespace-only lines, repeated spaces, and Markdown-looking `#` / `-` text
now survive in transcript history, while assistant messages still render
Markdown normally.
- **Stream decode failures no longer leave the turn visually stuck.** The UI
now marks an active turn failed and flushes live cells as soon as the engine
emits a stream error, so the sidebar/footer recover without requiring
+1 -1
View File
@@ -4,7 +4,7 @@ version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Terminal UI for DeepSeek"
description = "DeepSeek-first terminal UI for open coding models"
default-run = "codewhale-tui"
[features]
+18
View File
@@ -2342,6 +2342,24 @@ mod stream_decoder_tests {
);
}
#[test]
fn decoder_accepts_openrouter_reasoning_delta_with_extra_fields() {
let events = decode_chunk(
r#"{"id":"or-1","choices":[{"delta":{"reasoning":"openrouter thought","reasoning_details":[{"type":"summary","text":"extra"}],"native_finish_reason":null}}],"usage":{"completion_tokens_details":{"reasoning_tokens":3}}}"#,
);
assert!(
events.iter().any(|e| matches!(
e,
StreamEvent::ContentBlockDelta {
delta: Delta::ThinkingDelta { thinking },
..
} if thinking == "openrouter thought"
)),
"OpenRouter-style reasoning deltas with extra fields should not crash decoding; got {events:?}"
);
}
#[test]
fn decoder_treats_reasoning_content_as_text_when_provider_does_not_support_reasoning() {
let events = decode_chunk_with_reasoning(
+101 -3
View File
@@ -180,7 +180,7 @@ impl HistoryCell {
/// `transcript_lines`.
pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
match self {
HistoryCell::User { content } => render_message(
HistoryCell::User { content } => render_plain_message(
USER_GLYPH,
user_label_style(),
user_body_style(),
@@ -284,7 +284,7 @@ impl HistoryCell {
lines
}
HistoryCell::Tool(cell) => cell.lines_with_motion(width, options.low_motion),
HistoryCell::User { content } => render_message(
HistoryCell::User { content } => render_plain_message(
USER_GLYPH,
user_label_style(),
user_body_style(),
@@ -316,7 +316,7 @@ impl HistoryCell {
/// diverge.
pub fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
match self {
HistoryCell::User { content } => render_message(
HistoryCell::User { content } => render_plain_message(
USER_GLYPH,
user_label_style(),
user_body_style(),
@@ -2237,6 +2237,56 @@ fn render_message(
lines
}
/// Render a plain-text user message: split on newlines, word-wrap each line,
/// preserve leading whitespace. No markdown interpretation (headings, lists,
/// code blocks, etc. are rendered as literal text).
fn render_plain_message(
prefix: &str,
label_style: Style,
body_style: Style,
content: &str,
width: u16,
) -> Vec<Line<'static>> {
let prefix_width = UnicodeWidthStr::width(prefix);
let prefix_width_u16 = u16::try_from(prefix_width.saturating_add(2)).unwrap_or(u16::MAX);
let content_width = width.saturating_sub(prefix_width_u16).max(1);
let rendered = markdown_render::render_plain_text(content, content_width, body_style);
let mut lines = Vec::with_capacity(rendered.len());
for (idx, line) in rendered.into_iter().enumerate() {
if idx == 0 {
let mut spans = Vec::new();
if !prefix.is_empty() {
spans.push(Span::styled(
prefix.to_string(),
label_style.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
}
spans.extend(line.spans);
lines.push(Line::from(spans));
} else {
let indent = if prefix.is_empty() {
String::new()
} else {
let mut s = String::with_capacity(prefix_width + 1);
s.push('\u{258F}');
s.extend(std::iter::repeat_n(' ', prefix_width));
s
};
let rail_style = Style::default().fg(palette::TEXT_DIM);
let mut spans = vec![Span::styled(indent, rail_style)];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
}
if lines.is_empty() {
lines.push(Line::from(""));
}
lines
}
fn render_command_mode(command: &str, width: u16, mode: RenderMode) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let cap = match mode {
@@ -3787,6 +3837,32 @@ mod tests {
assert!(visible.contains("hello"));
}
#[test]
fn user_cell_renders_plain_text_without_markdown_interpretation() {
let cell = HistoryCell::User {
content: " # heading\n- item\n \nhello world".to_string(),
};
let visible: Vec<String> = cell.lines(80).iter().map(line_text).collect();
assert_eq!(visible[0], format!("{USER_GLYPH} # heading"));
assert!(
visible[1].ends_with("- item"),
"dash-prefixed text must remain literal: {visible:?}"
);
assert!(
visible[2].ends_with(" "),
"whitespace-only lines must survive: {visible:?}"
);
assert!(
visible[3].ends_with("hello world"),
"internal spacing must remain literal: {visible:?}"
);
assert!(
!visible.iter().any(|line| line.contains('\u{2500}')),
"plain user heading must not add markdown heading rule: {visible:?}"
);
}
#[test]
fn assistant_cell_renders_with_bullet_glyph_not_literal_label() {
let cell = HistoryCell::Assistant {
@@ -3808,6 +3884,28 @@ mod tests {
assert!(visible.contains("ready"));
}
#[test]
fn assistant_cell_still_renders_markdown() {
let cell = HistoryCell::Assistant {
content: "# Heading\n\n- item".to_string(),
streaming: false,
};
let visible: Vec<String> = cell.lines(80).iter().map(line_text).collect();
assert!(
visible[0].contains("Heading"),
"assistant heading text should render: {visible:?}"
);
assert!(
!visible[0].contains("# Heading"),
"assistant heading should still be parsed as markdown: {visible:?}"
);
assert!(
visible.iter().any(|line| line.contains('\u{2500}')),
"assistant h1 markdown should still add a heading rule: {visible:?}"
);
}
#[test]
fn assistant_code_block_lines_do_not_get_transcript_rail() {
let cell = HistoryCell::Assistant {
+149 -2
View File
@@ -168,13 +168,14 @@ pub fn parse(content: &str) -> ParsedMarkdown {
None => {}
}
if raw_line.is_empty() {
if trimmed.is_empty() {
// Whitespace-only lines are blank paragraphs.
blocks.push(Block::Blank);
continue;
}
blocks.push(Block::Paragraph {
text: trimmed.to_string(),
text: raw_line.to_string(),
});
}
@@ -331,6 +332,105 @@ pub fn render_markdown_tagged(
render_parsed_tagged(&parsed, width, base_style)
}
/// Render plain text: split on newlines, word-wrap each line independently,
/// preserve leading whitespace and blank lines. No markdown interpretation.
#[must_use]
pub fn render_plain_text(content: &str, width: u16, base_style: Style) -> Vec<Line<'static>> {
let width = width.max(1) as usize;
let mut lines = Vec::new();
for raw_line in content.split('\n') {
if raw_line.is_empty() {
lines.push(Line::from(""));
} else {
lines.extend(wrap_plain_line(raw_line, width, base_style));
}
}
if lines.is_empty() {
lines.push(Line::from(""));
}
lines
}
/// Word-wrap a single line at `width`, preserving leading whitespace.
/// Handles over-long words by char-breaking (same strategy as the markdown
/// line renderer).
fn wrap_plain_line(line: &str, width: usize, style: Style) -> Vec<Line<'static>> {
if width == 0 || line.is_empty() {
return vec![Line::from("")];
}
let mut chunks = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
let mut last_break_pos = None;
for ch in line.chars() {
loop {
let ch_width = char_display_width(ch, current_width);
if current_width + ch_width <= width || current.is_empty() {
break;
}
if let Some(pos) = last_break_pos {
if pos == current.len() {
chunks.push(std::mem::take(&mut current));
current_width = 0;
last_break_pos = None;
break;
}
if current[..pos].chars().any(|c| !c.is_whitespace()) {
let tail = current.split_off(pos);
chunks.push(std::mem::take(&mut current));
current = tail;
current_width = plain_display_width(&current);
last_break_pos = last_plain_break_pos(&current);
continue;
}
}
chunks.push(std::mem::take(&mut current));
current_width = 0;
last_break_pos = None;
break;
}
let ch_width = char_display_width(ch, current_width);
current.push(ch);
current_width += ch_width;
if ch.is_whitespace() {
last_break_pos = Some(current.len());
}
}
if !current.is_empty() {
chunks.push(current);
}
if chunks.is_empty() {
return vec![Line::from("")];
}
chunks
.into_iter()
.map(|chunk| Line::from(vec![Span::styled(chunk, style)]))
.collect()
}
fn plain_display_width(text: &str) -> usize {
let mut width = 0usize;
for ch in text.chars() {
width += char_display_width(ch, width);
}
width
}
fn last_plain_break_pos(text: &str) -> Option<usize> {
text.char_indices()
.rev()
.find_map(|(idx, ch)| ch.is_whitespace().then_some(idx + ch.len_utf8()))
}
fn parse_heading(line: &str) -> Option<(usize, &str)> {
let trimmed = line.trim_start();
let hashes = trimmed.chars().take_while(|c| *c == '#').count();
@@ -1044,6 +1144,18 @@ mod tests {
use super::*;
use ratatui::style::Style;
fn visible_lines(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
})
.collect()
}
#[test]
fn underscores_inside_identifiers_render_as_literal_text() {
// Regression for PR #1455 / @tiger-dog: previously the inline
@@ -1093,6 +1205,41 @@ mod tests {
assert_eq!(direct, two_step);
}
#[test]
fn render_plain_text_preserves_literal_markdown_and_spacing() {
let source = " # heading\n- item\n \nhello world\n";
let lines = render_plain_text(source, 80, Style::default());
assert_eq!(
visible_lines(&lines),
vec![" # heading", "- item", " ", "hello world", ""]
);
}
#[test]
fn render_plain_text_wraps_without_collapsing_spaces() {
let source = "alpha beta gamma";
let lines = render_plain_text(source, 12, Style::default());
for width in rendered_widths(&lines) {
assert!(width <= 12, "rendered width {width} exceeds budget");
}
let combined = visible_lines(&lines).join("");
assert_eq!(combined, source);
}
#[test]
fn render_plain_text_breaks_overlong_words() {
let source = "x".repeat(40);
let lines = render_plain_text(&source, 9, Style::default());
for width in rendered_widths(&lines) {
assert!(width <= 9, "rendered width {width} exceeds budget");
}
let combined = visible_lines(&lines).join("");
assert_eq!(combined, source);
}
#[test]
fn parse_is_width_independent() {
// Same source, two parses, must produce identical AST. (Sanity:
+5 -3
View File
@@ -120,9 +120,11 @@ still verifies.
## Why the name change
CodeWhale is a shorter, terminal-friendlier handle for the same terminal
coding agent. In v0.8.41 it remains centered on DeepSeek V4 while the project
name, command names, package names, release assets, Docker image, and CNB
mirror move to CodeWhale.
coding agent and the longer-term product direction: a DeepSeek-first agentic
terminal for open source and open-weight coding models. The project name,
command names, package names, release assets, Docker image, and CNB mirror move
to CodeWhale; the official DeepSeek provider, model IDs, env vars, and
`~/.deepseek/` config surface remain first-class.
## Reporting issues with the rename
+2 -1
View File
@@ -1,6 +1,7 @@
# codewhale
Install and run the `codewhale` and `codewhale-tui` binaries from GitHub release artifacts.
Install and run CodeWhale, a DeepSeek-first agentic terminal for open coding
models, from GitHub release artifacts.
> Previously published as `deepseek-tui`. See `docs/REBRAND.md` in the upstream
> repository for the migration notes; the legacy `deepseek-tui` npm package
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "codewhale",
"version": "0.8.42",
"codewhaleBinaryVersion": "0.8.42",
"description": "Install and run the codewhale CLI dispatcher and codewhale-tui terminal UI from GitHub release artifacts.",
"description": "Install and run CodeWhale, a DeepSeek-first agentic terminal for open coding models, from GitHub release artifacts.",
"author": "Hmbown",
"license": "MIT",
"homepage": "https://github.com/Hmbown/CodeWhale",