Skip to content

Commit 041bc5e

Browse files
committed
Polish TUI: throttle markdown, surface cache and tier costs, preserve draft
1 parent 6d96b8c commit 041bc5e

10 files changed

Lines changed: 203 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,22 @@ All notable changes to Sofos are documented in this file.
66

77
### Added
88

9+
- **The cache numbers now appear in the status line.** When either cache-read or cache-creation tokens are non-zero, the status row shows `cache: 1234r 56w` alongside the input/output totals so users can see their prompt-cache hit rate without quitting to view the session summary.
10+
- **The input box title line shows a `… +N more` hint when the draft overflows the visible area.** A long paste or multi-line draft that exceeds the input box height previously had no on-screen indication that more content existed below the visible window; the cap is now surfaced as part of the title.
911
- **`Ctrl+W` and `Ctrl+K` are now bound in the input box.** `Ctrl+W` deletes the word behind the cursor (the readline / bash / zsh / fish binding) and `Ctrl+K` deletes from the cursor to the end of the line. `Ctrl+U` (delete to start of line) is unchanged.
1012

1113
### Fixed
1214

15+
- **Long assistant turns render fluidly again.** The streaming markdown renderer used to re-render the whole accumulated buffer on every new line — quadratic in the length of the reply, which on a 50 KB stream was noticeably laggy. Replies under 16 KB stream per line as before; longer replies batch the work into 1 KB chunks so the total cost stays linear.
16+
- **Editing past a slash-popup dismissal stays dismissed.** Pressing Esc on `/clear` and then backspacing one character (or adding a typo fix) used to immediately re-open the popup; the dismissal now sticks while the textarea is still in the same `/command` edit family and only clears when the user switches to an unrelated command.
17+
- **A live draft is preserved while scrolling through input history.** Alt+Up used to discard whatever you had typed when stepping into older prompts; sofos now snapshots the draft on the first Alt+Up and restores it when Alt+Down walks past the newest entry.
18+
- **The session summary now surfaces the premium-tier crossover.** Crossing the GPT-5.5 / GPT-5.4 input-token threshold doubles the rate for every later turn in the session; the cost summary now prints a dimmed `(premium tier: peak input X exceeded Y threshold)` line below the estimated cost so users can see why the bill is what it is.
19+
- **A fully-cached session prints a summary instead of nothing.** The early-return that suppressed the post-session table when both fresh-input and output were zero ignored cache reads — a short prompt that re-hit cache used to look like zero usage. Sessions with any cache-read activity now print the usual summary.
20+
- **A late settlement of Anthropic cache usage in the streaming response is no longer hidden.** The renderer used to surface `cache: 0r 0w` for these turns; the trailing `message_delta` event now refreshes both totals when it carries them so the status row matches the cost summary.
21+
- **CommonMark `~~~` fences are recognised in streaming commits.** A partial `~~~` fence at a newline boundary used to allow a premature commit on the unclosed code block, which then needed the same fence type to close it; sofos now treats `~~~` like ```` ``` ```` for the commit-safety check.
22+
- **`Ctrl+C` after `WorkerBusy` is reliably routed to the in-flight job.** The worker used to clear the interrupt flag before announcing it was busy, so a Ctrl+C that landed in the tiny race window slipped through to the shutdown path. Sofos now sends `WorkerBusy` first and clears the flag second, eliminating the race.
23+
- **A panicking worker no longer prints a zeroed session summary.** The shutdown path now carries an explicit `panicked` flag so the goodbye line is preceded by a "Session ended unexpectedly" warning instead of pretending the run finished cleanly with no usage at all.
24+
1325
- **`Ctrl+C` while the slash-command popup is open dismisses the popup.** It used to fall through to the outer handler and quit the session, which surprised users who only wanted to bail out of the suggestion list. Ctrl+C with the popup closed still requests shutdown.
1426
- **The cursor shape now follows the active mode after `/safe` and `/normal`.** Toggling safe mode mid-session previously updated the status line but left the cursor in its old shape; the cursor now switches to the safe-mode underscore on `/safe` and back to the default block on `/normal`, matching the startup behaviour.
1527
- **A panic anywhere inside the TUI now restores the terminal before the backtrace prints.** A process-wide panic hook disables raw mode, disables bracketed paste, pops the keyboard enhancement flags, and shows the cursor through a real-tty handle that bypasses the output-capture pipe — so even a panic that strikes before the local Drop chain runs leaves the user with a usable shell.

src/repl/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,8 @@ impl Repl {
297297
reasoning,
298298
input_tokens: self.session_state.total_input_tokens,
299299
output_tokens: self.session_state.total_output_tokens,
300+
cache_read_tokens: self.session_state.total_cache_read_tokens,
301+
cache_creation_tokens: self.session_state.total_cache_creation_tokens,
300302
}
301303
}
302304

@@ -347,6 +349,7 @@ impl Repl {
347349
cache_read_tokens: self.session_state.total_cache_read_tokens,
348350
cache_creation_tokens: self.session_state.total_cache_creation_tokens,
349351
peak_single_turn_input_tokens: self.session_state.peak_single_turn_input_tokens,
352+
panicked: false,
350353
}
351354
}
352355

src/repl/tui/app.rs

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ pub struct App {
6868
/// `Alt+Up` / `Alt+Down` history navigation. Capped at
6969
/// [`INPUT_HISTORY_CAP`]; the oldest entries are dropped once full.
7070
pub input_history: VecDeque<String>,
71+
/// Live draft saved when the user first presses Alt+Up to enter
72+
/// history. Restored when `history_next` walks past the newest
73+
/// entry, so a typed-but-unsent draft isn't silently erased when
74+
/// the user wanted to scroll through prior prompts.
75+
pub history_draft: Option<String>,
7176
/// Current position in the history ring while navigating.
7277
/// `None` means the textarea holds the user's live draft (not a
7378
/// historical entry).
@@ -206,6 +211,7 @@ impl App {
206211
confirmation: None,
207212
pasted_images: Vec::new(),
208213
input_history: VecDeque::new(),
214+
history_draft: None,
209215
history_cursor: None,
210216
slash_popup: SlashPopup::new(),
211217
}
@@ -249,14 +255,19 @@ impl App {
249255

250256
/// Move one step backward (older) through input history and load the
251257
/// resulting entry into the textarea. No-op when the history is
252-
/// empty. When currently showing the live draft, snapshot... no — we
253-
/// deliberately don't snapshot the live draft; going forward past the
254-
/// newest entry restores an empty textarea, matching reedline's
255-
/// default behaviour.
258+
/// empty. Snapshots the in-progress draft on the first press so a
259+
/// later `history_next` past the newest entry can put it back.
256260
pub fn history_prev(&mut self) {
257261
if self.input_history.is_empty() {
258262
return;
259263
}
264+
// First step into history captures the live draft so we can
265+
// restore it on the way out. Subsequent steps don't overwrite
266+
// it — moving between two history entries doesn't change the
267+
// original draft.
268+
if self.history_cursor.is_none() {
269+
self.history_draft = Some(self.input_text());
270+
}
260271
let next = match self.history_cursor {
261272
None => self.input_history.len() - 1,
262273
Some(0) => 0,
@@ -267,15 +278,20 @@ impl App {
267278
}
268279

269280
/// Move one step forward (newer) through input history. When stepping
270-
/// past the newest entry, clears the textarea back to the live-draft
271-
/// state (`history_cursor = None`).
281+
/// past the newest entry, restores the live draft captured by
282+
/// `history_prev`, falling back to an empty textarea when no draft
283+
/// was saved.
272284
pub fn history_next(&mut self) {
273285
let Some(cursor) = self.history_cursor else {
274286
return;
275287
};
276288
if cursor + 1 >= self.input_history.len() {
277289
self.history_cursor = None;
290+
let draft = self.history_draft.take().unwrap_or_default();
278291
self.clear_input();
292+
if !draft.is_empty() {
293+
self.textarea.insert_str(draft);
294+
}
279295
return;
280296
}
281297
let next = cursor + 1;
@@ -497,6 +513,8 @@ mod tests {
497513
reasoning: String::new(),
498514
input_tokens: 0,
499515
output_tokens: 0,
516+
cache_read_tokens: 0,
517+
cache_creation_tokens: 0,
500518
});
501519
assert!(a.is_safe_mode());
502520
a.status.as_mut().unwrap().mode = Mode::Normal;
@@ -740,6 +758,8 @@ mod tests {
740758
reasoning: "thinking: 10000 tok".into(),
741759
input_tokens: 123,
742760
output_tokens: 456,
761+
cache_read_tokens: 0,
762+
cache_creation_tokens: 0,
743763
});
744764
let s = a.status.as_ref().unwrap();
745765
assert_eq!(s.mode.label(), "safe");

src/repl/tui/event.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ pub struct ExitSummary {
1919
/// pricing cliffs (e.g. gpt-5.4/5.5 at 272K) so the displayed
2020
/// session cost reflects the rate the provider actually billed.
2121
pub peak_single_turn_input_tokens: u32,
22+
/// True when the worker exits because it panicked rather than via
23+
/// the normal shutdown path. Lets the UI prefix the goodbye line
24+
/// with a "Session ended unexpectedly" notice instead of pretending
25+
/// the run finished cleanly with zeroed totals.
26+
pub panicked: bool,
2227
}
2328

2429
/// Tool access mode shown in the status line.
@@ -48,6 +53,12 @@ pub struct StatusSnapshot {
4853
pub reasoning: String,
4954
pub input_tokens: u32,
5055
pub output_tokens: u32,
56+
/// Cumulative cache-read tokens for the session — exposed in the
57+
/// status line so users can see their prompt-cache hit rate
58+
/// without quitting to view the session summary.
59+
pub cache_read_tokens: u32,
60+
/// Cumulative cache-creation tokens billed at the premium rate.
61+
pub cache_creation_tokens: u32,
5162
}
5263

5364
/// Which standard stream a captured line came from.

src/repl/tui/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,15 @@ pub fn run(mut repl: Repl) -> Result<()> {
342342
colored::control::unset_override();
343343

344344
if let Some(summary) = app.exit_summary.take() {
345+
if summary.panicked {
346+
// The worker exited via panic rather than the normal
347+
// shutdown path. The counts in `summary` are zeroed
348+
// placeholders from `ShutdownSender::send_now`, so the
349+
// session-summary table would be misleading; surface the
350+
// explicit notice and skip straight to the goodbye line.
351+
println!();
352+
UI::print_warning("Session ended unexpectedly. See backtrace above for details.");
353+
}
345354
let summary_printed = UI::display_session_summary(
346355
&summary.model,
347356
summary.input_tokens,

src/repl/tui/slash_popup.rs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,16 @@ impl SlashPopup {
103103
/// Show the popup whenever the first line still looks like a partially
104104
/// typed slash command; hide it otherwise.
105105
pub fn sync(&mut self, input: &str) {
106-
// Honour a prior `dismiss` until the input changes.
107-
if self
108-
.dismissed_for
109-
.as_deref()
110-
.is_some_and(|prev| prev == input)
111-
{
112-
return;
106+
// Honour a prior `dismiss` while the user is editing within the
107+
// same slash-command "family" — either the current input still
108+
// begins with the dismissed text (kept typing) or the dismissed
109+
// text begins with the current input (backspaced). Switching
110+
// to an unrelated command (e.g. dismiss `/clear`, then type
111+
// `/list`) breaks both prefix tests and re-opens the popup.
112+
if let Some(prev) = self.dismissed_for.as_deref() {
113+
if prev == input || input.starts_with(prev) || prev.starts_with(input) {
114+
return;
115+
}
113116
}
114117
self.dismissed_for = None;
115118

@@ -324,8 +327,21 @@ mod tests {
324327
popup.sync("/c");
325328
assert!(!popup.is_visible());
326329

327-
// Typing another character clears the dismissal.
330+
// Typing more inside the same prefix family keeps the dismissal
331+
// — the user is still editing the same slash-command attempt,
332+
// and a small typo fix shouldn't reopen the suggestion list.
328333
popup.sync("/cl");
334+
assert!(!popup.is_visible());
335+
336+
// Backspacing into a shared prefix likewise keeps the dismissal.
337+
popup.sync("/");
338+
assert!(!popup.is_visible());
339+
340+
// Switching to a clearly different slash-command attempt
341+
// re-opens the popup: neither input is a prefix of the
342+
// dismissed text (`/r` is a real prefix in the catalog —
343+
// `/resume`).
344+
popup.sync("/r");
329345
assert!(popup.is_visible());
330346
}
331347
}

src/repl/tui/ui.rs

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,14 +171,30 @@ fn draw_input(frame: &mut Frame, area: Rect, app: &App) {
171171

172172
let safe = app.is_safe_mode();
173173
let prompt_glyph = if safe { " : " } else { " > " };
174-
let title = Line::from(vec![Span::styled(
174+
let content_width = area.width.saturating_sub(2);
175+
176+
let mut textarea = app.textarea.clone();
177+
let measure = textarea.measure(content_width);
178+
let overflow_rows = measure.content_rows.saturating_sub(MAX_INPUT_CONTENT_ROWS);
179+
180+
let mut title_spans = vec![Span::styled(
175181
prompt_glyph,
176182
Style::default()
177183
.fg(if safe { Color::Yellow } else { TITLE_FG })
178184
.add_modifier(Modifier::BOLD),
179-
)]);
185+
)];
186+
if overflow_rows > 0 {
187+
// Long paste / long draft: the textarea is showing only the
188+
// first `MAX_INPUT_CONTENT_ROWS` rows. Surface the cap in the
189+
// title line so the user knows their input continues below the
190+
// visible window.
191+
title_spans.push(Span::styled(
192+
format!(" … +{} more ", overflow_rows),
193+
Style::default().fg(Color::DarkGray),
194+
));
195+
}
196+
let title = Line::from(title_spans);
180197

181-
let mut textarea = app.textarea.clone();
182198
textarea.set_block(
183199
Block::default()
184200
.borders(Borders::ALL)
@@ -301,15 +317,25 @@ fn draw_hint(frame: &mut Frame, area: Rect, app: &App) {
301317
fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
302318
// Fall back to the model name we already know if the worker hasn't
303319
// pushed a full snapshot yet (e.g. very first frame).
304-
let (model, mode, reasoning, in_tok, out_tok) = match &app.status {
320+
let (model, mode, reasoning, in_tok, out_tok, cache_read, cache_create) = match &app.status {
305321
Some(s) => (
306322
s.model.as_str(),
307323
s.mode,
308324
s.reasoning.as_str(),
309325
s.input_tokens,
310326
s.output_tokens,
327+
s.cache_read_tokens,
328+
s.cache_creation_tokens,
329+
),
330+
None => (
331+
app.model_label.as_str(),
332+
Mode::Normal,
333+
"",
334+
0u32,
335+
0u32,
336+
0u32,
337+
0u32,
311338
),
312-
None => (app.model_label.as_str(), Mode::Normal, "", 0u32, 0u32),
313339
};
314340

315341
let mode_style = match mode {
@@ -342,6 +368,18 @@ fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
342368
));
343369
}
344370

371+
// Surface the cache numbers when either side has a non-zero
372+
// total. The cache-read percentage is the cheapest cost lever
373+
// the user has and they otherwise can't see it without
374+
// dropping out of the session.
375+
if cache_read > 0 || cache_create > 0 {
376+
spans.push(Span::styled(SEP, Style::default().fg(Color::DarkGray)));
377+
spans.push(Span::styled(
378+
format!("cache: {}r {}w", cache_read, cache_create),
379+
Style::default().fg(MODEL_FG),
380+
));
381+
}
382+
345383
frame.render_widget(Paragraph::new(Line::from(spans)), area);
346384
}
347385

src/repl/tui/worker.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,18 @@ impl<'a> ShutdownSender<'a> {
7575
if self.sent {
7676
return;
7777
}
78+
// Reaching `send_now` without an installed summary means the
79+
// worker is panicking on the way out — set `panicked: true`
80+
// so the UI can prefix the goodbye line instead of pretending
81+
// a fully zeroed normal exit.
7882
let summary = self.summary.take().unwrap_or(ExitSummary {
7983
model: String::new(),
8084
input_tokens: 0,
8185
output_tokens: 0,
8286
cache_read_tokens: 0,
8387
cache_creation_tokens: 0,
8488
peak_single_turn_input_tokens: 0,
89+
panicked: true,
8590
});
8691
let _ = self.ui_tx.send(UiEvent::WorkerShutdown(summary));
8792
self.sent = true;
@@ -107,8 +112,15 @@ fn run(
107112
match job {
108113
Job::Shutdown => break,
109114
Job::Message { text, images } => {
110-
interrupt.store(false, Ordering::SeqCst);
115+
// Notify the UI we're busy BEFORE clearing the
116+
// interrupt flag. Otherwise a Ctrl+C that lands in
117+
// the window between the two falls through to
118+
// `request_shutdown` (the bare-press handler in
119+
// `input.rs`) instead of interrupting the upcoming
120+
// turn — `app.busy()` is still false because
121+
// `WorkerBusy` hasn't been delivered yet.
111122
let _ = ui_tx.send(UiEvent::WorkerBusy("processing".into()));
123+
interrupt.store(false, Ordering::SeqCst);
112124
if let Err(e) = repl.process_message(&text, images) {
113125
if !matches!(e, crate::error::SofosError::Interrupted) {
114126
if e.is_blocked() {

src/ui/cost.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ impl UI {
3838
total_cache_creation_tokens: u32,
3939
peak_single_turn_input_tokens: u32,
4040
) -> bool {
41-
if total_input_tokens == 0 && total_output_tokens == 0 {
41+
// A fully-cached session has `total_input_tokens == 0` and
42+
// `total_output_tokens == 0` because the new-input field
43+
// doesn't include cache reads. Without the cache-read clause
44+
// a session that only re-hit cache would print no summary at
45+
// all, which looks like a bug to users running short
46+
// exploratory prompts.
47+
if total_input_tokens == 0 && total_output_tokens == 0 && total_cache_read_tokens == 0 {
4248
return false;
4349
}
4450

@@ -102,6 +108,26 @@ impl UI {
102108
format!("${:.4}", estimated_cost).bright_yellow().bold()
103109
);
104110

111+
// Surface the per-prompt cliff when premium pricing kicked in
112+
// — users otherwise have no way to tell that crossing the
113+
// GPT-5.5 / GPT-5.4 input-token threshold doubled the rate
114+
// for every later turn in this session.
115+
let info = crate::api::model_info::lookup(model);
116+
if let Some(tier) = info.premium_tier {
117+
if peak_single_turn_input_tokens > tier.input_threshold {
118+
println!(
119+
"{:<20} {}",
120+
"".bright_white(),
121+
format!(
122+
"(premium tier: peak input {} exceeded {} threshold)",
123+
Self::format_number(peak_single_turn_input_tokens),
124+
Self::format_number(tier.input_threshold)
125+
)
126+
.dimmed()
127+
);
128+
}
129+
}
130+
105131
println!("{}", "─".repeat(50).bright_cyan());
106132
println!();
107133
true

0 commit comments

Comments
 (0)