M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,186 @@
//! Undo-tip trigger: detects a user-initiated wipe of a substantial prompt
//! draft so the pager can hint that the undo chord brings it back.
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use super::EphemeralTip;
use crate::input::key::KeyShortcut;
use crate::theme::Theme;
/// Ephemeral-tip dedup key for the undo hint.
pub(crate) const UNDO_TIP_KEY: &str = "undo_tip";
/// Key into the per-session in-memory seen-count map
/// (`AppView::tip_seen_counts`) for the undo tip. Not persisted to disk.
pub(crate) const UNDO_TIP_SEEN_KEY: &str = "undo_tip_shown_count";
/// The tip stops showing after this many shows within a single session.
const UNDO_TIP_SEEN_CAP: u32 = 3;
/// A draft must have reached this many chars for its wipe to matter.
const FIRE_PEAK_LEN: usize = 20;
/// Post-wipe residue at or below this many chars counts as "cleared".
const FIRE_RESIDUE_LEN: usize = 5;
/// Undo chord for the tip copy: always `ctrl+z`. Most macOS terminal
/// emulators capture Cmd by default and don't forward Cmd+Z to a raw-mode TUI,
/// so Ctrl+Z is the chord actually delivered on every platform. Still derived
/// from the real binding (not a literal) — Ctrl+Z is one of the two chords
/// [`crate::input::key::is_undo_key`] accepts — so it can't drift.
fn undo_chord_label() -> String {
KeyShortcut::new(KeyCode::Char('z'), KeyModifiers::CONTROL)
.display()
.to_ascii_lowercase()
}
/// Build the "Input cleared · {chord} to undo" tip, seen-gated to
/// [`UNDO_TIP_SEEN_CAP`] shows per session (in-memory).
pub fn undo_tip() -> EphemeralTip {
let theme = Theme::current();
let dim = Style::default().fg(theme.gray);
// Key chord styled like the shortcuts bar (bold secondary on dim text).
let chord = Style::default()
.fg(theme.text_secondary)
.add_modifier(Modifier::BOLD);
EphemeralTip::new(
UNDO_TIP_KEY,
Line::from(vec![
Span::styled("Input cleared · ", dim),
Span::styled(undo_chord_label(), chord),
Span::styled(" to undo", dim),
]),
)
.with_session_seen_cap(UNDO_TIP_SEEN_KEY, UNDO_TIP_SEEN_CAP)
}
/// Tracks prompt text length across user key edits and fires when a
/// substantial draft collapses to (near) empty in the user's hands.
///
/// The detector must be fed only user-initiated edits. Programmatic
/// mutations — submit clears, queue restores, slash completions — must not
/// be observed; the `last_len` resync absorbs any that slip through at the
/// next user edit without firing on a peak the user did not build down from.
#[derive(Debug, Default)]
pub struct ClearDetector {
/// High-water mark of the draft since the last fire or resync.
peak_len: usize,
/// Text length after the last observed user edit; a mismatch at the
/// next edit means programmatic changes happened in between.
last_len: usize,
}
impl ClearDetector {
/// Observe one user-initiated edit as (length before, length after).
/// Returns true when a substantial draft was just wiped.
pub fn observe_user_edit(&mut self, before: usize, after: usize) -> bool {
if before != self.last_len {
// Programmatic mutation since the last user edit: adopt the
// current draft as the baseline instead of firing on a peak
// the user did not build down from.
self.peak_len = before;
}
let fired = self.peak_len >= FIRE_PEAK_LEN && after <= FIRE_RESIDUE_LEN;
// Reset after a fire so re-typing must build a fresh peak.
self.peak_len = if fired {
after
} else {
self.peak_len.max(after)
};
self.last_len = after;
fired
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Replay a typing session as successive (before, after) user edits.
fn type_to(detector: &mut ClearDetector, from: usize, to: usize) {
let mut len = from;
while len != to {
let next = if to > len { len + 1 } else { len - 1 };
assert!(
!detector.observe_user_edit(len, next),
"no fire expected while moving {len} -> {next}"
);
len = next;
}
}
#[test]
fn gradual_delete_fires_at_residue_threshold() {
let mut d = ClearDetector::default();
type_to(&mut d, 0, 30);
type_to(&mut d, 30, 6); // still above the residue threshold
assert!(d.observe_user_edit(6, 5), "crossing into residue fires");
// Peak was reset: continuing to delete must not re-fire.
assert!(!d.observe_user_edit(5, 0));
}
#[test]
fn one_shot_clear_fires() {
let mut d = ClearDetector::default();
type_to(&mut d, 0, 25);
assert!(d.observe_user_edit(25, 0), "ctrl+c style 25 -> 0 wipe");
}
#[test]
fn short_draft_never_fires() {
let mut d = ClearDetector::default();
type_to(&mut d, 0, 19); // one below FIRE_PEAK_LEN
assert!(!d.observe_user_edit(19, 0));
}
#[test]
fn programmatic_clear_resyncs_without_firing() {
let mut d = ClearDetector::default();
type_to(&mut d, 0, 30);
// Submit wiped the draft outside the detector (unobserved); the next
// user edit starts from 0 and must not fire on the stale peak.
assert!(!d.observe_user_edit(0, 1));
assert!(!d.observe_user_edit(1, 0), "tiny draft, no fire");
}
#[test]
fn wiping_a_programmatically_restored_draft_fires() {
let mut d = ClearDetector::default();
// Queue-edit restored an 80-char draft (unobserved), then the user
// wipes it: the resync adopts 80 as the peak and the wipe fires.
assert!(d.observe_user_edit(80, 0));
}
#[test]
fn refire_requires_building_a_new_peak() {
let mut d = ClearDetector::default();
type_to(&mut d, 0, 25);
assert!(d.observe_user_edit(25, 0));
type_to(&mut d, 0, 25);
assert!(d.observe_user_edit(25, 0), "fresh peak fires again");
}
#[test]
fn undo_tip_builder_applies_seen_gating() {
// Key/cap echoes would be tautological; the real wiring to pin is
// that the builder opts into the per-session seen gate at all.
assert_eq!(
undo_tip().session_seen.map(|(key, _cap)| key),
Some(UNDO_TIP_SEEN_KEY)
);
}
#[test]
fn undo_tip_chord_is_ctrl_z() {
// Always ctrl+z (the chord terminals actually deliver), on every
// platform — derived from the real binding so it can't drift.
assert_eq!(undo_chord_label(), "ctrl+z");
// The advertised chord is genuinely one is_undo_key accepts — so the
// label can't drift from the binding it documents.
assert!(crate::input::key::is_undo_key(
&crossterm::event::KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL,)
));
}
}
@@ -0,0 +1,480 @@
//! Clipboard-image tip trigger: while the terminal is focused and the active
//! agent is image-eligible, hint that ctrl+v pastes an image sitting on the
//! pasteboard — without waiting for a focus switch.
//!
//! Trigger model: opportunistic, focus-scoped polling. The caller drives
//! [`ClipboardFocusTipState::poll`] only from event-loop iterations that are
//! already running for another reason (input, FocusGained, resize, an animation
//! tick); nothing schedules a wakeup and the tip never forces animation, so an
//! idle/hibernating/unfocused app polls zero times. Each in-window poll is
//! throttled to one cheap `changeCount` read per [`POLL_INTERVAL`], and the
//! heavier type classification runs ONLY on a changeCount delta. Frequency is
//! further capped by a fire cooldown plus a changeCount dedup (the same copied
//! content never re-fires), not a seen-count — the tip is contextual and
//! recurring by design.
//!
//! The state machine takes the clock and BOTH probe steps as inputs, so every
//! transition — including "classify is not called when the changeCount is
//! unchanged" — is unit-testable with a fake clock and call-counting probes.
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use super::EphemeralTip;
use crate::input::key::KeyShortcut;
use crate::theme::Theme;
/// Ephemeral-tip dedup key for the clipboard-image hint.
pub const CLIPBOARD_IMAGE_TIP_KEY: &str = "clipboard_image_tip";
/// Throttle for the opportunistic pasteboard poll: at most one `changeCount`
/// read per this interval, even when the event loop iterates at ~30fps. The
/// poll rides existing loop activity (it never schedules a tick), so this only
/// caps how often an already-running iteration touches the pasteboard.
const POLL_INTERVAL: Duration = Duration::from_secs(1);
/// Minimum spacing between fires, so copy-heavy workflows aren't nagged on every
/// new image.
const FIRE_COOLDOWN: Duration = Duration::from_secs(30);
/// Paste chord for the tip copy: always `ctrl+v`. Most macOS terminal emulators
/// capture Cmd by default and don't forward Cmd+V to a raw-mode TUI, so Ctrl+V
/// is the chord actually delivered. Derived from the real binding (not a
/// literal) — Ctrl+V is one of the two chords [`crate::input::key::is_paste_key`]
/// accepts — so it can't drift.
fn paste_label() -> String {
KeyShortcut::new(KeyCode::Char('v'), KeyModifiers::CONTROL)
.display()
.to_ascii_lowercase()
}
/// Build the "Image in clipboard · {chord} to paste" tip. No seen-cap:
/// changeCount dedup + the cooldown are the frequency caps.
pub fn clipboard_image_tip() -> EphemeralTip {
let theme = Theme::current();
let dim = Style::default().fg(theme.gray);
// Key chord styled like the shortcuts bar (bold secondary on dim text).
let chord = Style::default()
.fg(theme.text_secondary)
.add_modifier(Modifier::BOLD);
EphemeralTip::new(
CLIPBOARD_IMAGE_TIP_KEY,
Line::from(vec![
Span::styled("Image in clipboard · ", dim),
Span::styled(paste_label(), chord),
Span::styled(" to paste", dim),
]),
)
}
/// Result of one pasteboard classification pass.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CheckOutcome {
/// Pasteboard change count at probe time (`None` when unavailable).
pub change_count: Option<u64>,
/// Whether a *pasteable* image was advertised: raster types with no
/// file-URL types alongside. File-manager copies (Finder) put a file-icon
/// raster on the board next to the file URLs, but ctrl+v routes those
/// through path handling, so they must not fire a tip promising an image
/// paste (see `clipboard_image_snapshot`).
pub has_image: bool,
}
/// The `classify` step: the heavier native probe in one pasteboard pass — the
/// changeCount plus the advertised type list (no image bytes, no subprocess),
/// so it stays sub-millisecond and safe to call inline on the ~30fps loop.
///
/// The throttled poll reaches here ONLY on a changeCount delta (see
/// [`ClipboardFocusTipState::poll`]); the cheap changeCount-only read gates it.
/// The expensive one-time AppKit `dlopen` is pre-warmed off the UI thread at the
/// first focus-gain (see `clipboard::prewarm_image_probe`); if the warm-up
/// hasn't finished yet the memoised `dlopen` happens here once as a fallback.
pub fn run_clipboard_check() -> CheckOutcome {
let (change_count, has_image) = crate::clipboard::clipboard_image_snapshot();
CheckOutcome {
change_count,
has_image,
}
}
/// Pure state machine for the focus-scoped, opportunistically-polled
/// clipboard-image tip.
///
/// Owns the poll throttle, the changeCount delta-detection, the fire cooldown,
/// and the changeCount dedup. It never schedules itself — the caller drives
/// [`Self::poll`] from event-loop iterations that are already running for some
/// other reason (input, FocusGained, resize, an animation tick), so an idle or
/// hibernating app polls zero times. All inputs (clock, both probe steps) are
/// injected, so every transition is unit-testable with a fake clock and
/// call-counting probes.
#[derive(Debug, Default)]
pub struct ClipboardFocusTipState {
/// When the last poll actually read the pasteboard (throttle anchor).
last_poll_at: Option<Instant>,
/// changeCount observed by the last cheap read; a differing value is what
/// warrants paying for the type classification.
last_seen_change_count: Option<u64>,
/// When the tip last actually showed (cooldown anchor).
last_fired_at: Option<Instant>,
/// changeCount of the content that last fired; identical content never
/// fires twice even across long gaps.
last_fired_change_count: Option<u64>,
}
impl ClipboardFocusTipState {
/// Throttle gate: at most one poll per [`POLL_INTERVAL`], so a ~30fps loop
/// still reads the pasteboard at most ~once a second. Pure — does not mutate.
pub fn due_to_poll(&self, now: Instant) -> bool {
self.last_poll_at
.is_none_or(|at| now.duration_since(at) >= POLL_INTERVAL)
}
/// Whether `change_count` differs from the one the last cheap read saw — the
/// signal that the pasteboard changed and a classification is worth paying
/// for. A `None` (changeCount unavailable, e.g. AppKit failed to load) is
/// treated as "nothing new" so the cheap path never escalates blindly.
fn is_new_change_count(&self, change_count: Option<u64>) -> bool {
change_count.is_some() && change_count != self.last_seen_change_count
}
/// Run one throttled poll on an already-running loop iteration.
///
/// `cheap` reads ONLY the pasteboard changeCount (one Obj-C message);
/// `classify` runs the heavier type scan. The contract that keeps the idle
/// cost at ~zero: `classify` is invoked ONLY when `cheap` reports a
/// changeCount that differs from the last one seen. Returns the classified
/// [`CheckOutcome`] for the caller to evaluate via [`Self::should_fire`], or
/// `None` when the poll was throttled or the changeCount was unchanged (the
/// hot path — no classify, no redraw).
///
/// Dedup-commit policy: the classify-dedup (`last_seen_change_count`) is
/// advanced here ONLY for content this poll fully handles — non-image
/// content, which has nothing to show. A fireable image is deferred to
/// [`Self::note_fired`] (called only on a landed show): committing it here
/// would let a *refused* show skip re-classification forever, breaking the
/// "refused show burns nothing" contract. So an image found but not shown
/// re-classifies on the next poll; a shown image is deduped post-cooldown.
pub fn poll(
&mut self,
now: Instant,
cheap: impl FnOnce() -> Option<u64>,
classify: impl FnOnce() -> CheckOutcome,
) -> Option<CheckOutcome> {
if !self.due_to_poll(now) {
return None;
}
self.last_poll_at = Some(now);
let change_count = cheap();
if !self.is_new_change_count(change_count) {
return None;
}
let outcome = classify();
// Commit the classify-dedup now only for non-image content (nothing to
// show, so it's fully handled). A fireable image waits for `note_fired`
// so a refused show stays retryable.
if !outcome.has_image {
self.last_seen_change_count = change_count;
}
Some(outcome)
}
/// Whether `outcome` warrants showing the tip right now. Pure check — the
/// caller commits via [`Self::note_fired`] only after the show actually
/// lands, so refused shows never burn the cooldown or dedup.
pub fn should_fire(&self, outcome: &CheckOutcome, now: Instant) -> bool {
outcome.has_image
&& !self.in_cooldown(now)
&& (outcome.change_count.is_none()
|| outcome.change_count != self.last_fired_change_count)
}
/// Commit a successful (landed) show: anchors the cooldown, records the
/// fired changeCount, and — because the show is now fully handled — commits
/// the classify-dedup too (`poll` defers it for fireable images). So the
/// same image isn't re-scanned once the cooldown elapses, while a refused
/// show (which never calls this) leaves `last_seen` stale and stays retryable.
pub fn note_fired(&mut self, outcome: &CheckOutcome, now: Instant) {
self.last_fired_at = Some(now);
if outcome.change_count.is_some() {
self.last_fired_change_count = outcome.change_count;
self.last_seen_change_count = outcome.change_count;
}
}
/// Whether the fire cooldown is still in effect. Part of the caller's
/// in-window gate, so during the cooldown the poll touches the pasteboard
/// zero times.
pub fn in_cooldown(&self, now: Instant) -> bool {
self.last_fired_at
.is_some_and(|at| now.duration_since(at) < FIRE_COOLDOWN)
}
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use super::*;
fn outcome(change_count: Option<u64>, has_image: bool) -> CheckOutcome {
CheckOutcome {
change_count,
has_image,
}
}
/// Drive a full successful fire through the poll path (changeCount delta →
/// classify → should_fire → note_fired).
fn fire_via_poll(state: &mut ClipboardFocusTipState, now: Instant, change_count: u64) {
let got = state
.poll(
now,
|| Some(change_count),
|| outcome(Some(change_count), true),
)
.expect("a changeCount delta should classify");
assert!(state.should_fire(&got, now));
state.note_fired(&got, now);
}
#[test]
fn paste_chord_is_ctrl_v() {
// Always ctrl+v (the chord terminals actually deliver) — derived from
// the real binding so the label can't drift.
assert_eq!(paste_label(), "ctrl+v");
assert!(crate::input::key::is_paste_key(
&crossterm::event::KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)
));
}
#[test]
fn clipboard_image_tip_is_not_seen_capped() {
// Frequency is capped by changeCount dedup + cooldown, never a
// seen-count — so the builder must not opt into the seen gate.
assert!(clipboard_image_tip().session_seen.is_none());
}
#[test]
fn throttle_limits_reads_to_one_per_interval() {
let mut state = ClipboardFocusTipState::default();
let t0 = Instant::now();
let cheap_reads = Cell::new(0u32);
// First poll reads the cheap changeCount.
let _ = state.poll(
t0,
|| {
cheap_reads.set(cheap_reads.get() + 1);
Some(1)
},
|| outcome(Some(1), false),
);
assert_eq!(cheap_reads.get(), 1);
// A second poll within the interval is throttled — no cheap read at all.
let _ = state.poll(
t0 + Duration::from_millis(500),
|| {
cheap_reads.set(cheap_reads.get() + 1);
Some(1)
},
|| outcome(Some(1), false),
);
assert_eq!(cheap_reads.get(), 1, "two polls <1s apart → one read");
// Once the interval elapses the next poll reads again.
let _ = state.poll(
t0 + POLL_INTERVAL,
|| {
cheap_reads.set(cheap_reads.get() + 1);
Some(2)
},
|| outcome(Some(2), false),
);
assert_eq!(cheap_reads.get(), 2, "poll resumes after the interval");
}
#[test]
fn unchanged_change_count_skips_classify_and_does_not_fire() {
let mut state = ClipboardFocusTipState::default();
let t0 = Instant::now();
let classify_calls = Cell::new(0u32);
// First poll: changeCount 5 is new → classify runs once.
let first = state.poll(
t0,
|| Some(5),
|| {
classify_calls.set(classify_calls.get() + 1);
outcome(Some(5), false)
},
);
assert_eq!(first, Some(outcome(Some(5), false)));
assert_eq!(classify_calls.get(), 1);
// Next interval, SAME changeCount → cheap path returns; the call-counter
// proves the classify probe was NOT invoked.
let next = state.poll(
t0 + POLL_INTERVAL,
|| Some(5),
|| {
classify_calls.set(classify_calls.get() + 1);
outcome(Some(5), false)
},
);
assert_eq!(next, None, "unchanged changeCount → no outcome");
assert_eq!(
classify_calls.get(),
1,
"classify must not run on an unchanged changeCount"
);
}
#[test]
fn change_to_image_classifies_and_fires_once() {
let mut state = ClipboardFocusTipState::default();
let t0 = Instant::now();
let classify_calls = Cell::new(0u32);
let got = state
.poll(
t0,
|| Some(3),
|| {
classify_calls.set(classify_calls.get() + 1);
outcome(Some(3), true)
},
)
.expect("a changeCount delta classifies");
assert_eq!(classify_calls.get(), 1);
assert!(state.should_fire(&got, t0));
state.note_fired(&got, t0);
// Same content, past the cooldown, changeCount unchanged → cheap path
// short-circuits; the classify closure panics if reached, proving the
// same image never re-classifies or re-fires.
let later = t0 + FIRE_COOLDOWN + Duration::from_secs(1);
let again = state.poll(
later,
|| Some(3),
|| panic!("classify must not run for unchanged (deduped) content"),
);
assert_eq!(again, None, "same image never re-fires");
}
#[test]
fn refused_show_keeps_retrying_then_dedups_once_landed() {
let mut state = ClipboardFocusTipState::default();
let t0 = Instant::now();
let classify_calls = Cell::new(0u32);
// Image copied: classify runs and returns a fireable image.
let got = state
.poll(
t0,
|| Some(7),
|| {
classify_calls.set(classify_calls.get() + 1);
outcome(Some(7), true)
},
)
.expect("a changeCount delta classifies");
assert_eq!(classify_calls.get(), 1);
assert!(state.should_fire(&got, t0));
// Show REFUSED — the caller did NOT call note_fired. `poll` must not have
// advanced `last_seen` for a fireable image, so the same changeCount
// RE-classifies on the next poll (preserving the retry).
let t1 = t0 + POLL_INTERVAL;
let retry = state.poll(
t1,
|| Some(7),
|| {
classify_calls.set(classify_calls.get() + 1);
outcome(Some(7), true)
},
);
assert_eq!(
retry,
Some(outcome(Some(7), true)),
"a refused image must re-classify, not be skipped as 'seen'"
);
assert_eq!(
classify_calls.get(),
2,
"classify ran again for the un-shown image"
);
// Now the show LANDS: note_fired commits the classify-dedup too, so the
// same content past the cooldown does NOT re-classify (panic if it does).
let landed = retry.unwrap();
state.note_fired(&landed, t1);
let t2 = t1 + FIRE_COOLDOWN + Duration::from_secs(1);
let after = state.poll(
t2,
|| Some(7),
|| panic!("a shown image must not re-classify"),
);
assert_eq!(
after, None,
"a successfully shown image is deduped post-cooldown"
);
}
#[test]
fn cooldown_blocks_fire_until_elapsed() {
let mut state = ClipboardFocusTipState::default();
let t0 = Instant::now();
fire_via_poll(&mut state, t0, 1);
// A different copy mid-cooldown classifies (changeCount changed) but
// should_fire refuses while the cooldown holds.
let during = t0 + Duration::from_secs(5);
let o2 = state
.poll(during, || Some(2), || outcome(Some(2), true))
.expect("a new changeCount classifies");
assert!(!state.should_fire(&o2, during), "inside the cooldown");
// After the cooldown a fresh copy fires again.
let after = t0 + FIRE_COOLDOWN + Duration::from_secs(1);
let o3 = state
.poll(after, || Some(3), || outcome(Some(3), true))
.expect("a new changeCount classifies");
assert!(state.should_fire(&o3, after), "cooldown over");
}
#[test]
fn no_image_never_fires() {
let state = ClipboardFocusTipState::default();
let t0 = Instant::now();
assert!(!state.should_fire(&outcome(Some(3), false), t0));
}
#[test]
fn refused_show_burns_nothing() {
let state = ClipboardFocusTipState::default();
let t0 = Instant::now();
let got = outcome(Some(4), true);
assert!(state.should_fire(&got, t0));
// Caller could not paint (e.g. modal raced in) and did NOT commit: the
// same outcome stays fireable and no cooldown started.
assert!(state.should_fire(&got, t0 + Duration::from_secs(1)));
assert!(!state.in_cooldown(t0 + Duration::from_secs(1)));
}
#[test]
fn missing_change_count_still_fires_under_cooldown_cap() {
let mut state = ClipboardFocusTipState::default();
let t0 = Instant::now();
let got = outcome(None, true);
assert!(state.should_fire(&got, t0));
state.note_fired(&got, t0);
assert!(
!state.should_fire(&got, t0 + Duration::from_secs(1)),
"cooldown still caps when dedup is unavailable"
);
}
}
@@ -0,0 +1,412 @@
//! The ephemeral tip primitive: a single-slot, TTL'd, dedup-keyed banner hint
//! and the show/seen-count gating state that drives it.
use std::collections::HashMap;
use ratatui::text::Line;
/// Default tip lifetime in animation ticks (~3 s: 90 ticks at the default
/// 30 fps animation cadence).
///
/// Expiry takes N+1 ticks: [`EphemeralTipState::tick`] checks `== 0` *before*
/// decrementing, so a tip shown with `ticks_remaining = N` survives N ticks
/// and is cleared on the (N+1)th.
pub const DEFAULT_TIP_TICKS: u16 = 90;
/// Whether the tip row can render given UI occlusion and the screen height.
/// Single predicate shared by the show gate, the banner-height reservation,
/// and the paint so the three can never drift. Shares the layout's
/// short-terminal threshold so the tip row appears exactly when the optional
/// rows above the prompt do.
pub fn tip_row_renderable(occluded: bool, area_height: u16) -> bool {
!occluded && area_height > crate::views::agent::SHORT_TERMINAL_ROWS
}
/// A transient one-line hint shown in the banner row above the prompt.
#[derive(Debug, Clone)]
pub struct EphemeralTip {
/// Dedup key: re-showing the same key refreshes the TTL instead of
/// stacking, and [`EphemeralTipState::clear`] only removes a match.
pub key: &'static str,
/// Pre-styled spans (dim text with a highlighted key chord).
pub line: Line<'static>,
/// Remaining animation ticks before the tip expires.
pub ticks_remaining: u16,
/// In-memory seen-count map key paired with the per-session show cap:
/// `Some((key, cap))` stops showing once this session's count for `key`
/// reaches `cap`; `None` for tips that are never seen-gated. The count
/// lives only in `AppView::tip_seen_counts` (per session, never on disk).
pub session_seen: Option<(&'static str, u32)>,
/// Ambient hint, not contextual to the draft being edited: submission
/// ([`EphemeralTipState::clear_on_submit`]) does NOT retire it, and its
/// TTL burns only while the tip row can actually paint (occlusion pauses
/// instead of expiring it off-screen — see
/// `AgentView::ephemeral_tip_needs_tick`). Default `false` keeps the
/// edit-contextual tips' retire-on-submit + burn-while-occluded behavior.
pub ambient: bool,
}
impl EphemeralTip {
/// Build a tip with the default TTL and no seen-gating.
pub fn new(key: &'static str, line: Line<'static>) -> Self {
Self {
key,
line,
ticks_remaining: DEFAULT_TIP_TICKS,
session_seen: None,
ambient: false,
}
}
/// Gate the tip on a per-session in-memory seen count: it stops showing
/// once this session's count reaches `cap` (resets every new pager run).
pub fn with_session_seen_cap(mut self, key: &'static str, cap: u32) -> Self {
self.session_seen = Some((key, cap));
self
}
/// Mark the tip ambient (survives submission; TTL pauses while occluded).
pub fn ambient(mut self) -> Self {
self.ambient = true;
self
}
}
/// Single-slot ephemeral tip state. Seen counts are NOT stored here — gating
/// runs against the app-level map passed into [`Self::show`], so there is
/// exactly one copy of that state.
#[derive(Debug, Default)]
pub struct EphemeralTipState {
slot: Option<EphemeralTip>,
}
impl EphemeralTipState {
/// Show `tip`, replacing any currently shown tip. Re-showing the key
/// already on screen only refreshes the TTL (no second count increment).
///
/// Seen-gating runs against `seen_counts` (the app-level per-session map):
/// a tip whose count reached its cap is a no-op. A passing show increments
/// the map in place. Returns true when the tip was newly shown (false on a
/// same-key TTL refresh or a gated no-op).
///
/// Pager code must go through `AgentView::show_ephemeral_tip`, which
/// adds the renderability gate — calling this directly skips it and can
/// burn a seen count on a tip the user never sees (tests only).
pub(crate) fn show(
&mut self,
tip: EphemeralTip,
seen_counts: &mut HashMap<&'static str, u32>,
) -> bool {
// Refresh before gating so a visible tip never goes dark mid-TTL
// just because its first show already reached the cap.
if self.slot.as_ref().is_some_and(|cur| cur.key == tip.key) {
self.slot = Some(tip);
return false;
}
if let Some((seen_key, cap)) = tip.session_seen
&& seen_counts.get(seen_key).copied().unwrap_or(0) >= cap
{
return false;
}
if let Some(replaced) = self.slot.take() {
log_dismissed(replaced.key, DismissReason::Replaced);
}
crate::unified_log::info(
"tip.shown",
None,
Some(serde_json::json!({ "key": tip.key })),
);
if let Some((key, _cap)) = tip.session_seen {
let count = seen_counts.get(key).copied().unwrap_or(0).saturating_add(1);
seen_counts.insert(key, count);
}
self.slot = Some(tip);
true
}
/// Tick the TTL. Call once per animation tick.
/// Returns true when the tip expired and was removed (needs redraw).
pub fn tick(&mut self) -> bool {
if let Some(ref mut tip) = self.slot {
if tip.ticks_remaining == 0 {
let key = tip.key;
self.slot = None;
log_dismissed(key, DismissReason::Expired);
return true;
}
tip.ticks_remaining = tip.ticks_remaining.saturating_sub(1);
}
false
}
/// Whether a tip is on screen (drives `needs_animation` when it can tick).
pub fn is_active(&self) -> bool {
self.slot.is_some()
}
/// Remaining TTL ticks, if a tip is active.
pub fn ticks_remaining(&self) -> Option<u16> {
self.slot.as_ref().map(|t| t.ticks_remaining)
}
/// The active tip's pre-styled line, if any.
pub fn line(&self) -> Option<&Line<'static>> {
self.slot.as_ref().map(|t| &t.line)
}
/// The active tip's dedup key, if any (drives accept-site attribution).
pub fn current_key(&self) -> Option<&'static str> {
self.slot.as_ref().map(|t| t.key)
}
/// Clear the tip only when `key` matches the one on screen.
/// Returns true when a tip was removed (needs redraw).
pub fn clear(&mut self, key: &str) -> bool {
if self.slot.as_ref().is_some_and(|t| t.key == key) {
self.dismiss();
return true;
}
false
}
/// Clear any tip (e.g. on prompt submit).
/// Returns true when a tip was removed (needs redraw).
pub fn clear_all(&mut self) -> bool {
if self.slot.is_some() {
self.dismiss();
return true;
}
false
}
/// Submission retire: clear the tip unless it is ambient (an ambient tip
/// is not about the draft that was just submitted, so it lives out its
/// TTL across the submit). Returns true when a tip was removed.
pub fn clear_on_submit(&mut self) -> bool {
if self.slot.as_ref().is_some_and(|t| t.ambient) {
return false;
}
self.clear_all()
}
/// Whether the active tip (if any) is ambient — drives the TTL pause
/// while the tip row cannot paint.
pub(crate) fn active_is_ambient(&self) -> bool {
self.slot.as_ref().is_some_and(|t| t.ambient)
}
fn dismiss(&mut self) {
if let Some(tip) = self.slot.take() {
log_dismissed(tip.key, DismissReason::Cleared);
}
}
}
/// Why a tip left the slot, mapped to the `tip.dismissed` telemetry reason.
#[derive(Debug, Clone, Copy)]
enum DismissReason {
/// A different-keyed tip took the slot.
Replaced,
/// The TTL ran out.
Expired,
/// An explicit clear (keyed clear, `clear_all`, or submit).
Cleared,
}
impl DismissReason {
/// Telemetry string — must stay stable for `tip.dismissed` dashboards.
fn as_str(self) -> &'static str {
match self {
Self::Replaced => "replaced",
Self::Expired => "expired",
Self::Cleared => "cleared",
}
}
}
fn log_dismissed(key: &'static str, reason: DismissReason) {
crate::unified_log::info(
"tip.dismissed",
None,
Some(serde_json::json!({ "key": key, "reason": reason.as_str() })),
);
}
#[cfg(test)]
mod tests {
use super::*;
fn tip(key: &'static str, ticks: u16) -> EphemeralTip {
EphemeralTip {
ticks_remaining: ticks,
..EphemeralTip::new(key, Line::from("test tip"))
}
}
#[test]
fn ttl_expires_and_clears_slot() {
let mut state = EphemeralTipState::default();
assert!(state.show(tip("a", 2), &mut HashMap::new()));
assert!(state.is_active());
assert!(!state.tick()); // 2 -> 1
assert!(!state.tick()); // 1 -> 0
assert!(state.tick()); // 0 -> expired, needs redraw
assert!(!state.is_active());
assert!(state.line().is_none());
assert!(!state.tick(), "empty slot ticks are no-ops");
}
#[test]
fn show_different_key_replaces_current_tip() {
let mut state = EphemeralTipState::default();
let mut counts = HashMap::new();
let _ = state.show(EphemeralTip::new("a", Line::from("first")), &mut counts);
let _ = state.show(EphemeralTip::new("b", Line::from("second")), &mut counts);
assert!(state.is_active());
assert_eq!(state.line(), Some(&Line::from("second")));
assert!(!state.clear("a"), "replaced tip key no longer matches");
assert!(state.clear("b"));
}
#[test]
fn show_same_key_refreshes_ttl() {
let mut state = EphemeralTipState::default();
let mut counts = HashMap::new();
let _ = state.show(tip("a", 3), &mut counts);
assert!(!state.tick()); // 3 -> 2
let _ = state.show(tip("a", 3), &mut counts); // refresh back to 3
for _ in 0..3 {
assert!(!state.tick());
}
assert!(state.tick(), "expires on the refreshed budget, not the old");
}
#[test]
fn seen_gating_counts_up_to_cap_then_blocks() {
let mut state = EphemeralTipState::default();
let mut counts = HashMap::new();
for expected in 1..=2 {
assert!(
state.show(tip("a", 5).with_session_seen_cap("a_seen", 2), &mut counts),
"a fresh show takes the slot and counts"
);
assert_eq!(counts.get("a_seen"), Some(&expected));
assert!(state.clear_all());
}
assert!(
!state.show(tip("a", 5).with_session_seen_cap("a_seen", 2), &mut counts),
"gated show must be a no-op"
);
assert!(!state.is_active(), "gated show must be a no-op");
assert_eq!(
counts.get("a_seen"),
Some(&2),
"blocked show must not count"
);
}
#[test]
fn show_gates_against_preloaded_counts() {
let mut state = EphemeralTipState::default();
// A session count already at the cap (e.g. shown earlier this run)
// blocks the next show.
let mut counts = HashMap::from([("a_seen", 1u32)]);
assert!(!state.show(tip("a", 5).with_session_seen_cap("a_seen", 1), &mut counts));
assert!(!state.is_active());
}
#[test]
fn same_key_refresh_skips_gate_and_recount() {
let mut state = EphemeralTipState::default();
let mut counts = HashMap::new();
assert!(state.show(tip("a", 5).with_session_seen_cap("a_seen", 1), &mut counts));
// Still visible: cap is reached but the refresh must not go dark
// and must not burn another count.
assert!(
!state.show(tip("a", 5).with_session_seen_cap("a_seen", 1), &mut counts),
"same-key refresh neither re-counts nor re-shows"
);
assert!(state.is_active());
assert_eq!(counts.get("a_seen"), Some(&1));
}
#[test]
fn unkeyed_tip_is_never_gated() {
let mut state = EphemeralTipState::default();
let mut counts = HashMap::new();
for _ in 0..3 {
assert!(
state.show(tip("a", 5), &mut counts),
"unkeyed shows always take the slot"
);
assert!(state.is_active());
assert!(state.clear_all());
}
assert!(counts.is_empty(), "unkeyed shows never touch the map");
}
#[test]
fn clear_only_removes_matching_key() {
let mut state = EphemeralTipState::default();
let _ = state.show(tip("a", 5), &mut HashMap::new());
assert!(!state.clear("other"));
assert!(state.is_active());
assert!(state.clear("a"));
assert!(!state.is_active());
assert!(!state.clear("a"), "second clear is a no-op");
}
#[test]
fn current_key_tracks_the_active_tip() {
let mut state = EphemeralTipState::default();
assert_eq!(state.current_key(), None, "empty slot has no key");
let _ = state.show(tip("undo_tip", 5), &mut HashMap::new());
assert_eq!(state.current_key(), Some("undo_tip"));
// A different-keyed show replaces the reported key.
let _ = state.show(tip("plan_nudge", 5), &mut HashMap::new());
assert_eq!(state.current_key(), Some("plan_nudge"));
assert!(state.clear("plan_nudge"));
assert_eq!(state.current_key(), None, "cleared slot reports no key");
}
#[test]
fn clear_all_reports_whether_a_tip_was_removed() {
let mut state = EphemeralTipState::default();
assert!(!state.clear_all());
let _ = state.show(tip("a", 5), &mut HashMap::new());
assert!(state.clear_all());
assert!(!state.clear_all());
}
#[test]
fn clear_on_submit_retires_edit_contextual_but_keeps_ambient() {
let mut state = EphemeralTipState::default();
// Default (edit-contextual) tip: submit retires it.
let _ = state.show(tip("a", 5), &mut HashMap::new());
assert!(state.clear_on_submit());
assert!(!state.is_active());
// Ambient tip: submit is a no-op; an explicit clear_all still works.
let _ = state.show(tip("b", 5).ambient(), &mut HashMap::new());
assert!(!state.clear_on_submit());
assert!(state.is_active(), "ambient tip must survive the submit");
assert!(state.active_is_ambient());
assert!(state.clear_all());
}
#[test]
fn tip_row_renderable_gates_on_occlusion_and_terminal_height() {
assert!(tip_row_renderable(false, 30));
assert!(
!tip_row_renderable(true, 30),
"occluded by permission/question/modal"
);
assert!(!tip_row_renderable(false, 16), "short terminal");
assert!(tip_row_renderable(false, 17));
assert!(
!tip_row_renderable(false, 0),
"unknown size before first draw"
);
}
}
+19
View File
@@ -0,0 +1,19 @@
//! Ephemeral tip primitive: a single-slot, TTL'd hint line rendered in the
//! banner rect above the prompt input.
//!
//! Unlike the toast, an ephemeral tip deliberately survives typing — it is
//! cleared only by TTL expiry, prompt-box submission, or an explicit clear.
//! Tips carrying a seen-count key are show-gated by the app-level, per-session
//! seen-count map (`AppView::tip_seen_counts`) so they stop appearing once seen
//! often enough within a run; that map is in-memory only and resets each run.
pub mod clear_detector;
pub mod clipboard_focus;
pub mod ephemeral;
pub mod plan_nudge;
pub mod render;
pub mod send_now;
pub mod small_screen;
pub mod word_select;
pub use ephemeral::{DEFAULT_TIP_TICKS, EphemeralTip, EphemeralTipState, tip_row_renderable};
@@ -0,0 +1,157 @@
//! Plan-nudge trigger: detects planning keywords typed into the prompt so the
//! pager can hint that Shift+Tab cycles into plan mode first.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use super::EphemeralTip;
use crate::theme::Theme;
/// Ephemeral-tip dedup key for the plan-mode nudge.
pub(crate) const PLAN_NUDGE_KEY: &str = "plan_nudge";
/// Key into the per-session in-memory seen-count map
/// (`AppView::tip_seen_counts`) for the plan nudge. Not persisted to disk.
pub(crate) const PLAN_NUDGE_SEEN_KEY: &str = "plan_nudge_shown_count";
/// The tip stops showing after this many shows within a single session.
const PLAN_NUDGE_SEEN_CAP: u32 = 3;
/// Tight, false-positive-averse allowlist of planning intents (ASCII
/// lowercase). Matched as whole words (see [`prompt_mentions_planning`]) so
/// "explain"/"explanation"/"planet" never trip the "plan" entry.
const PLANNING_KEYWORDS: &[&str] = &[
"plan",
"planning",
"design",
"architect",
"step by step",
"break this down",
"lay out",
"approach",
"strategy",
];
/// Plan-mode chord for the tip copy: always `shift+tab`. Derived from the real
/// `CycleMode` binding (not a literal) — `shift_tab_keys()[0]` is one of the
/// encodings [`crate::input::key::is_shift_tab`] accepts — so it can't drift.
fn plan_chord_label() -> String {
crate::input::key::shift_tab_keys()[0]
.display()
.to_ascii_lowercase()
}
/// Build the "Planning? Check out plan mode via {chord}" tip, seen-gated to
/// [`PLAN_NUDGE_SEEN_CAP`] shows per session (in-memory).
pub fn plan_nudge_tip() -> EphemeralTip {
let theme = Theme::current();
let dim = Style::default().fg(theme.gray);
// Key chord styled like the shortcuts bar (bold secondary on dim text).
let chord = Style::default()
.fg(theme.text_secondary)
.add_modifier(Modifier::BOLD);
EphemeralTip::new(
PLAN_NUDGE_KEY,
Line::from(vec![
Span::styled("Planning? Check out plan mode via ", dim),
Span::styled(plan_chord_label(), chord),
]),
)
.with_session_seen_cap(PLAN_NUDGE_SEEN_KEY, PLAN_NUDGE_SEEN_CAP)
}
/// Whether `text` mentions a planning intent from the tight [`PLANNING_KEYWORDS`]
/// allowlist. Case-insensitive and matched on whole-word boundaries so near
/// neighbours ("explain", "explanation", "planet", "redesign") never match.
/// Non-allocating — runs on the prompt edit hot path.
pub fn prompt_mentions_planning(text: &str) -> bool {
PLANNING_KEYWORDS
.iter()
.any(|kw| contains_whole_word_ci(text, kw))
}
/// Whether ASCII-lowercase `needle` occurs in `haystack` matched
/// case-insensitively and bordered by non-word bytes (or string ends). No
/// allocation: scans bytes and lowercases each candidate byte in place. A byte
/// `>= 0x80` (any UTF-8 multibyte) counts as a word byte, so a keyword touching
/// a non-ASCII letter is conservatively rejected.
fn contains_whole_word_ci(haystack: &str, needle: &str) -> bool {
let hay = haystack.as_bytes();
let need = needle.as_bytes();
if need.is_empty() || hay.len() < need.len() {
return false;
}
let is_word = |b: u8| b.is_ascii_alphanumeric() || b >= 0x80;
let last_start = hay.len() - need.len();
for start in 0..=last_start {
let matches = hay[start..start + need.len()]
.iter()
.zip(need)
.all(|(h, n)| h.to_ascii_lowercase() == *n);
if !matches {
continue;
}
let before_ok = start == 0 || !is_word(hay[start - 1]);
let end = start + need.len();
let after_ok = end >= hay.len() || !is_word(hay[end]);
if before_ok && after_ok {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_planning_keywords_as_whole_words() {
assert!(prompt_mentions_planning("let's plan the refactor"));
assert!(prompt_mentions_planning("Plan it first"));
assert!(prompt_mentions_planning("some planning before we code"));
assert!(prompt_mentions_planning("design the system"));
assert!(prompt_mentions_planning("architect this module"));
assert!(prompt_mentions_planning(
"walk me through this step by step"
));
assert!(prompt_mentions_planning("break this down for me"));
assert!(prompt_mentions_planning("lay out the migration"));
assert!(prompt_mentions_planning("what's your approach?"));
assert!(prompt_mentions_planning("pick a strategy"));
}
#[test]
fn does_not_match_near_neighbours() {
// The headline false positive: "explain" must NOT match "plan".
assert!(!prompt_mentions_planning("explain this code"));
assert!(!prompt_mentions_planning("can you explain the explanation"));
assert!(!prompt_mentions_planning("the planet is round"));
assert!(!prompt_mentions_planning("redesigned the airplane wing"));
assert!(!prompt_mentions_planning("fix the bug in main.rs"));
assert!(!prompt_mentions_planning(""));
}
#[test]
fn plan_nudge_builder_applies_seen_gating() {
// The wiring to pin is that the builder opts into the per-session seen
// gate at all (echoing key/cap would be tautological).
assert_eq!(
plan_nudge_tip().session_seen.map(|(key, _cap)| key),
Some(PLAN_NUDGE_SEEN_KEY)
);
}
#[test]
fn plan_nudge_chord_is_shift_tab() {
// Always shift+tab — derived from the real binding so it can't drift.
assert_eq!(plan_chord_label(), "shift+tab");
// The advertised chord is genuinely one is_shift_tab accepts.
assert!(crate::input::key::is_shift_tab(
&crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::BackTab,
crossterm::event::KeyModifiers::NONE,
)
));
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Tip renderer.
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Widget, Wrap},
};
use crate::render::SafeBuf;
use crate::theme::Theme;
/// Compute the number of rows a tip needs when rendered at the given `width`.
pub fn tip_height(width: u16, tip: &str) -> u16 {
if width == 0 {
return 0;
}
let line = tip_line(tip);
let line_width = line.width() as u16;
if line_width <= width {
1
} else {
// Ceiling division — word wrapping may use slightly more rows than
// a naive character split, but this is a close-enough upper bound.
(line_width as u32)
.div_ceil(width as u32)
.min(u16::MAX as u32) as u16
}
}
fn tip_line(tip: &str) -> Line<'_> {
let theme = Theme::current();
Line::from(vec![
Span::styled(
"Tip: ",
Style::default().fg(theme.gray).add_modifier(Modifier::BOLD),
),
Span::styled(tip, Style::default().fg(theme.gray)),
])
}
/// Render a tip into the provided area, word-wrapping if it exceeds the width.
pub fn render_tip(area: Rect, buf: &mut Buffer, tip: &str) {
if area.height == 0 {
return;
}
let theme = Theme::current();
Paragraph::new(tip_line(tip))
.style(Style::default().bg(theme.bg_base))
.wrap(Wrap { trim: false })
.render(area, buf);
}
/// Blank every cell of `area` (chars, colors, and modifiers) in `color`.
///
/// Modifiers MUST be reset here: ratatui's `Cell::set_style` only *merges*
/// modifiers (`insert(add)` / `remove(sub)`), so a later paint whose style
/// carries no `sub_modifier` inherits whatever BOLD/ITALIC/… an earlier
/// same-frame paint left behind (e.g. the welcome tip's bold `Tip: ` prefix
/// bleeding into the ephemeral tip as "**Queue**d · Enter to send now").
fn clear_rect(buf: &mut Buffer, area: Rect, color: Color) {
for row in 0..area.height {
for col in 0..area.width {
if let Some(cell) = buf.cell_mut((area.x + col, area.y + row)) {
cell.set_char(' ');
cell.fg = color;
cell.bg = color;
cell.modifier = Modifier::empty();
}
}
}
}
/// Render a pre-styled tip line into the banner rect. The whole rect is
/// cleared first (it can be taller than one row when a wrapped session tip
/// reserved it) and the line paints on the first row, truncated at width.
pub fn render_ephemeral_tip(area: Rect, buf: &mut Buffer, line: &Line<'static>) {
if area.height == 0 || area.width == 0 {
return;
}
let theme = Theme::current();
clear_rect(buf, area, theme.bg_base);
buf.set_line_safe(area.x, area.y, line, area.width);
}
#[cfg(test)]
mod tests {
use super::*;
fn row_text(buf: &Buffer, area: Rect, y: u16) -> String {
(0..area.width)
.map(|x| buf.cell((area.x + x, y)).expect("cell in area").symbol())
.collect()
}
#[test]
fn clears_full_rect_and_truncates_to_width() {
let area = Rect::new(0, 0, 8, 2);
let mut buf = Buffer::empty(area);
// Pre-dirty both rows to simulate stale banner content underneath.
buf.set_string(0, 0, "XXXXXXXX", Style::default());
buf.set_string(0, 1, "XXXXXXXX", Style::default());
let line = Line::from("0123456789"); // wider than the rect
render_ephemeral_tip(area, &mut buf, &line);
assert_eq!(row_text(&buf, area, 0), "01234567", "truncated at width");
assert_eq!(
row_text(&buf, area, 1),
" ",
"stale rows below the line are cleared"
);
}
#[test]
fn zero_sized_area_is_a_noop() {
let area = Rect::new(0, 0, 8, 1);
let mut buf = Buffer::empty(area);
buf.set_string(0, 0, "XXXXXXXX", Style::default());
render_ephemeral_tip(Rect::new(0, 0, 8, 0), &mut buf, &Line::from("tip"));
assert_eq!(row_text(&buf, area, 0), "XXXXXXXX", "untouched");
}
/// Regression: a bold underpaint in the banner rect (e.g. the welcome
/// tip's `Tip: ` prefix painted the same frame) must not bleed BOLD into
/// the ephemeral tip. `Cell::set_style` merges modifiers, so the clear
/// pass has to reset them explicitly — otherwise `Queued · Enter …`
/// rendered as bold `Queue` + regular `d` (5 leaked bold cells).
#[test]
fn clears_leaked_modifiers_from_underpaint() {
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
// Simulate the phantom session-tip underpaint: 5 bold cells ("Tip: ").
buf.set_string(
0,
0,
"Tip: never gonna give you up",
Style::default().add_modifier(Modifier::BOLD),
);
// The send-now tip shape: dim text with a single bold key chord.
let dim = Style::default();
let bold = Style::default().add_modifier(Modifier::BOLD);
let line = Line::from(vec![
Span::styled("Queued · ", dim),
Span::styled("Enter", bold),
Span::styled(" to send now", dim),
]);
render_ephemeral_tip(area, &mut buf, &line);
assert_eq!(
row_text(&buf, area, 0).trim_end(),
"Queued · Enter to send now"
);
let bold_cols: Vec<u16> = (0..area.width)
.filter(|&x| {
buf.cell((x, 0))
.expect("cell in area")
.modifier
.contains(Modifier::BOLD)
})
.collect();
// "Queued · " occupies cols 0..9, "Enter" cols 9..14.
assert_eq!(
bold_cols,
(9..14).collect::<Vec<u16>>(),
"only the Enter chord may be bold — no leak from the underpaint"
);
}
}
@@ -0,0 +1,66 @@
//! Tip after queuing a follow-up while a turn is running: advertise that
//! bare Enter on an empty prompt force-sends the top queued item ("send now").
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use super::EphemeralTip;
use crate::theme::Theme;
/// Ephemeral-tip dedup key for the queued-follow-up send-now hint.
pub(crate) const SEND_NOW_TIP_KEY: &str = "send_now_tip";
/// Key into the per-session in-memory seen-count map for this tip.
pub(crate) const SEND_NOW_TIP_SEEN_KEY: &str = "send_now_tip_shown_count";
/// Stop showing after this many shows within a single session.
const SEND_NOW_TIP_SEEN_CAP: u32 = 3;
/// Build "Queued · Enter to send now", seen-gated to
/// [`SEND_NOW_TIP_SEEN_CAP`] shows per session (in-memory).
///
/// After a mid-turn queue the composer is empty, so a second Enter force-sends
/// the top queued follow-up without learning a special chord.
pub fn send_now_tip() -> EphemeralTip {
let theme = Theme::current();
let dim = Style::default().fg(theme.gray);
let key_style = Style::default()
.fg(theme.text_secondary)
.add_modifier(Modifier::BOLD);
EphemeralTip::new(
SEND_NOW_TIP_KEY,
Line::from(vec![
Span::styled("Queued · ", dim),
Span::styled("Enter", key_style),
Span::styled(" to send now", dim),
]),
)
.with_session_seen_cap(SEND_NOW_TIP_SEEN_KEY, SEND_NOW_TIP_SEEN_CAP)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn send_now_tip_builder_applies_seen_gating() {
assert_eq!(
send_now_tip().session_seen.map(|(key, _cap)| key),
Some(SEND_NOW_TIP_SEEN_KEY)
);
assert_eq!(
send_now_tip().session_seen.map(|(_, cap)| cap),
Some(SEND_NOW_TIP_SEEN_CAP)
);
}
#[test]
fn send_now_tip_advertises_enter() {
let tip = send_now_tip();
let text: String = tip.line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("Enter") && text.contains("send now") && text.contains("Queued"),
"expected queued/send-now copy with Enter, got {text:?}"
);
}
}
@@ -0,0 +1,106 @@
//! Small-screen tip: on smallish terminals, advertise that `/compact-mode`
//! reclaims the padding and sticky-header rows.
//!
//! Shown once per run, at the first stable agent-view draw only (never on a
//! later resize): below the band auto-compact already trims the chrome,
//! above it the default layout is roomy enough that the hint is noise.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use super::EphemeralTip;
use crate::theme::Theme;
use crate::views::agent::AUTO_COMPACT_MAX_ROWS;
/// Ephemeral-tip dedup key for the small-screen `/compact-mode` hint.
pub(crate) const SMALL_SCREEN_TIP_KEY: &str = "small_screen_tip";
/// Key into the per-session in-memory seen-count map for this tip.
pub(crate) const SMALL_SCREEN_TIP_SEEN_KEY: &str = "small_screen_tip_shown_count";
/// Stop showing after this many shows within a single session.
const SMALL_SCREEN_TIP_SEEN_CAP: u32 = 1;
/// Tallest terminal (rows) that still counts as "tight on space".
const SMALL_SCREEN_TIP_MAX_ROWS: u16 = 28;
/// Whether `rows` falls in the band the tip targets: taller than the
/// auto-compact threshold (where compact is the user's call) and no taller
/// than [`SMALL_SCREEN_TIP_MAX_ROWS`].
pub fn small_screen_band_contains(rows: u16) -> bool {
(AUTO_COMPACT_MAX_ROWS + 1..=SMALL_SCREEN_TIP_MAX_ROWS).contains(&rows)
}
/// Build "Tight on space? Try /compact-mode", seen-gated to
/// [`SMALL_SCREEN_TIP_SEEN_CAP`] show per session (in-memory). Ambient: it is
/// not about the draft, so submitting a prompt right after the promote must
/// not retire it, and occlusion pauses (not burns) its TTL — otherwise a
/// quick Enter into a multi-second turn reduces the tip to a sub-second blink.
pub fn small_screen_tip() -> EphemeralTip {
let theme = Theme::current();
let dim = Style::default().fg(theme.gray);
// Command token styled like the other tips style their chord/key tokens.
let command = Style::default()
.fg(theme.text_secondary)
.add_modifier(Modifier::BOLD);
EphemeralTip::new(
SMALL_SCREEN_TIP_KEY,
Line::from(vec![
Span::styled("Tight on space? Try ", dim),
Span::styled("/compact-mode", command),
]),
)
.with_session_seen_cap(SMALL_SCREEN_TIP_SEEN_KEY, SMALL_SCREEN_TIP_SEEN_CAP)
.ambient()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn band_starts_one_row_above_the_auto_compact_threshold() {
// At the threshold auto-compact engages instead; one above is the
// first height the tip targets.
assert!(!small_screen_band_contains(AUTO_COMPACT_MAX_ROWS));
assert!(small_screen_band_contains(AUTO_COMPACT_MAX_ROWS + 1));
}
#[test]
fn band_ends_at_the_max_row_boundary() {
assert!(small_screen_band_contains(SMALL_SCREEN_TIP_MAX_ROWS));
assert!(!small_screen_band_contains(SMALL_SCREEN_TIP_MAX_ROWS + 1));
}
#[test]
fn band_rejects_degenerate_heights() {
assert!(!small_screen_band_contains(0));
assert!(!small_screen_band_contains(1));
}
#[test]
fn small_screen_tip_builder_applies_seen_gating() {
assert_eq!(
small_screen_tip().session_seen.map(|(key, _cap)| key),
Some(SMALL_SCREEN_TIP_SEEN_KEY)
);
assert_eq!(
small_screen_tip().session_seen.map(|(_, cap)| cap),
Some(SMALL_SCREEN_TIP_SEEN_CAP)
);
}
#[test]
fn small_screen_tip_advertises_compact_mode() {
let tip = small_screen_tip();
let text: String = tip.line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "Tight on space? Try /compact-mode");
}
#[test]
fn small_screen_tip_is_ambient() {
// Must survive prompt submission and pause TTL under occlusion —
// otherwise a quick Enter into a slow turn blinks it away.
assert!(small_screen_tip().ambient);
}
}
@@ -0,0 +1,108 @@
//! Tip after double-clicking scrollback while text-selection mode is fold/nav:
//! advertise Settings → Text selection → Word select.
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use super::EphemeralTip;
use crate::theme::Theme;
/// Ephemeral-tip dedup key for the word-select settings hint.
pub(crate) const WORD_SELECT_TIP_KEY: &str = "word_select_tip";
/// Key into the per-session in-memory seen-count map for this tip.
pub(crate) const WORD_SELECT_TIP_SEEN_KEY: &str = "word_select_tip_shown_count";
/// Stop showing after this many shows within a single session.
const WORD_SELECT_TIP_SEEN_CAP: u32 = 3;
/// Tip lifetime: ~20s at the 30fps animation cadence (vs the ~3s default).
/// This tip is a call to action (read → decide → press the chord), not a
/// glanceable notice, so it gets a much longer window. Ambient + the
/// retire-on-prompt-edit hook bound the window: it pauses while occluded and
/// dies the moment the user starts doing something else.
pub(crate) const WORD_SELECT_TIP_TICKS: u16 = 600;
/// The accept chord advertised by the tip: pressing it while the tip is on
/// screen flips `keep_text_selection` to `word_select` (see
/// `Action::AcceptWordSelectTip`). Tip-scoped — outside the tip's TTL the
/// chord keeps its normal meaning (prompt yank), and any prompt edit retires
/// the tip so the long TTL cannot shadow a kill→yank sequence.
pub(crate) const WORD_SELECT_ACCEPT_CHORD: &str = "Ctrl+Y";
/// Build "Want double-click to select? /settings → Text selection · Ctrl+Y:
/// enable now", seen-gated to [`WORD_SELECT_TIP_SEEN_CAP`] shows per session
/// (in-memory).
///
/// Fires when double-click runs the fold/nav path (default `flash` / `hold`)
/// so users who expected terminal-like word highlight learn about the setting
/// — or flip it on the spot with the advertised chord.
pub fn word_select_tip() -> EphemeralTip {
let theme = Theme::current();
let dim = Style::default().fg(theme.gray);
let key_style = Style::default()
.fg(theme.text_secondary)
.add_modifier(Modifier::BOLD);
EphemeralTip {
ticks_remaining: WORD_SELECT_TIP_TICKS,
..EphemeralTip::new(
WORD_SELECT_TIP_KEY,
Line::from(vec![
Span::styled("Want double-click to select? ", dim),
Span::styled("/settings", key_style),
Span::styled(" → Text selection · ", dim),
Span::styled(WORD_SELECT_ACCEPT_CHORD, key_style),
Span::styled(": enable now", dim),
]),
)
.with_session_seen_cap(WORD_SELECT_TIP_SEEN_KEY, WORD_SELECT_TIP_SEEN_CAP)
// Ambient: not about the draft being edited — an unrelated submit
// keeps it, and occlusion (permission ask, modal) pauses the TTL
// instead of burning the decision window off-screen.
.ambient()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn word_select_tip_builder_applies_seen_gating() {
assert_eq!(
word_select_tip().session_seen.map(|(key, _cap)| key),
Some(WORD_SELECT_TIP_SEEN_KEY)
);
assert_eq!(
word_select_tip().session_seen.map(|(_, cap)| cap),
Some(WORD_SELECT_TIP_SEEN_CAP)
);
}
/// The CTA window: long TTL + ambient (pauses while occluded, survives an
/// unrelated submit). Retire-on-prompt-edit bounds it — see the
/// `PromptEvent::Edited` hook in `agent_view/prompt.rs`.
#[test]
fn word_select_tip_has_long_ambient_window() {
let tip = word_select_tip();
assert_eq!(tip.ticks_remaining, WORD_SELECT_TIP_TICKS);
assert!(
tip.ticks_remaining > super::super::DEFAULT_TIP_TICKS,
"CTA tip must outlive the glanceable default"
);
assert!(tip.ambient, "occlusion must pause, not burn, the window");
}
#[test]
fn word_select_tip_advertises_settings_and_accept_chord() {
let tip = word_select_tip();
let text: String = tip.line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("double-click to select")
&& text.contains("/settings")
&& text.contains("Text selection")
&& text.contains(WORD_SELECT_ACCEPT_CHORD),
"expected settings path + accept chord copy, got {text:?}"
);
}
}