feat(ui): demo-mode step-caption stealth buffer (#22, ADR-0047 D3/D4)

Ctrl+] (decodes to Char('5')+CONTROL) toggles an invisible capture
buffer: typed characters accumulate without touching the input/output,
Backspace edits, every other key is inert, and a second Ctrl+] commits
the text to a caption box (empty commit dismisses). Handled at the top
of handle_key — before the badge and modal gates — so captions can be
authored over the load picker (the #24 cast); an ordinary keystroke
clears a visible caption. The caption renders as a floating
black-on-yellow box at the output panel's bottom-right, wrapped to <=3
lines (then ellipsised), with the keystroke badge stacked directly
above it when both are present.

Tier 1: capture/commit, invisible accumulation, backspace, inert keys
(incl. no badge), empty-commit dismiss, next-key clear, over-modal,
demo-off inert. Tier 2: caption / stacked / wrapped snapshots. Phase C
of ADR-0047 — feature complete.
This commit is contained in:
claude@clouddev1
2026-06-11 08:32:16 +00:00
parent 2584e76b22
commit 241f60c503
5 changed files with 408 additions and 20 deletions
+199 -5
View File
@@ -394,6 +394,19 @@ pub struct App {
/// (ADR-0047 D5). The runtime watches it so a *new* badge — even the
/// same label twice in a row (Tab, Tab) — restarts the expiry timer.
pub demo_badge_seq: u64,
/// The step-caption currently displayed in demo mode (ADR-0047 D3),
/// or `None`. Committed from the stealth buffer on the closing
/// `Ctrl+]`; cleared by the next ordinary keystroke (or an empty
/// commit). Rendered as a wrapped box stacked above the badge.
pub demo_caption: Option<String>,
/// Whether the stealth caption buffer is open (ADR-0047 D3): between
/// the opening and closing `Ctrl+]`, typed characters accumulate into
/// `demo_caption_buffer` invisibly and every other key is inert.
pub demo_caption_capturing: bool,
/// The invisible accumulator for the caption being typed while
/// `demo_caption_capturing` (ADR-0047 D3). Never rendered directly;
/// its trimmed contents become `demo_caption` on commit.
pub demo_caption_buffer: String,
/// The DSL → SQL teaching echo (ADR-0038) for the command currently
/// being rendered: set from the success event just before its handler
/// runs, consumed by `note_ok_summary` (which pushes it beneath
@@ -574,6 +587,9 @@ impl App {
demo_mode: false,
demo_badge: None,
demo_badge_seq: 0,
demo_caption: None,
demo_caption_capturing: false,
demo_caption_buffer: String::new(),
pending_echo: None,
}
}
@@ -1090,12 +1106,25 @@ impl App {
}
trace!(?key, "handle_key");
// ADR-0047 D3: the demo step-caption stealth buffer runs before
// every other gate — even ahead of the badge and the modal gate —
// so it can be authored over the load picker (the `#24` cast) and
// so captured keystrokes never leak into the input, a badge, or a
// command. `Ctrl+]` toggles capture; while capturing, the key is
// consumed here.
if self.demo_mode {
if let Some(actions) = self.handle_demo_caption_key(key) {
return actions;
}
// Not a caption key: any ordinary keystroke dismisses a
// visible caption (it then falls through to normal handling).
self.demo_caption = None;
}
// ADR-0047 D2: in demo mode raise a transient badge for an
// otherwise-invisible key. Done before every gate below so it
// fires even while a modal is open (the `#24` projects cast) or
// in navigation mode. The runtime times its expiry (D5). (Phase
// C inserts the caption-capture gate *above* this, so captured
// keystrokes return early and never raise a badge.)
// otherwise-invisible key. Done before the modal / nav gates so
// it fires even while a modal is open (the `#24` projects cast)
// or in navigation mode. The runtime times its expiry (D5).
if self.demo_mode
&& let Some(label) = demo_badge_label(&key)
{
@@ -1206,6 +1235,59 @@ impl App {
}
}
/// Drive the demo step-caption stealth buffer (ADR-0047 D3).
///
/// Returns `Some(_)` when the key belongs to the caption mechanism
/// (the `Ctrl+]` toggle, or any key while capturing) — the caller
/// then returns it and processes nothing else. Returns `None` when
/// the key is not consumed, so normal handling continues.
///
/// `Ctrl+]` decodes to `Char('5') + CONTROL` (ADR-0047 D3, verified
/// against crossterm 0.29). Only active in demo mode (the caller
/// gates on `self.demo_mode`).
fn handle_demo_caption_key(&mut self, key: KeyEvent) -> Option<Vec<Action>> {
let is_toggle = key.code == KeyCode::Char('5')
&& key.modifiers.contains(KeyModifiers::CONTROL);
if self.demo_caption_capturing {
if is_toggle {
// Commit: a trimmed, non-empty buffer becomes the caption;
// an empty commit dismisses any caption (explicit clear).
self.demo_caption_capturing = false;
let text = std::mem::take(&mut self.demo_caption_buffer);
let trimmed = text.trim();
self.demo_caption =
(!trimmed.is_empty()).then(|| trimmed.to_string());
} else {
match key.code {
// Plain characters accumulate invisibly; the prompt
// and output are untouched.
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
self.demo_caption_buffer.push(c);
}
KeyCode::Backspace => {
self.demo_caption_buffer.pop();
}
// Every other key (Enter, arrows, Tab, …) is inert
// while capturing.
_ => {}
}
}
return Some(Vec::new());
}
if is_toggle {
// Open capture. Starting a new annotation clears any caption
// currently on screen.
self.demo_caption_capturing = true;
self.demo_caption_buffer.clear();
self.demo_caption = None;
return Some(Vec::new());
}
None
}
fn cursor_left(&mut self) {
let mut idx = self.input_cursor;
while idx > 0 {
@@ -3089,6 +3171,118 @@ mod tests {
assert_eq!(app.demo_badge_seq, 1);
}
// ---- ADR-0047 (issue #22): demo-mode step-caption stealth buffer ----
/// `Ctrl+]` — the caption toggle (decodes to Char('5')+CONTROL).
fn caption_toggle() -> AppEvent {
key_mod(KeyCode::Char('5'), KeyModifiers::CONTROL)
}
#[test]
fn demo_caption_toggle_captures_then_commits() {
let mut app = App::new();
app.demo_mode = true;
app.update(caption_toggle());
assert!(app.demo_caption_capturing, "first Ctrl+] opens capture");
assert_eq!(app.demo_caption, None);
type_str(&mut app, "Press Tab");
// The text accumulates invisibly — nothing on the input line.
assert_eq!(app.input, "");
assert_eq!(app.demo_caption_buffer, "Press Tab");
assert_eq!(app.demo_caption, None, "not shown until committed");
app.update(caption_toggle());
assert!(!app.demo_caption_capturing, "second Ctrl+] commits");
assert_eq!(app.demo_caption.as_deref(), Some("Press Tab"));
assert_eq!(app.demo_caption_buffer, "", "buffer drained on commit");
assert_eq!(app.input, "", "input never touched");
}
#[test]
fn demo_caption_backspace_edits_the_buffer() {
let mut app = App::new();
app.demo_mode = true;
app.update(caption_toggle());
type_str(&mut app, "Helloo");
app.update(key(KeyCode::Backspace));
assert_eq!(app.demo_caption_buffer, "Hello");
assert_eq!(app.input, "");
}
#[test]
fn demo_caption_other_keys_are_inert_while_capturing() {
let mut app = App::new();
app.demo_mode = true;
app.update(caption_toggle());
type_str(&mut app, "note");
// Enter must not submit, Tab must not complete, arrows do nothing.
let a1 = app.update(key(KeyCode::Enter));
let a2 = app.update(key(KeyCode::Tab));
let a3 = app.update(key(KeyCode::Up));
assert!(a1.is_empty() && a2.is_empty() && a3.is_empty());
assert!(app.demo_caption_capturing, "still capturing");
assert_eq!(app.demo_caption_buffer, "note");
assert_eq!(app.input, "");
assert_eq!(app.demo_badge, None, "inert keys raise no badge while capturing");
}
#[test]
fn demo_caption_empty_commit_dismisses() {
let mut app = App::new();
app.demo_mode = true;
app.demo_caption = Some("old".to_string());
// Open (clears the visible caption) then commit empty.
app.update(caption_toggle());
assert_eq!(app.demo_caption, None, "opening clears the visible caption");
app.update(caption_toggle());
assert_eq!(app.demo_caption, None, "empty commit leaves nothing");
assert!(!app.demo_caption_capturing);
}
#[test]
fn demo_caption_cleared_by_next_ordinary_keystroke() {
let mut app = App::new();
app.demo_mode = true;
app.demo_caption = Some("step 1".to_string());
// An ordinary key clears the caption, then is processed normally.
app.update(key(KeyCode::Char('a')));
assert_eq!(app.demo_caption, None);
assert_eq!(app.input, "a", "the key still reaches the input");
}
#[test]
fn demo_caption_captures_over_an_open_modal() {
// The stealth buffer sits before the modal gate, so captions can
// be authored while the load picker is up (the `#24` cast).
let mut app = App::new();
app.demo_mode = true;
app.modal = Some(Modal::LoadPicker(LoadPickerModal {
entries: Vec::new(),
selected: 0,
sub_mode: LoadPickerSubMode::List,
}));
app.update(caption_toggle());
type_str(&mut app, "pick one");
app.update(caption_toggle());
assert_eq!(app.demo_caption.as_deref(), Some("pick one"));
// The modal is untouched by the capture.
assert!(matches!(app.modal, Some(Modal::LoadPicker(_))));
}
#[test]
fn demo_mode_off_makes_ctrl_rbracket_inert() {
let mut app = App::new();
assert!(!app.demo_mode);
app.update(caption_toggle());
type_str(&mut app, "x");
assert!(!app.demo_caption_capturing);
assert_eq!(app.demo_caption, None);
// Ctrl+] did nothing; the later 'x' is an ordinary character.
assert_eq!(app.input, "x");
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));