feat(commands): add /change slash command to display latest CHANGELOG entry

`/change` reads the most recent `## [version]` section from the
workspace `CHANGELOG.md` (or the bundled release-notes copy when
no workspace changelog is available) and renders it inline in the
TUI. On non-English locales the command also queues a model-side
translation request so localised users see the changelog text in
their UI language; with no API key configured, the offline path
returns the section verbatim with a brief explanatory header.

Lets users discover what changed in the version they just upgraded
into without leaving the chat — and keeps the v0.8.32 release-notes
flow consistent with `deepseek update`'s newly-fixed sibling-TUI
refresh: now both binaries match the version, and `/change` shows
what that version actually delivered.

Resolved a `clippy::needless_range_loop` warning in the section
extractor (idiomatic `iter().enumerate().skip(...).find(...)` instead
of an indexed range loop) so the harvest passes the workspace's
`-D warnings` clippy gate without touching the contributor's design.

Harvested from PR #1416 by @zhuangbiaowei

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-12 00:25:30 -05:00
parent 23d3a71126
commit 1f0065ccee
4 changed files with 436 additions and 0 deletions
+12
View File
@@ -14,6 +14,18 @@ have to work with?" — and the answer is now closer to "everything
you'd reach for from a shell, including the document formats the
real world uses."
### Added
- **`/change` slash command** displays the most recent
CHANGELOG.md version section from inside the TUI, so users can
see what they just upgraded into without leaving the chat
(harvested from PR #1416 by **@zhuangbiaowei**). The command
works against the bundled release-notes copy when no workspace
CHANGELOG is available, and on non-English locales it requests
a model-side translation of the section so localised users see
the changelog in their UI language. Pure offline fallback when
no API key is configured.
### Fixed
- **`deepseek update` now refreshes the companion TUI binary
+374
View File
@@ -0,0 +1,374 @@
//! `/change` command — show the latest changelog entry, translated to the
//! user's locale when it is not English.
//!
//! Usage: `/change`
//!
//! Uses the DeepSeek-TUI changelog embedded at compile time, extracts the
//! most recent version section, and displays it. When the UI locale is not
//! English and the current session can reach a model, the command also fires a
//! `SendMessage` action that asks the model to translate the changelog into
//! the user's language.
use crate::localization::{Locale, MessageId, tr};
use crate::tui::app::{App, AppAction};
use super::CommandResult;
/// Maximum length of the changelog excerpt we'll show inline (characters).
/// If the changelog section exceeds this, we truncate and show a notice.
/// 4096 chars is large enough for most version entries.
const MAX_INLINE_CHANGELOG_CHARS: usize = 4096;
const DEEPSEEK_TUI_CHANGELOG: &str = include_str!("../../../../CHANGELOG.md");
/// Execute the `/change` command.
pub fn change(app: &mut App) -> CommandResult {
let latest_section = match extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG) {
Some(s) => s,
None => {
return CommandResult::error(
"Could not find a version section in the bundled DeepSeek-TUI changelog. \
Expected a line starting with `## [`. "
.to_string(),
);
}
};
let locale = app.ui_locale;
let header = tr(locale, MessageId::CmdChangeHeader);
let section_text = inline_changelog_section(&latest_section);
// If the user's locale is English, just display.
// Otherwise, also ask the model to translate.
if locale == Locale::En {
CommandResult::message(format!(
"{header}\n─────────────────────────────\n{section_text}"
))
} else if app.offline_mode || app.onboarding_needs_api_key {
let fallback = tr(locale, MessageId::CmdChangeTranslationUnavailable);
CommandResult::message(format!(
"{header}\n\
─────────────────────────────\n\
{fallback}\n\n\
{section_text}"
))
} else {
let queued = tr(locale, MessageId::CmdChangeTranslationQueued);
let display_text = format!(
"{header}\n\
─────────────────────────────\n\
{queued}\n\n\
{section_text}"
);
let lang_name = match locale {
Locale::ZhHans => "Simplified Chinese (中文)",
Locale::Ja => "Japanese (日本語)",
Locale::PtBr => "Brazilian Portuguese (Português)",
// Fallback — should never reach here since we check En above.
Locale::En => "English",
};
let translation_prompt = format!(
"Translate the following changelog into {lang_name}. \
Keep all markdown formatting, version numbers, dates, \
contributor names, and code references intact. \
Output ONLY the translated changelog, no preamble or commentary.\n\n\
{latest_section}"
);
CommandResult::with_message_and_action(
display_text,
AppAction::SendMessage(translation_prompt),
)
}
}
fn inline_changelog_section(section: &str) -> String {
if section.len() <= MAX_INLINE_CHANGELOG_CHARS {
return section.to_string();
}
let truncated: String = section.chars().take(MAX_INLINE_CHANGELOG_CHARS).collect();
format!(
"{truncated}\n\
\n\
[... {} characters omitted from the bundled DeepSeek-TUI changelog]",
section.len() - MAX_INLINE_CHANGELOG_CHARS
)
}
/// Extract the latest version section from CHANGELOG.md content.
///
/// Looks for the first `## [version] - date` heading and returns all lines
/// from that heading up to the next `## [` heading (or end of file).
/// Leading and trailing whitespace is trimmed.
fn extract_latest_changelog_section(content: &str) -> Option<String> {
let lines: Vec<&str> = content.lines().collect();
let mut start_idx: Option<usize> = None;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("## [") {
start_idx = Some(i);
break;
}
}
let start = start_idx?;
// Find the next `## [` heading (or end)
let end = lines
.iter()
.enumerate()
.skip(start + 1)
.find(|(_, line)| line.trim().starts_with("## ["))
.map_or(lines.len(), |(i, _)| i);
let section = lines[start..end].join("\n");
let section = section.trim().to_string();
if section.is_empty() {
return None;
}
Some(section)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::localization::Locale;
use crate::tui::app::{App, TuiOptions};
fn make_app(tmpdir: &tempfile::TempDir, locale: Locale, has_api_key: bool) -> App {
let mut config = Config::default();
if has_api_key {
config.api_key = Some("test-key".to_string());
}
let mut app = App::new(
TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
initial_input: None,
},
&config,
);
app.ui_locale = locale;
app
}
#[test]
fn extract_latest_section_finds_first_version() {
let content = "\n\
## [0.8.26] - 2026-05-09\n\
\n\
A security + polish release.\n\
\n\
### Fixed\n\
\n\
- Fixed something\n\
\n\
## [0.8.25] - 2026-05-09\n\
\n\
A stabilization release.\n";
let section = extract_latest_changelog_section(content).expect("should find a section");
assert!(section.contains("0.8.26"));
assert!(section.contains("Fixed something"));
assert!(!section.contains("0.8.25"));
}
#[test]
fn extract_latest_section_handles_0_8_29_style_fixture() {
let content = "\n\
# Changelog\n\
\n\
## [0.8.29] - 2026-05-11\n\
\n\
Release candidate polish.\n\
\n\
### Added\n\
- New note-management command.\n\
\n\
## [0.8.28] - 2026-05-10\n\
\n\
Previous release.\n";
let section = extract_latest_changelog_section(content).expect("should find a section");
assert!(section.contains("0.8.29"));
assert!(section.contains("2026-05-11"));
assert!(section.contains("New note-management command"));
assert!(!section.contains("0.8.28"));
}
#[test]
fn extract_latest_section_returns_none_for_empty_content() {
assert!(extract_latest_changelog_section("").is_none());
}
#[test]
fn extract_latest_section_returns_none_for_no_version_headers() {
let content = "# Just a heading\n\nSome text\n";
assert!(extract_latest_changelog_section(content).is_none());
}
#[test]
fn extract_latest_section_handles_single_version() {
let content = "\n## [0.8.26] - 2026-05-09\n\nOnly one version.\n";
let section = extract_latest_changelog_section(content).expect("should find a section");
assert!(section.contains("0.8.26"));
assert!(section.contains("Only one version"));
}
#[test]
fn extract_latest_section_handles_subheadings() {
let content = "\n\
## [0.8.26] - 2026-05-09\n\
\n\
### Added\n\
- New feature A\n\
\n\
### Fixed\n\
- Fixed bug B\n\
\n\
## [0.8.25] - 2026-05-09\n\
";
let section = extract_latest_changelog_section(content).expect("should find a section");
assert!(section.contains("New feature A"));
assert!(section.contains("Fixed bug B"));
assert!(!section.contains("0.8.25"));
}
#[test]
fn change_uses_bundled_release_notes_without_workspace_changelog() {
let tmp = tempfile::TempDir::new().unwrap();
let mut app = make_app(&tmp, Locale::En, false);
let result = change(&mut app);
assert!(!result.is_error);
let msg = result.message.expect("should have a message");
let expected = extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG)
.expect("bundled changelog should have a release section");
assert!(msg.contains(expected.lines().next().unwrap()));
}
#[test]
fn change_ignores_workspace_changelog() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("CHANGELOG.md"),
"\n## [9.9.9] - 2099-01-01\n\nWorkspace changelog.\n",
)
.unwrap();
let mut app = make_app(&tmp, Locale::En, false);
let result = change(&mut app);
assert!(!result.is_error);
let msg = result.message.expect("should have a message");
assert!(!msg.contains("9.9.9"));
assert!(!msg.contains("Workspace changelog"));
}
#[test]
fn change_in_english_returns_message_without_action() {
let tmp = tempfile::TempDir::new().unwrap();
let mut app = make_app(&tmp, Locale::En, true);
let result = change(&mut app);
assert!(!result.is_error);
let msg = result.message.expect("should have a message");
let expected = extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG)
.expect("bundled changelog should have a release section");
assert!(msg.contains(expected.lines().next().unwrap()));
assert!(
result.action.is_none(),
"English locale should not send translation"
);
}
#[test]
fn change_in_non_english_also_sends_translation_action() {
for (locale, _label) in [
(Locale::ZhHans, "zh-Hans"),
(Locale::Ja, "ja"),
(Locale::PtBr, "pt-BR"),
] {
let tmp = tempfile::TempDir::new().unwrap();
let mut app = make_app(&tmp, locale, true);
let result = change(&mut app);
assert!(!result.is_error, "Failed for locale {locale:?}");
let msg = result.message.expect("should have a message");
assert!(msg.contains(tr(locale, MessageId::CmdChangeTranslationQueued)));
assert!(
matches!(result.action, Some(AppAction::SendMessage(_))),
"Non-English locale should send translation, got {:?}",
result.action
);
if let Some(AppAction::SendMessage(prompt)) = &result.action {
let expected = extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG)
.expect("bundled changelog should have a release section");
assert!(prompt.contains(expected.lines().next().unwrap()));
}
}
}
#[test]
fn change_in_non_english_without_api_key_uses_explicit_fallback() {
let tmp = tempfile::TempDir::new().unwrap();
let mut app = make_app(&tmp, Locale::ZhHans, false);
let result = change(&mut app);
assert!(!result.is_error);
let msg = result.message.expect("should have a message");
assert!(msg.contains(tr(
Locale::ZhHans,
MessageId::CmdChangeTranslationUnavailable
)));
assert!(
result.action.is_none(),
"missing API key should not send translation"
);
}
#[test]
fn change_in_non_english_offline_uses_explicit_fallback() {
let tmp = tempfile::TempDir::new().unwrap();
let mut app = make_app(&tmp, Locale::Ja, true);
app.offline_mode = true;
let result = change(&mut app);
assert!(!result.is_error);
let msg = result.message.expect("should have a message");
assert!(msg.contains(tr(Locale::Ja, MessageId::CmdChangeTranslationUnavailable)));
assert!(
result.action.is_none(),
"offline mode should not send translation"
);
}
#[test]
fn extract_latest_ignores_lines_before_first_version() {
let content = "\n\
# Changelog\n\
\n\
Some intro text.\n\
\n\
## [0.8.26] - 2026-05-09\n\
\n\
Content\n\
";
let section = extract_latest_changelog_section(content).expect("should find a section");
assert!(section.contains("0.8.26"));
assert!(!section.contains("Changelog"));
assert!(!section.contains("intro text"));
}
}
+10
View File
@@ -5,6 +5,7 @@
mod anchor;
mod attachment;
mod change;
mod config;
mod core;
mod cycle;
@@ -386,6 +387,12 @@ pub const COMMANDS: &[CommandInfo] = &[
usage: "/diff",
description_id: MessageId::CmdDiffDescription,
},
CommandInfo {
name: "change",
aliases: &[],
usage: "/change",
description_id: MessageId::CmdChangeDescription,
},
CommandInfo {
name: "undo",
aliases: &[],
@@ -558,6 +565,9 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"tokens" => debug::tokens(app),
"cost" => debug::cost(app),
"cache" => debug::cache(app, arg),
// ChangeLog command
"change" => change::change(app),
"system" => debug::system_prompt(app),
"context" | "ctx" => debug::context(app),
"edit" => debug::edit(app),
+40
View File
@@ -217,6 +217,10 @@ pub enum MessageId {
CmdAttachDescription,
CmdAnchorDescription,
CmdCacheDescription,
CmdChangeDescription,
CmdChangeHeader,
CmdChangeTranslationQueued,
CmdChangeTranslationUnavailable,
CmdClearDescription,
CmdCompactDescription,
CmdConfigDescription,
@@ -498,6 +502,10 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CmdCacheHeader,
MessageId::CmdCacheNoData,
MessageId::CmdCacheTotals,
MessageId::CmdChangeDescription,
MessageId::CmdChangeHeader,
MessageId::CmdChangeTranslationQueued,
MessageId::CmdChangeTranslationUnavailable,
MessageId::CmdCostReport,
MessageId::CmdTokensCacheBoth,
MessageId::CmdTokensCacheHitOnly,
@@ -783,6 +791,14 @@ fn english(id: MessageId) -> &'static str {
MessageId::CmdCacheDescription => {
"Show DeepSeek prefix-cache hit/miss stats for the last N turns"
}
MessageId::CmdChangeDescription => "Show the latest changelog entry",
MessageId::CmdChangeHeader => "Latest Changelog",
MessageId::CmdChangeTranslationQueued => {
"English release notes are shown below. A translated version will be requested next; if the provider is unavailable, this English text is the fallback."
}
MessageId::CmdChangeTranslationUnavailable => {
"English release notes are shown below. Translation is unavailable because the current session has no API key or is offline."
}
MessageId::CmdClearDescription => "Clear conversation history",
MessageId::CmdCompactDescription => {
"Trigger context compaction to free up space (legacy; v0.6.6 prefers cycle restart)"
@@ -1118,6 +1134,14 @@ fn japanese(id: MessageId) -> Option<&'static str> {
MessageId::CmdCacheDescription => {
"直近 N ターンの DeepSeek プレフィックスキャッシュのヒット/ミス統計を表示"
}
MessageId::CmdChangeDescription => "最新の更新履歴を表示",
MessageId::CmdChangeHeader => "最新の更新履歴",
MessageId::CmdChangeTranslationQueued => {
"英語のリリースノートを以下に表示します。次に翻訳を依頼します。プロバイダーを利用できない場合は、この英語版がフォールバックです。"
}
MessageId::CmdChangeTranslationUnavailable => {
"英語のリリースノートを以下に表示します。現在のセッションに API キーがないかオフラインのため、翻訳は利用できません。"
}
MessageId::CmdClearDescription => "会話履歴をクリア",
MessageId::CmdCompactDescription => {
"コンテキスト圧縮で容量を確保(旧式:v0.6.6 以降はサイクル再起動を推奨)"
@@ -1443,6 +1467,14 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
MessageId::CmdAnchorDescription => "钉选关键事实,在压缩后自动注入上下文",
MessageId::CmdAttachDescription => "附加图片或视频媒体;文本文件或目录请使用 @path",
MessageId::CmdCacheDescription => "显示最近 N 轮的 DeepSeek 前缀缓存命中/未命中统计",
MessageId::CmdChangeDescription => "显示最新的更新日志",
MessageId::CmdChangeHeader => "最新更新日志",
MessageId::CmdChangeTranslationQueued => {
"下面显示英文发布说明。接下来会请求模型翻译;如果当前提供商不可用,这段英文内容就是备用结果。"
}
MessageId::CmdChangeTranslationUnavailable => {
"下面显示英文发布说明。当前会话没有 API Key 或处于离线状态,无法翻译。"
}
MessageId::CmdClearDescription => "清除对话历史",
MessageId::CmdCompactDescription => {
"触发上下文压缩以释放空间(旧版命令;v0.6.6 起建议改用循环重启)"
@@ -1736,6 +1768,14 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
MessageId::CmdCacheDescription => {
"Exibir estatísticas de hit/miss do cache de prefixo DeepSeek nas últimas N rodadas"
}
MessageId::CmdChangeDescription => "Mostrar a entrada mais recente do changelog",
MessageId::CmdChangeHeader => "Changelog Mais Recente",
MessageId::CmdChangeTranslationQueued => {
"As notas de versao em ingles aparecem abaixo. Uma versao traduzida sera solicitada em seguida; se o provedor estiver indisponivel, este texto em ingles sera o fallback."
}
MessageId::CmdChangeTranslationUnavailable => {
"As notas de versao em ingles aparecem abaixo. A traducao esta indisponivel porque a sessao atual nao tem chave de API ou esta offline."
}
MessageId::CmdClearDescription => "Limpar o histórico da conversa",
MessageId::CmdCompactDescription => {
"Compactar o contexto para liberar espaço (legado; a v0.6.6 prefere o reinício de ciclo)"