Improve TUI OTP readability

This commit is contained in:
Hermes Agent
2026-08-10 12:22:53 +00:00
parent d3427f3be6
commit cec1cc3500
6 changed files with 433 additions and 65 deletions

View File

@@ -34,7 +34,9 @@ submits a command or creates extra entry lines. Losing terminal ownership
(including terminal suspension/focus loss) immediately revokes the lease,
cancels Git and clipboard work, removes plaintext presentation state, and
returns to the locked screen. `NO_COLOR` or `TERM=dumb` selects the monochrome
fallback while preserving focus through text and bold attributes.
fallback while preserving focused selections through reverse-video attributes.
Selected rows override nested field colors so labels, masked values, metadata,
and OTP codes keep one high-contrast foreground across the complete selection.
## Pass and pass-otp coverage matrix
@@ -65,7 +67,7 @@ where one is listed.
| git fetch/sync | cancellable progress view | — | `:git fetch …`, `:git sync …` |
| git pull/push | cancellable progress view | `g p` / `g P` | `:git pull …`, `:git push …` |
| git conflict resolution | conflict view | — | `:git resolve-local`, `:git resolve-remote` |
| otp code | focused OTP field | `o c` | `:otp code ENTRY` |
| otp code | responsive entry OTP panel/compact field | `o c` | `:otp code ENTRY` |
| otp code clipboard | clipboard feedback | `o y` | `:otp code --clip ENTRY` |
| otp insert/append | masked OTP forms | `o i` / `o a` | `:otp insert …`, `:otp append …` |
| otp URI terminal/clipboard/QR | secret popup/clipboard/QR | `o u` / `o x` / `o q` | `:otp uri [--clip\|--qrcode] ENTRY` |
@@ -79,6 +81,13 @@ ticks, storage completion, Git progress, and clipboard timers do not extend the
authentication lease. Clipboard and QR payloads are storage-produced,
zeroizing values and disappear immediately on relock.
Opening an entry with a TOTP field requests its read-only code without requiring
field focus. If the viewer pane can retain both the code and field list, the TUI
renders five-row digit-only glyphs and a storage-period-driven countdown bar.
Shorter panes fall back to a leading inline code and remaining-seconds label.
The displayed TOTP survives field navigation, refreshes at its exact storage
boundary, and is removed when the entry closes or the application relocks.
`:quit` is a TUI convenience. Shell-completion script generation is a CLI
build-time/integration surface; interactive Tab completion replaces it here.

View File

@@ -138,6 +138,9 @@ impl OtpDisplay {
pub fn counter(&self) -> Option<u64> {
self.validity.counter()
}
pub fn period_seconds(&self) -> Option<u64> {
self.validity.period()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -542,19 +545,33 @@ impl App {
return None;
}
let viewer = self.viewer.as_ref()?;
let field = viewer.focused_field()?;
let otp = field.metadata().otp()?;
if otp.kind() != ironstorage::otp::OtpKind::Totp {
return None;
}
let entry = self.selected_entry.clone()?;
if self.otp_display.as_ref().is_some_and(|display| {
display.entry == entry
&& display.field == Some(field.id())
&& display.remaining_seconds != Some(0)
}) {
return None;
}
let field = if let Some(display) = self
.otp_display
.as_ref()
.filter(|display| display.entry == entry)
{
match display.remaining_seconds {
Some(0) => {
let field_id = display.field?;
viewer.document().fields().iter().find(|field| {
field.id() == field_id
&& field
.metadata()
.otp()
.is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
})?
}
Some(_) | None => return None,
}
} else {
viewer.document().fields().iter().find(|field| {
field
.metadata()
.otp()
.is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
})?
};
self.otp_pending = true;
Some((entry, field.id()))
}
@@ -998,13 +1015,11 @@ impl App {
Action::FocusNext if self.mode == Mode::Viewer => {
if let Some(viewer) = self.viewer.as_mut() {
viewer.focus_next();
self.otp_display = None;
}
}
Action::FocusPrevious if self.mode == Mode::Viewer => {
if let Some(viewer) = self.viewer.as_mut() {
viewer.focus_previous();
self.otp_display = None;
}
}
Action::FocusNext if self.mode == Mode::Editor => {

View File

@@ -2,11 +2,12 @@
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
};
use zeroize::Zeroizing;
use crate::{
action::{context_actions, help_actions},
@@ -17,6 +18,8 @@ use crate::{
const MINIMUM_WIDTH: u16 = 40;
const MINIMUM_HEIGHT: u16 = 8;
const LARGE_OTP_HEIGHT: u16 = 8;
const LARGE_OTP_ROWS: usize = 5;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ColorCapability {
@@ -69,6 +72,9 @@ pub fn draw_with_color_capability(frame: &mut Frame, app: &App, capability: Colo
draw_inner(frame, app);
if capability == ColorCapability::Monochrome {
for cell in &mut frame.buffer_mut().content {
if cell.fg == Color::White && cell.bg == Color::Blue {
cell.modifier.insert(Modifier::REVERSED);
}
cell.set_fg(Color::Reset).set_bg(Color::Reset);
}
}
@@ -232,6 +238,56 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
.wrap(Wrap { trim: false }),
panes[0],
);
if app.mode() == Mode::Viewer
&& let Some(viewer) = app.viewer()
&& large_otp_fits(app, viewer, panes[1])
{
let main_rows =
Layout::vertical([Constraint::Length(LARGE_OTP_HEIGHT), Constraint::Min(3)])
.split(panes[1]);
render_large_otp(
frame,
app.otp_display().expect("large OTP was checked"),
main_rows[0],
);
frame.render_widget(
Paragraph::new(viewer_lines(viewer, None))
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
.block(pane_block(
mode_title(app.mode()),
app.focus() == PaneFocus::Main,
))
.wrap(Wrap { trim: false }),
main_rows[1],
);
return;
}
if app.mode() == Mode::Viewer
&& let Some(viewer) = app.viewer()
&& let Some(display) = matching_totp_display(app, viewer)
{
let (otp_area, details_area) = if panes[1].height >= 6 {
let rows =
Layout::vertical([Constraint::Length(3), Constraint::Min(3)]).split(panes[1]);
(rows[0], Some(rows[1]))
} else {
(panes[1], None)
};
render_compact_otp(frame, display, otp_area);
if let Some(details_area) = details_area {
frame.render_widget(
Paragraph::new(viewer_lines(viewer, None))
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
.block(pane_block(
mode_title(app.mode()),
app.focus() == PaneFocus::Main,
))
.wrap(Wrap { trim: false }),
details_area,
);
}
return;
}
let main = match app.mode() {
Mode::Viewer => app.viewer().map_or_else(
|| Paragraph::new(main_text(app)),
@@ -267,6 +323,146 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
);
}
fn large_otp_fits(app: &App, viewer: &EntryViewer, area: Rect) -> bool {
let Some(display) = matching_totp_display(app, viewer) else {
return false;
};
let code = display.code().expose();
let glyph_width = code.len().saturating_mul(4).saturating_sub(1);
!code.is_empty()
&& code.iter().all(u8::is_ascii_digit)
&& usize::from(area.width.saturating_sub(2)) >= glyph_width
&& area.height >= LARGE_OTP_HEIGHT.saturating_add(3)
}
fn matching_totp_display<'a>(
app: &'a App,
viewer: &EntryViewer,
) -> Option<&'a crate::app::OtpDisplay> {
let display = app.otp_display()?;
if app.selected_entry() != Some(display.entry())
|| display.remaining_seconds().is_none()
|| display.period_seconds().is_none()
{
return None;
}
let field_id = display.field()?;
viewer
.document()
.fields()
.iter()
.any(|field| {
field.id() == field_id
&& field
.metadata()
.otp()
.is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
})
.then_some(display)
}
fn render_large_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: Rect) {
let glyphs = (0..LARGE_OTP_ROWS)
.map(|row| otp_ascii_row(display.code().expose(), row))
.collect::<Vec<_>>();
let remaining = display.remaining_seconds().unwrap_or_default();
let period = display.period_seconds().unwrap_or(1);
let inner_width = usize::from(area.width.saturating_sub(2));
let mut lines = glyphs
.iter()
.map(|row| {
Line::styled(
row.as_str(),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
})
.collect::<Vec<_>>();
lines.push(Line::styled(
countdown_bar(remaining, period, inner_width),
Style::default().fg(Color::Cyan),
));
frame.render_widget(
Paragraph::new(lines)
.alignment(Alignment::Center)
.block(Block::bordered().title(format!("TOTP code — {remaining}s remaining"))),
area,
);
}
fn render_compact_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: Rect) {
let code = String::from_utf8_lossy(display.code().expose());
let remaining = display.remaining_seconds().unwrap_or_default();
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
code.as_ref(),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!("{remaining}s remaining")),
]))
.alignment(Alignment::Center)
.block(Block::bordered().title("TOTP code")),
area,
);
}
fn otp_ascii_row(code: &[u8], row: usize) -> Zeroizing<String> {
let mut rendered = Zeroizing::new(String::with_capacity(
code.len().saturating_mul(4).saturating_sub(1),
));
for (index, digit) in code.iter().copied().enumerate() {
if index != 0 {
rendered.push(' ');
}
rendered.push_str(otp_digit_glyph(digit, row));
}
rendered
}
fn otp_digit_glyph(digit: u8, row: usize) -> &'static str {
const GLYPHS: [[&str; LARGE_OTP_ROWS]; 10] = [
["000", "0 0", "0 0", "0 0", "000"],
[" 1 ", "11 ", " 1 ", " 1 ", "111"],
["222", " 2", "222", "2 ", "222"],
["333", " 3", "333", " 3", "333"],
["4 4", "4 4", "444", " 4", " 4"],
["555", "5 ", "555", " 5", "555"],
["666", "6 ", "666", "6 6", "666"],
["777", " 7", " 7 ", " 7 ", " 7 "],
["888", "8 8", "888", "8 8", "888"],
["999", "9 9", "999", " 9", "999"],
];
let index = usize::from(digit.saturating_sub(b'0'));
GLYPHS
.get(index)
.and_then(|glyph| glyph.get(row))
.copied()
.unwrap_or(" ")
}
fn countdown_bar(remaining: u64, period: u64, width: usize) -> String {
let label = format!("{remaining}s");
let bar_width = width.saturating_sub(label.len().saturating_add(1));
let period = period.max(1);
let remaining = remaining.min(period);
let filled = if remaining == 0 {
0
} else {
usize::try_from((u128::from(remaining) * bar_width as u128).div_ceil(u128::from(period)))
.unwrap_or(bar_width)
.min(bar_width)
};
format!(
"{}{} {label}",
"".repeat(filled),
"".repeat(bar_width.saturating_sub(filled))
)
}
fn pane_block(title: &'static str, focused: bool) -> Block<'static> {
let style = if focused {
Style::default()
@@ -281,6 +477,14 @@ fn pane_block(title: &'static str, focused: bool) -> Block<'static> {
.border_style(style)
}
fn selected_line<'a>(mut spans: Vec<Span<'a>>) -> Line<'a> {
let style = Style::default().bg(Color::Blue).fg(Color::White);
for span in &mut spans {
span.style = span.style.bg(Color::Blue).fg(Color::White);
}
Line::from(spans).style(style)
}
fn sidebar_lines(app: &App) -> Vec<Line<'static>> {
let selected = app.sidebar().selected();
let rows = app.sidebar().visible_window();
@@ -408,8 +612,7 @@ fn viewer_lines<'a>(
),
Style::default().fg(Color::DarkGray),
));
if focused == Some(index)
&& let Some(display) = otp_display
if let Some(display) = otp_display
&& display
.field()
.is_none_or(|display_field| display_field == field.id())
@@ -423,19 +626,21 @@ fn viewer_lines<'a>(
},
|remaining| format!(", {remaining}s remaining"),
);
spans.push(Span::styled(
format!(" code {code}{validity}"),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
));
spans.insert(
0,
Span::styled(
format!("code {code}{validity} "),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
);
}
}
let line = Line::from(spans);
if focused == Some(index) {
line.style(Style::default().bg(Color::Blue).fg(Color::White))
selected_line(spans)
} else {
line
Line::from(spans)
}
})
.collect()
@@ -503,11 +708,10 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
Style::default().fg(Color::Yellow),
));
}
let line = Line::from(spans);
if selected {
line.style(Style::default().bg(Color::Blue).fg(Color::White))
selected_line(spans)
} else {
line
Line::from(spans)
}
})
.collect()
@@ -753,6 +957,14 @@ mod tests {
.iter()
.all(|cell| { cell.fg == Color::Reset && cell.bg == Color::Reset })
);
assert!(
terminal
.backend()
.buffer()
.content
.iter()
.any(|cell| cell.modifier.contains(Modifier::REVERSED))
);
terminal
.draw(|frame| draw_with_color_capability(frame, &app, ColorCapability::Color))
@@ -1020,7 +1232,10 @@ mod tests {
.expect("OTP field")
.id();
app.open_test_document("otp/totp", document);
app.dispatch(crate::action::Action::FocusNext);
assert_eq!(
app.begin_totp_refresh(),
Some(("otp/totp".to_owned(), otp_field))
);
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
@@ -1028,13 +1243,17 @@ mod tests {
entry: "otp/totp".to_owned(),
field: Some(wrong_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 72 },
validity: ironstorage::otp::OtpCodeValidity::Timed {
valid_until: 72,
period: 30,
},
observed_at: 60,
clipboard: false,
tree: None,
}),
});
assert!(!render(120, 20, &app).contains("123456"));
app.dispatch(crate::action::Action::FocusNext);
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
@@ -1042,19 +1261,38 @@ mod tests {
entry: "otp/totp".to_owned(),
field: Some(otp_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 72 },
validity: ironstorage::otp::OtpCodeValidity::Timed {
valid_until: 72,
period: 30,
},
observed_at: 60,
clipboard: false,
tree: None,
}),
});
let code = render(120, 20, &app);
assert!(code.contains("123456"));
assert!(code.contains("12s remaining"));
assert!(!code.contains("JBSWY3DPEHPK3PXP"));
app.dispatch(crate::action::Action::FocusPrevious);
let large = render(120, 20, &app);
assert!(large.contains("TOTP code — 12s remaining"));
assert!(large.contains("222 333"));
assert!(!large.contains("code 123456"));
assert!(!large.contains("JBSWY3DPEHPK3PXP"));
let full_bar = large.matches('█').count();
let compact = render(60, 16, &app);
assert!(compact.contains("123456 — 12s remaining"));
assert!(!compact.contains("TOTP code —"));
app.observe_time(66);
let progressed = render(120, 20, &app);
assert!(progressed.contains("TOTP code — 6s remaining"));
let progressed_bar = progressed.matches('█').count();
assert!(progressed_bar < full_bar);
assert!(progressed_bar > 0);
app.observe_time(72);
assert!(render(120, 20, &app).contains("0s remaining"));
let expired = render(120, 20, &app);
assert!(expired.contains("0s remaining"));
assert_eq!(expired.matches('█').count(), 0);
assert_eq!(
app.begin_totp_refresh(),
Some(("otp/totp".to_owned(), otp_field))
@@ -1066,16 +1304,23 @@ mod tests {
entry: "otp/totp".to_owned(),
field: Some(otp_field),
code: ironstorage::repository::SecretBytes::new(b"654321".to_vec()),
validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 102 },
validity: ironstorage::otp::OtpCodeValidity::Timed {
valid_until: 102,
period: 30,
},
observed_at: 72,
clipboard: false,
tree: None,
}),
});
let refreshed = render(120, 20, &app);
assert!(refreshed.contains("654321"));
assert!(refreshed.contains("30s remaining"));
assert!(!refreshed.contains("123456"));
assert!(refreshed.contains("TOTP code — 30s remaining"));
assert!(refreshed.contains("666 555"));
assert!(refreshed.contains("333 222"));
assert!(!refreshed.contains("222 333"));
assert!(refreshed.matches('█').count() > full_bar);
let minimum = render(40, 8, &app);
assert!(minimum.contains("654321 — 30s remaining"));
let payload = ironstorage::repository::SecretBytes::new(
b"otpauth://totp/test?secret=NEVER-RENDER".to_vec(),
@@ -1100,9 +1345,87 @@ mod tests {
));
let locked = render(120, 20, &app);
assert!(!locked.contains("123456"));
assert!(!locked.contains("666 555"));
assert!(!locked.contains("NEVER-RENDER"));
}
#[test]
fn selected_viewer_spans_override_nested_colors_for_readable_contrast() {
let mut app = App::new();
let document = fixture_document("otp/totp");
let otp_field = document
.fields()
.iter()
.find(|field| field.metadata().otp().is_some())
.expect("OTP field")
.id();
app.open_test_document("otp/totp", document);
while app
.viewer()
.and_then(EntryViewer::focused_field)
.map(|field| field.id())
!= Some(otp_field)
{
app.dispatch(crate::action::Action::FocusNext);
}
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
entry: "otp/totp".to_owned(),
field: Some(otp_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
validity: ironstorage::otp::OtpCodeValidity::Timed {
valid_until: 90,
period: 30,
},
observed_at: 60,
clipboard: false,
tree: None,
}),
});
let viewer = app.viewer().expect("viewer");
let lines = viewer_lines(viewer, app.otp_display());
let selected = &lines[viewer.focused_index().expect("focused field")];
assert!(
selected
.spans
.iter()
.any(|span| span.content.contains("code 123456"))
);
assert!(selected.spans.iter().all(|span| {
span.style.fg == Some(Color::White) && span.style.bg == Some(Color::Blue)
}));
let editor = EntryEditor::new(fixture_document("email/personal"));
let editor_selected = &editor_lines(&editor)[0];
assert!(editor_selected.spans.iter().all(|span| {
span.style.fg == Some(Color::White) && span.style.bg == Some(Color::Blue)
}));
let backend = TestBackend::new(60, 16);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|frame| {
draw_with_color_capability(frame, &app, ColorCapability::Monochrome);
})
.expect("monochrome viewer draw");
let buffer = terminal.backend().buffer();
assert!(
buffer
.content
.iter()
.any(|cell| cell.modifier.contains(Modifier::REVERSED))
);
assert!(
buffer
.content
.iter()
.all(|cell| cell.fg == Color::Reset && cell.bg == Color::Reset)
);
}
#[test]
fn closing_a_document_preserves_the_sidebar_selection() {
let mut app = App::new();

View File

@@ -433,17 +433,18 @@ pub struct OtpCodeOutcome {
///
/// Frontends use this value instead of deriving TOTP periods or inferring HOTP
/// behavior from display strings. A timed code carries its exclusive Unix-time
/// boundary, while a counter-based code identifies the committed HOTP counter.
/// boundary and complete period for progress presentation, while a
/// counter-based code identifies the committed HOTP counter.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OtpCodeValidity {
Timed { valid_until: u64 },
Timed { valid_until: u64, period: u64 },
CounterBased { counter: u64 },
}
impl OtpCodeValidity {
pub fn valid_until(self) -> Option<u64> {
match self {
Self::Timed { valid_until } => Some(valid_until),
Self::Timed { valid_until, .. } => Some(valid_until),
Self::CounterBased { .. } => None,
}
}
@@ -455,6 +456,13 @@ impl OtpCodeValidity {
}
}
pub fn period(self) -> Option<u64> {
match self {
Self::Timed { period, .. } => Some(period),
Self::CounterBased { .. } => None,
}
}
pub fn remaining_at(self, unix_seconds: u64) -> Option<u64> {
self.valid_until()
.map(|valid_until| valid_until.saturating_sub(unix_seconds))
@@ -695,6 +703,7 @@ impl<'a> OtpService<'a> {
.checked_add(1)
.and_then(|counter| counter.checked_mul(period))
.ok_or(OtpError::CounterOverflow)?,
period,
},
});
}
@@ -743,18 +752,21 @@ impl<'a> OtpService<'a> {
committer: &mut impl EntryCommitter,
) -> Result<OtpCodeOutcome, OtpError> {
match uri.kind() {
OtpKind::Totp => Ok(OtpCodeOutcome {
code: uri.code_at(unix_seconds)?,
validity: OtpCodeValidity::Timed {
valid_until: {
let period = uri.period().ok_or(OtpError::NotTotp)?;
(unix_seconds / period)
.checked_add(1)
.and_then(|counter| counter.checked_mul(period))
.ok_or(OtpError::CounterOverflow)?
OtpKind::Totp => {
let period = uri.period().ok_or(OtpError::NotTotp)?;
Ok(OtpCodeOutcome {
code: uri.code_at(unix_seconds)?,
validity: OtpCodeValidity::Timed {
valid_until: {
(unix_seconds / period)
.checked_add(1)
.and_then(|counter| counter.checked_mul(period))
.ok_or(OtpError::CounterOverflow)?
},
period,
},
},
}),
})
}
OtpKind::Hotp => {
let (counter, incremented) = uri.incremented_hotp()?;
let code = incremented.code_for_counter(counter)?;

View File

@@ -478,7 +478,14 @@ fn automatic_code_supports_pass_diff_config_and_opens_git_only_for_hotp() -> Tes
let totp = service.code_automatic("otp/totp", 59, None, &mut provider)?;
assert_eq!(totp.counter(), None);
assert_eq!(totp.valid_until(), Some(60));
assert_eq!(totp.validity(), OtpCodeValidity::Timed { valid_until: 60 });
assert_eq!(
totp.validity(),
OtpCodeValidity::Timed {
valid_until: 60,
period: 30,
}
);
assert_eq!(totp.validity().period(), Some(30));
assert_eq!(totp.remaining_at(59), Some(1));
assert_eq!(totp.remaining_at(60), Some(0));
assert_eq!(repository.read_entry(&totp_path)?, totp_before);
@@ -494,6 +501,7 @@ fn automatic_code_supports_pass_diff_config_and_opens_git_only_for_hotp() -> Tes
OtpCodeValidity::CounterBased { counter: 1 }
);
assert_eq!(hotp.remaining_at(0), None);
assert_eq!(hotp.validity().period(), None);
assert_eq!(service.uri("otp/hotp", &mut provider)?.counter(), Some(1));
let git = GitRepository::open(&repository, identity)?;
assert_eq!(git.log(None)?.len(), initial_commits + 1);

View File

@@ -50,13 +50,14 @@ supports terminal, clipboard, and the shared storage-owned QR matrix renderer.
Clipboard and QR requests never print the underlying code or URI as plaintext.
Every generated code carries an `OtpCodeValidity` value from `crates/storage`.
`Timed { valid_until }` identifies the exclusive Unix-time boundary for TOTP;
frontends call its `remaining_at` method to present a countdown and request a
replacement at zero. `CounterBased { counter }` identifies the HOTP counter
whose increment was committed and must be described as counter-based rather
than time-limited. Frontends must not recover periods from OTP URIs, decrement
an assumed interval, or infer the kind from formatted text. This same contract
is intended for the terminal, desktop, Apple, AutoFill, and watch interfaces.
`Timed { valid_until, period }` identifies the exclusive Unix-time boundary and
complete display interval for TOTP; frontends call its `remaining_at` method to
present a countdown and request a replacement at zero. The `CounterBased`
variant identifies the HOTP counter whose increment was committed and must be
described as counter-based rather than time-limited. Frontends must not recover
periods from OTP URIs, decrement an assumed interval, or infer the kind from
formatted text. This same contract is intended for the terminal, desktop,
Apple, AutoFill, and watch interfaces.
The CLI preserves code-only standard output for pass-compatible pipelines and
reports the non-secret validity description on standard error. Clipboard