Skip to content

Commit ba59fb7

Browse files
committed
feat: fix tui cursor width and add update notice
1 parent fa1bed5 commit ba59fb7

7 files changed

Lines changed: 154 additions & 6 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ ratatui = { version = "0.29", features = ["unstable-rendered-line-info"] }
1717
rmcp = { version = "0.15.0", default-features = false, features = ["macros", "server", "transport-streamable-http-server"] }
1818
serde = { version = "1", features = ["derive"] }
1919
serde_json = "1"
20+
semver = "1"
2021
tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "sync", "time", "io-util", "io-std", "fs", "signal", "net"] }
2122
tower = { version = "0.5", features = ["util"] }
2223
tui-textarea = { version = "0.7", features = ["crossterm"] }
24+
unicode-width = "0.2"
2325
uuid = { version = "1", features = ["v4"] }
2426
urlencoding = "2"
2527

src/chat.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,8 @@ impl ChatRuntime {
436436
}
437437

438438
pub async fn run_chat(args: ChatArgs) -> Result<()> {
439+
crate::update::maybe_print_update_notice().await;
440+
439441
let cwd = std::env::current_dir().context("failed to resolve current dir")?;
440442
let config_path = cwd.join(Path::new(&args.config_path));
441443
let is_tty = std::io::stdin().is_terminal();

src/init.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ pub struct InitArgs {
1616
}
1717

1818
pub async fn run_init(args: InitArgs) -> Result<()> {
19+
crate::update::maybe_print_update_notice().await;
20+
1921
let profiles_path = profiles::global_profiles_path()?;
2022

2123
if let Some(name) = args.delete {

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod provider;
1111
mod scheduler;
1212
mod text;
1313
mod tui;
14+
mod update;
1415

1516
use clap::{Args, Parser, Subcommand};
1617

src/tui.rs

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use tokio::sync::mpsc::UnboundedReceiver;
2828
use tokio::sync::mpsc::error::TryRecvError;
2929
use tokio::task::JoinHandle;
3030
use tui_textarea::TextArea;
31+
use unicode_width::UnicodeWidthStr;
3132

3233
use crate::chat::{ChatRuntime, handle_user_input};
3334
use crate::kernel::MessageEvent;
@@ -609,23 +610,38 @@ fn cursor_visual_position(textarea: &TextArea, content_width: u16) -> (usize, us
609610
let mut visual_row = 0_usize;
610611

611612
for line in lines.iter().take(cursor_row) {
612-
let line_len = line.chars().count();
613-
let wrapped_rows = if line_len == 0 {
613+
let display_width = UnicodeWidthStr::width(line.as_str());
614+
let wrapped_rows = if display_width == 0 {
614615
1
615616
} else {
616-
line_len.saturating_sub(1) / width + 1
617+
display_width.saturating_sub(1) / width + 1
617618
};
618619
visual_row = visual_row.saturating_add(wrapped_rows);
619620
}
620621

621-
let current_line_len = lines[cursor_row].chars().count();
622+
let current_line = lines[cursor_row].as_str();
623+
let current_line_len = current_line.chars().count();
622624
let cursor_col = cursor_col.min(current_line_len);
623-
visual_row = visual_row.saturating_add(cursor_col / width);
624-
let visual_col = cursor_col % width;
625+
let cursor_display_col = display_width_for_char_prefix(current_line, cursor_col);
626+
visual_row = visual_row.saturating_add(cursor_display_col / width);
627+
let visual_col = cursor_display_col % width;
625628

626629
(visual_row, visual_col)
627630
}
628631

632+
fn display_width_for_char_prefix(line: &str, char_count: usize) -> usize {
633+
if char_count == 0 {
634+
return 0;
635+
}
636+
637+
let split_at = line
638+
.char_indices()
639+
.nth(char_count)
640+
.map(|(byte_idx, _)| byte_idx)
641+
.unwrap_or(line.len());
642+
UnicodeWidthStr::width(&line[..split_at])
643+
}
644+
629645
fn input_rendered_line_count(textarea: &TextArea, content_width: u16) -> usize {
630646
Paragraph::new(input_text(textarea))
631647
.wrap(Wrap { trim: false })
@@ -973,4 +989,22 @@ mod tests {
973989
let (row, col) = cursor_visual_position(&textarea, 5);
974990
assert_eq!((row, col), (1, 1));
975991
}
992+
993+
#[test]
994+
fn cursor_visual_position_accounts_for_wide_char_width() {
995+
let mut textarea = TextArea::default();
996+
textarea.insert_str("你好a");
997+
998+
let (row, col) = cursor_visual_position(&textarea, 4);
999+
assert_eq!((row, col), (1, 1));
1000+
}
1001+
1002+
#[test]
1003+
fn cursor_visual_position_counts_wrapped_wide_lines_before_cursor_row() {
1004+
let mut textarea = TextArea::default();
1005+
textarea.insert_str("你好你好\nx");
1006+
1007+
let (row, col) = cursor_visual_position(&textarea, 4);
1008+
assert_eq!((row, col), (2, 1));
1009+
}
9761010
}

src/update.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
use std::io::IsTerminal;
2+
use std::process::Stdio;
3+
4+
use semver::Version;
5+
use tokio::process::Command;
6+
use tokio::time::{Duration, timeout};
7+
8+
const CREWFORGE_PACKAGE_NAME: &str = "crewforge";
9+
const DEFAULT_NPM_COMMAND: &str = "npm";
10+
const UPDATE_CHECK_TIMEOUT_MS: u64 = 1_200;
11+
const DISABLE_UPDATE_CHECK_ENV: &str = "CREWFORGE_NO_UPDATE_CHECK";
12+
13+
pub async fn maybe_print_update_notice() {
14+
if !std::io::stdout().is_terminal() || update_check_disabled() {
15+
return;
16+
}
17+
18+
let Some(latest) = latest_npm_version().await else {
19+
return;
20+
};
21+
let current = env!("CARGO_PKG_VERSION");
22+
if !is_newer_version(current, &latest) {
23+
return;
24+
}
25+
26+
eprintln!(
27+
"[update] New crewforge version {latest} is available (current {current}). Run `npm i -g crewforge` to update."
28+
);
29+
}
30+
31+
async fn latest_npm_version() -> Option<String> {
32+
let command = std::env::var("CREWFORGE_NPM_COMMAND")
33+
.ok()
34+
.filter(|value| !value.trim().is_empty())
35+
.unwrap_or_else(|| DEFAULT_NPM_COMMAND.to_string());
36+
37+
let output = timeout(
38+
Duration::from_millis(UPDATE_CHECK_TIMEOUT_MS),
39+
Command::new(command)
40+
.arg("view")
41+
.arg(CREWFORGE_PACKAGE_NAME)
42+
.arg("version")
43+
.arg("--silent")
44+
.stdin(Stdio::null())
45+
.stdout(Stdio::piped())
46+
.stderr(Stdio::null())
47+
.output(),
48+
)
49+
.await
50+
.ok()?
51+
.ok()?;
52+
53+
if !output.status.success() {
54+
return None;
55+
}
56+
57+
first_non_empty_line(&String::from_utf8_lossy(&output.stdout)).map(str::to_string)
58+
}
59+
60+
fn first_non_empty_line(text: &str) -> Option<&str> {
61+
text.lines().find_map(|line| {
62+
let line = line.trim();
63+
if line.is_empty() { None } else { Some(line) }
64+
})
65+
}
66+
67+
fn update_check_disabled() -> bool {
68+
let Some(value) = std::env::var(DISABLE_UPDATE_CHECK_ENV).ok() else {
69+
return false;
70+
};
71+
matches!(
72+
value.trim().to_ascii_lowercase().as_str(),
73+
"1" | "true" | "yes"
74+
)
75+
}
76+
77+
fn is_newer_version(current: &str, latest: &str) -> bool {
78+
let Some(current) = Version::parse(current).ok() else {
79+
return false;
80+
};
81+
let Some(latest) = Version::parse(latest).ok() else {
82+
return false;
83+
};
84+
latest > current
85+
}
86+
87+
#[cfg(test)]
88+
mod tests {
89+
use super::*;
90+
91+
#[test]
92+
fn first_non_empty_line_skips_blanks() {
93+
assert_eq!(first_non_empty_line("\n \n0.2.0\n"), Some("0.2.0"));
94+
}
95+
96+
#[test]
97+
fn newer_semver_detected_correctly() {
98+
assert!(is_newer_version("0.1.3", "0.1.4"));
99+
assert!(is_newer_version("0.1.3", "0.2.0"));
100+
assert!(!is_newer_version("0.1.3", "0.1.3"));
101+
assert!(!is_newer_version("0.2.0", "0.1.9"));
102+
assert!(!is_newer_version("dev", "0.1.4"));
103+
assert!(!is_newer_version("0.1.3", "latest"));
104+
}
105+
}

0 commit comments

Comments
 (0)