Skip to content

Commit 07c4371

Browse files
Shahinyanmclaude
andcommitted
fix(tui): truncate session titles by chars, not bytes (v0.2.2)
Real-world panic reported on a Cyrillic prompt: thread main panicked at session_list.rs:348:36: end byte index 80 is not a char boundary; it is inside у (bytes 79..81) The session title truncator was slicing the string by raw byte offset (), which lands in the middle of any multi-byte UTF-8 character (Cyrillic, CJK, emoji). Replaced with a small helper that takes by Unicode scalars via chars().take(N). Same fix applied to the fallback Session <id> path for consistency, even though session_id is currently always ASCII (ULID). Tests: - truncate_ascii_under_limit_returns_input_as_is - truncate_ascii_over_limit_appends_ellipsis - truncate_cyrillic_does_not_panic_at_char_boundary ← regression - truncate_emoji_counts_by_chars_not_bytes - truncate_exact_length_no_ellipsis Drive-by: also fix lingering clippy::doc_lazy_continuation in classifier_eval.rs that was failing CI (test docstring). Manifests: workspace + tj-cli + tj-mcp + plugin.json + marketplace.json all bumped to 0.2.2 (patch). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8452d28 commit 07c4371

10 files changed

Lines changed: 77 additions & 17 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
},
77
"metadata": {
88
"description": "Task Journal — append-only reasoning chain memory for AI-coding tasks",
9-
"version": "0.2.1"
9+
"version": "0.2.2"
1010
},
1111
"plugins": [
1212
{
1313
"name": "task-journal",
1414
"source": "./plugin",
1515
"description": "Append-only journal of AI-coding task reasoning chains. Captures hypotheses, decisions, rejections, evidence — renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
16-
"version": "0.2.1",
16+
"version": "0.2.2",
1717
"author": {
1818
"name": "Digital-Threads"
1919
},

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.2] - 2026-05-07
11+
12+
Hotfix release. No new features.
13+
14+
### Fixed
15+
- TUI session browser (`task-journal ui`) panicked with `byte index is
16+
not a char boundary` when a session's first user message was longer
17+
than 80 bytes and contained non-ASCII characters (Cyrillic, CJK,
18+
emoji, etc.). Title truncation now slices by Unicode scalars instead
19+
of bytes. Same fix applied to the fallback `Session <id>` path for
20+
consistency.
21+
- Added regression tests covering Cyrillic, emoji, and exact-boundary
22+
inputs for the new `truncate_with_ellipsis` helper.
23+
1024
## [0.2.1] - 2026-05-07
1125

1226
Operational maturity release. No breaking changes — additive features

Cargo.lock

Lines changed: 3 additions & 3 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
@@ -7,7 +7,7 @@ members = [
77
]
88

99
[workspace.package]
10-
version = "0.2.1"
10+
version = "0.2.2"
1111
edition = "2021"
1212
rust-version = "1.83"
1313
license = "MIT"

crates/tj-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ name = "task-journal"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
tj-core = { package = "task-journal-core", version = "0.2.1", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.2.2", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
clap = { workspace = true }
2222
tracing = { workspace = true }

crates/tj-cli/src/tui/session_list.rs

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -336,21 +336,27 @@ impl SessionList {
336336
}
337337
}
338338

339+
fn truncate_with_ellipsis(text: &str, max_chars: usize) -> String {
340+
let mut chars = text.chars();
341+
let head: String = chars.by_ref().take(max_chars).collect();
342+
if chars.next().is_some() {
343+
format!("{head}…")
344+
} else {
345+
head
346+
}
347+
}
348+
339349
fn session_title(s: &ParsedSession) -> String {
340350
if let Some(text) = s.first_user_text() {
341351
let clean = strip_xml_tags(&text);
342352
let line = clean
343353
.lines()
344354
.find(|l| !l.trim().is_empty())
345355
.unwrap_or(&clean);
346-
let trimmed = line.trim();
347-
if trimmed.len() > 80 {
348-
format!("{}…", &trimmed[..80])
349-
} else {
350-
trimmed.to_string()
351-
}
356+
truncate_with_ellipsis(line.trim(), 80)
352357
} else {
353-
format!("Session {}", &s.session_id[..8.min(s.session_id.len())])
358+
let head: String = s.session_id.chars().take(8).collect();
359+
format!("Session {head}")
354360
}
355361
}
356362

@@ -422,3 +428,42 @@ fn shorten_path(path: &str) -> String {
422428
parts[parts.len() - 2..].join("/")
423429
}
424430
}
431+
432+
#[cfg(test)]
433+
mod tests {
434+
use super::truncate_with_ellipsis;
435+
436+
#[test]
437+
fn truncate_ascii_under_limit_returns_input_as_is() {
438+
assert_eq!(truncate_with_ellipsis("hello", 80), "hello");
439+
}
440+
441+
#[test]
442+
fn truncate_ascii_over_limit_appends_ellipsis() {
443+
let long = "a".repeat(100);
444+
let out = truncate_with_ellipsis(&long, 10);
445+
assert_eq!(out, format!("{}…", "a".repeat(10)));
446+
}
447+
448+
#[test]
449+
fn truncate_cyrillic_does_not_panic_at_char_boundary() {
450+
let cyr = "Можешь ли ты проанализировать проект? Мне нужен вот md файл, то есть который описывает, вообще делает этот проект. Все полностью.";
451+
let out = truncate_with_ellipsis(cyr, 80);
452+
assert!(out.ends_with('…'));
453+
assert_eq!(out.chars().count(), 81);
454+
}
455+
456+
#[test]
457+
fn truncate_emoji_counts_by_chars_not_bytes() {
458+
let s = "🦀".repeat(50);
459+
let out = truncate_with_ellipsis(&s, 10);
460+
assert!(out.ends_with('…'));
461+
assert_eq!(out.chars().filter(|c| *c == '🦀').count(), 10);
462+
}
463+
464+
#[test]
465+
fn truncate_exact_length_no_ellipsis() {
466+
let s: String = (0..80).map(|_| 'x').collect();
467+
assert_eq!(truncate_with_ellipsis(&s, 80), s);
468+
}
469+
}

crates/tj-core/tests/classifier_eval.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
//! - the fixture has at least 30 examples
88
//! - every example has a recognised `expected` event_type
99
//! - the prompt builder always emits the input text into the prompt
10+
//!
1011
//! No model API is called. Deterministic, hermetic.
1112
//!
1213
//! 2. **Opt-in real classifier (`TJ_CLASSIFIER_EVAL=on`).** Calls

crates/tj-mcp/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ name = "task-journal-mcp"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
tj-core = { package = "task-journal-core", version = "0.2.1", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.2.2", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
tokio = { workspace = true }
2222
tracing = { workspace = true }

plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "task-journal",
3-
"version": "0.2.1",
3+
"version": "0.2.2",
44
"description": "Append-only journal of AI-coding task reasoning chains: hypotheses, decisions, rejections, evidence. Renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
55
"author": {
66
"name": "Mher Shahinyan"

plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "task-journal",
3-
"version": "0.2.1",
3+
"version": "0.2.2",
44
"description": "Append-only journal of AI-coding task reasoning chains. Captures hypotheses, decisions, rejections, evidence — renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
55
"author": {
66
"name": "Mher Shahinyan",

0 commit comments

Comments
 (0)