fix(tui): preserve user message formatting
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¤t);
|
||||
last_break_pos = last_plain_break_pos(¤t);
|
||||
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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user