Skip to content

Commit 150ba40

Browse files
committed
fix(tui): P0 TUI fixes — Ctrl+C, spinner timing, buttons, welcome
P0-1: Ctrl+C double-press to exit (CC-aligned) - Single Ctrl+C: interrupts current operation, shows [interrupted] - Double Ctrl+C within 500ms: actually exits - Ctrl+D: same double-press behavior P0-4: Spinner shows elapsed time and token count - Format: "⠋ Cogitating… (12s · 3.2k tokens)" - Tracks started_at and response_tokens per spinner session - Token count estimated from ContentDelta character length P0-5: UI text and visual fixes - Permission buttons: Yes/No → Allow/Deny/Always Allow - Welcome message on startup: "Welcome! Type a message..." - Turn separators: ❯ prompt echo + ──── divider between turns - Blank line after assistant response for visual breathing room
1 parent 9373a13 commit 150ba40

3 files changed

Lines changed: 89 additions & 12 deletions

File tree

crates/tui/src/app.rs

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ pub struct App {
161161
unseen_message_count: usize,
162162
/// Current prompt input mode.
163163
pub input_mode: PromptInputMode,
164+
/// Timestamp of last Ctrl+C press for double-press detection.
165+
last_interrupt: Option<Instant>,
164166
}
165167

166168
impl App {
@@ -171,7 +173,8 @@ impl App {
171173
state: AppState::Idle,
172174
input: InputBox::new(),
173175
spinner: Spinner::new(),
174-
content_buffer: String::new(),
176+
content_buffer: "Welcome! Type a message to start, or /help for commands.\n\n"
177+
.to_string(),
175178
model_name: model_name.into(),
176179
permission_dialog: None,
177180
should_quit: false,
@@ -195,6 +198,7 @@ impl App {
195198
scroll_anchor: None,
196199
unseen_message_count: 0,
197200
input_mode: PromptInputMode::Prompt,
201+
last_interrupt: None,
198202
}
199203
}
200204

@@ -286,8 +290,24 @@ impl App {
286290
if let Some(action) = self.keybindings.resolve(key.code, key.modifiers) {
287291
match action {
288292
Action::Quit => {
289-
self.should_quit = true;
290-
return AppAction::Quit;
293+
// CC-aligned double-press: first Ctrl+C interrupts, second exits.
294+
let now = Instant::now();
295+
if let Some(last) = self.last_interrupt
296+
&& now.duration_since(last) < Duration::from_millis(500)
297+
{
298+
// Double press within 500ms → actually quit
299+
self.should_quit = true;
300+
return AppAction::Quit;
301+
}
302+
// First press → interrupt current operation
303+
self.last_interrupt = Some(now);
304+
if self.state == AppState::Processing {
305+
self.spinner.stop();
306+
self.state = AppState::Idle;
307+
let _ = writeln!(self.content_buffer, "\n[interrupted]");
308+
}
309+
// Show hint that double-press exits
310+
return AppAction::None;
291311
}
292312
Action::NewSession if self.state != AppState::Confirming => {
293313
return AppAction::NewSession;
@@ -476,6 +496,9 @@ impl App {
476496
if key.code == KeyCode::Enter && !key.modifiers.contains(KeyModifiers::SHIFT) {
477497
if !self.input.is_empty() {
478498
let text = self.input.submit();
499+
// Turn separator: show user prompt then divider
500+
let _ = writeln!(self.content_buffer, "❯ {text}");
501+
let _ = writeln!(self.content_buffer, "────────────────────────────────");
479502
self.state = AppState::Processing;
480503
self.spinner.start_with_random_verb();
481504
return AppAction::Submit(text);
@@ -563,6 +586,8 @@ impl App {
563586
self.content_scroll = 0; // auto-scroll on new content
564587
}
565588
self.content_buffer.push_str(&delta);
589+
// Approximate token count for spinner display (~4 chars per token)
590+
self.spinner.response_tokens += (delta.len() as u64).div_ceil(4);
566591
}
567592
Event::MessageEnd { usage, .. } => {
568593
self.spinner.stop();
@@ -571,6 +596,8 @@ impl App {
571596
// Accumulate token usage (displayed in status bar, not inline)
572597
self.total_input_tokens += usage.input_tokens;
573598
self.total_output_tokens += usage.output_tokens;
599+
// Turn separator after assistant response
600+
let _ = writeln!(self.content_buffer, "\n");
574601
}
575602
Event::ToolUseStart { name, .. } => {
576603
self.current_tool = Some(name.clone());
@@ -1193,7 +1220,7 @@ mod tests {
11931220
assert_eq!(app.state, AppState::Idle);
11941221
assert!(app.input.is_empty());
11951222
assert!(!app.spinner.is_active());
1196-
assert!(app.content_buffer.is_empty());
1223+
assert!(app.content_buffer.contains("Welcome"));
11971224
assert_eq!(app.model_name, "gpt-4o");
11981225
assert!(!app.should_quit);
11991226
assert!(!app.sidebar_visible);
@@ -1233,16 +1260,30 @@ mod tests {
12331260
}
12341261

12351262
#[test]
1236-
fn ctrl_c_quits() {
1263+
fn ctrl_c_single_interrupts() {
12371264
let mut app = App::new("test");
12381265
let action = app.handle_event(ctrl_key('c'));
1266+
// Single Ctrl+C should interrupt, not quit
1267+
assert_eq!(action, AppAction::None);
1268+
assert!(!app.should_quit);
1269+
}
1270+
1271+
#[test]
1272+
fn ctrl_c_double_quits() {
1273+
let mut app = App::new("test");
1274+
// First press: interrupt
1275+
app.handle_event(ctrl_key('c'));
1276+
// Second press within 500ms: quit
1277+
let action = app.handle_event(ctrl_key('c'));
12391278
assert_eq!(action, AppAction::Quit);
12401279
assert!(app.should_quit);
12411280
}
12421281

12431282
#[test]
12441283
fn ctrl_d_quits() {
12451284
let mut app = App::new("test");
1285+
// Ctrl+D also goes through Quit action (same double-press logic)
1286+
app.handle_event(ctrl_key('d'));
12461287
let action = app.handle_event(ctrl_key('d'));
12471288
assert_eq!(action, AppAction::Quit);
12481289
}
@@ -1266,7 +1307,7 @@ mod tests {
12661307
index: 0,
12671308
delta: "world".into(),
12681309
}));
1269-
assert_eq!(app.content_buffer, "Hello world");
1310+
assert!(app.content_buffer.ends_with("Hello world"));
12701311
assert_eq!(app.content_scroll, 0); // auto-scrolled
12711312
}
12721313

@@ -1602,7 +1643,10 @@ mod tests {
16021643
let mut app = App::new("test");
16031644
let kb = Keybindings::defaults();
16041645
app.set_keybindings(kb);
1605-
// Should not panic
1646+
// Single Ctrl+C should interrupt (not quit with double-press logic)
1647+
let action = app.handle_event(ctrl_key('c'));
1648+
assert_eq!(action, AppAction::None);
1649+
// Double press should quit
16061650
let action = app.handle_event(ctrl_key('c'));
16071651
assert_eq!(action, AppAction::Quit);
16081652
}

crates/tui/src/components/dialog.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ impl PermissionDialog {
6565
request_id: request_id.into(),
6666
selected: 0,
6767
options: vec![
68-
("Yes", PermissionResponse::Allow),
69-
("No", PermissionResponse::Deny),
70-
("Always allow", PermissionResponse::AlwaysAllow),
68+
("Allow", PermissionResponse::Allow),
69+
("Deny", PermissionResponse::Deny),
70+
("Always Allow", PermissionResponse::AlwaysAllow),
7171
],
7272
}
7373
}

crates/tui/src/components/spinner.rs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,10 @@ pub struct Spinner {
255255
active: bool,
256256
/// Shimmer color (the highlight sliding across text).
257257
shimmer_color: Color,
258+
/// When the spinner started (for elapsed time display).
259+
started_at: Option<std::time::Instant>,
260+
/// Cumulative response tokens during this spinner session.
261+
pub response_tokens: u64,
258262
}
259263

260264
impl Spinner {
@@ -268,6 +272,8 @@ impl Spinner {
268272
override_message: None,
269273
active: false,
270274
shimmer_color: Color::White,
275+
started_at: None,
276+
response_tokens: 0,
271277
}
272278
}
273279

@@ -278,6 +284,8 @@ impl Spinner {
278284
self.active = true;
279285
self.frame = 0;
280286
self.tick = 0;
287+
self.started_at = Some(std::time::Instant::now());
288+
self.response_tokens = 0;
281289
}
282290

283291
/// Start the spinner with a specific message (overrides verb).
@@ -286,6 +294,8 @@ impl Spinner {
286294
self.active = true;
287295
self.frame = 0;
288296
self.tick = 0;
297+
self.started_at = Some(std::time::Instant::now());
298+
self.response_tokens = 0;
289299
}
290300

291301
/// Stop the spinner.
@@ -307,13 +317,36 @@ impl Spinner {
307317
self.active
308318
}
309319

310-
/// Current display message (verb + "…" or override message).
320+
/// Current display message (verb + "…" + timing + tokens).
311321
#[must_use]
312322
pub fn message(&self) -> String {
313-
if let Some(ref msg) = self.override_message {
323+
let base = if let Some(ref msg) = self.override_message {
314324
msg.clone()
315325
} else {
316326
format!("{}…", self.verb)
327+
};
328+
329+
// Append elapsed time and token count like CC: "Verb… (12s · 3.2k tokens)"
330+
let mut suffix_parts = Vec::new();
331+
if let Some(started) = self.started_at {
332+
let elapsed = started.elapsed().as_secs();
333+
if elapsed >= 1 {
334+
suffix_parts.push(format!("{elapsed}s"));
335+
}
336+
}
337+
if self.response_tokens > 0 {
338+
let formatted = if self.response_tokens >= 1000 {
339+
format!("{:.1}k", self.response_tokens as f64 / 1000.0)
340+
} else {
341+
self.response_tokens.to_string()
342+
};
343+
suffix_parts.push(format!("{formatted} tokens"));
344+
}
345+
346+
if suffix_parts.is_empty() {
347+
base
348+
} else {
349+
format!("{base} ({})", suffix_parts.join(" · "))
317350
}
318351
}
319352

0 commit comments

Comments
 (0)