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();